From 923e741a90ad87d3ae1717301ded125f6749fc43 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:29:52 -0600 Subject: [PATCH 01/78] Add ordered async reward injections --- AdvancedCore/pom.xml | 5 + .../advancedcore/api/rewards/Reward.java | 209 +++++++++++- .../api/rewards/injected/RewardInject.java | 72 +++- .../api/rewards/injected/RewardInjectInt.java | 57 +++- .../rewards/RewardAsyncInjectionTest.java | 320 ++++++++++++++++++ 5 files changed, 643 insertions(+), 20 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml index 383ffe5bbe..5e33f01bc8 100644 --- a/AdvancedCore/pom.xml +++ b/AdvancedCore/pom.xml @@ -85,6 +85,11 @@ ${project.name} + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + org.apache.maven.plugins maven-shade-plugin diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java index d86d8d2110..bbcdd54e88 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 @@ -8,6 +8,12 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import java.util.logging.Level; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -245,6 +251,152 @@ public void giveInjectedRewards(AdvancedCoreUser user, HashMap p } } + /** + * Gives injected rewards in registration order, waiting for each asynchronous + * injection before evaluating the next one. Synchronous injections retain + * their existing behavior, and post-reward injections run after all normal + * injections have completed. + * + * @param user receiving user + * @param placeholders current placeholders + * @return completion stage that completes after all injections have run + */ + public CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, + HashMap placeholders) { + List postRewards = new ArrayList<>(); + CompletionStage sequence = CompletableFuture.completedFuture(null); + + for (RewardInject inject : plugin.getRewardHandler().getInjectedRewards()) { + if (inject.isPostReward()) { + postRewards.add(inject); + continue; + } + sequence = sequence.thenCompose(ignored -> invokeInjectionAsync(inject, user, placeholders)) + .thenCompose(result -> resumeOnServerThread(user).thenRun(() -> { + if (inject.isAddAsPlaceholder() && result != null) addPlaceholder(inject, result, placeholders); + })); + } + + for (RewardInject inject : postRewards) { + sequence = sequence.thenCompose(ignored -> invokeInjectionAsync(inject, user, placeholders)) + .thenCompose(result -> resumeOnServerThread(user)); + } + return sequence; + } + + private CompletionStage invokeInjectionAsync(RewardInject inject, AdvancedCoreUser user, + HashMap placeholders) { + try { + if (!plugin.isEnabled()) return CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before reward injection completed")); + Supplier> request = () -> requestOnServerThread(user, + () -> requestInjectionAsync(inject, user, placeholders)); + CompletionStage result = inject.isSynchronize() + ? inject.runSynchronizedAsync(request) + : request.get(); + if (result == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null asynchronous result: " + inject.getPath())); + } + return result; + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } + + private CompletionStage requestOnServerThread(AdvancedCoreUser user, + Supplier> request) { + CompletableFuture> handoff = new CompletableFuture<>(); + Runnable invocation = () -> { + if (!plugin.isEnabled()) { + handoff.complete(CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before reward injection completed"))); + return; + } + CompletableFuture result = new CompletableFuture<>(); + // Claim the handoff before invoking user code. If the timeout won, this + // queued task must not produce late reward side effects. + if (!handoff.complete(result)) return; + try { + CompletionStage stage = request.get(); + if (stage == null) { + result.completeExceptionally( + new IllegalStateException("Reward injection returned a null asynchronous result")); + return; + } + stage.whenComplete((value, failure) -> { + if (failure == null) result.complete(value); + else result.completeExceptionally(failure); + }); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }; + Runnable resolvePlayer = () -> { + if (!plugin.isEnabled()) { + handoff.complete(CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before reward injection completed"))); + return; + } + try { + Player player = user.getPlayer(); + if (player != null) plugin.getBukkitScheduler().executeOrScheduleSync(plugin, invocation, player); + else invocation.run(); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + }; + try { + plugin.getBukkitScheduler().executeOrScheduleSync(plugin, resolvePlayer); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + return handoff.orTimeout(getServerThreadDispatchTimeoutMillis(), TimeUnit.MILLISECONDS) + .thenCompose(stage -> stage); + } + + private CompletionStage resumeOnServerThread(AdvancedCoreUser user) { + return requestOnServerThread(user, () -> CompletableFuture.completedFuture(null)); + } + + /** Maximum time to wait when a scheduler drops a task during plugin shutdown. */ + protected long getServerThreadDispatchTimeoutMillis() { + return TimeUnit.SECONDS.toMillis(30); + } + + private CompletionStage requestInjectionAsync(RewardInject inject, AdvancedCoreUser user, + HashMap placeholders) { + if (inject.supportsAsyncRequest()) { + return inject.onRewardRequestAsync(this, user, getConfig().getConfigData(), placeholders); + } + return CompletableFuture.completedFuture(inject.onRewardRequest(this, user, getConfig().getConfigData(), placeholders)); + } + + private void addPlaceholder(RewardInject inject, Object obj, HashMap placeholders) { + String placeholderName = inject.getPlaceholderName(); + String value = ""; + if (obj instanceof Boolean) { + value = obj.toString(); + } else if (obj instanceof String) { + value = (String) obj; + } else if (obj instanceof Double) { + value = obj.toString(); + } else if (obj instanceof Integer) { + value = obj.toString(); + } + plugin.extraDebug("Adding placeholder " + placeholderName + ":" + value); + placeholders.put(placeholderName, value); + } + + private boolean hasAsyncRewardInjection() { + for (RewardInject inject : plugin.getRewardHandler().getInjectedRewards()) { + if (inject.supportsAsyncRequest()) { + return true; + } + } + return false; + } + public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { if (!AdvancedCorePlugin.getInstance().getOptions().isProcessRewards()) { AdvancedCorePlugin.getInstance().debug("Processing rewards is disabled"); @@ -363,6 +515,48 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { * @param rewardOptions rewardOptions */ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { + if (hasAsyncRewardInjection()) { + giveRewardUserAsync(user, phs, rewardOptions).exceptionally(failure -> { + logRewardUserFailure(failure); + return null; + }); + return; + } + + HashMap placeholders = prepareRewardUser(user, phs); + if (placeholders == null) { + return; + } + giveInjectedRewards(user, placeholders); + plugin.debug("Gave " + user.getPlayerName() + " reward " + name); + } + + /** + * Asynchronously gives a reward to a user when an injection opts into the + * asynchronous API. Preparation remains on the calling thread; only the + * opted-in injection chain is asynchronous. + * + * @param user receiving user + * @param phs placeholders + * @param rewardOptions reward options (reserved for API symmetry) + * @return completion stage that completes after reward injections finish + */ + public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap phs, + RewardOptions rewardOptions) { + final HashMap placeholders; + try { + placeholders = prepareRewardUser(user, phs); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + if (placeholders == null) { + return CompletableFuture.completedFuture(null); + } + return giveInjectedRewardsAsync(user, placeholders) + .thenRun(() -> plugin.debug("Gave " + user.getPlayerName() + " reward " + name)); + } + + private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap phs) { Player player = user.getPlayer(); if (player == null) { @@ -391,14 +585,19 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, R } } - final HashMap placeholders = new HashMap<>(phs); - - giveInjectedRewards(user, placeholders); - - plugin.debug("Gave " + user.getPlayerName() + " reward " + name); + return new HashMap<>(phs); } else { plugin.debug(getRewardName() + ": Player == null & forceoffline false, player: " + user.getPlayerName() + "/" + user.getUUID()); + return null; + } + } + + private void logRewardUserFailure(Throwable failure) { + if (plugin.getLogger() != null) { + plugin.getLogger().log(Level.WARNING, "Failed to give reward " + getRewardName(), failure); + } else { + failure.printStackTrace(); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java index 521facb4c3..72ba553e53 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java @@ -1,7 +1,10 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.ArrayList; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Supplier; import org.bukkit.configuration.ConfigurationSection; @@ -16,7 +19,8 @@ import lombok.Getter; import lombok.Setter; -public abstract class RewardInject extends Inject { +public abstract class RewardInject extends Inject { + private CompletableFuture synchronizedAsyncTail = CompletableFuture.completedFuture(null); @Getter private boolean addAsPlaceholder = false; @@ -85,16 +89,68 @@ public void extraDebug(String str) { AdvancedCorePlugin.getInstance().extraDebug(str); } - public boolean hasValidator() { - return getValidate() != null; - } + public boolean hasValidator() { + return getValidate() != null; + } + + /** + * Whether this injection has an asynchronous implementation. Existing + * injections remain synchronous by default. + * + * @return true when {@link #onRewardRequestAsync(Reward, AdvancedCoreUser, + * ConfigurationSection, HashMap)} should be used + */ + public boolean supportsAsyncRequest() { + return false; + } public boolean isEditable() { return !getEditButtons().isEmpty(); } - public abstract Object onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, - HashMap placeholders); + public abstract Object onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, + HashMap placeholders); + + /** + * Asynchronously evaluates this injection. The default implementation keeps + * the compatibility behavior by wrapping the existing synchronous callback. + * + * @param reward reward being given + * @param user receiving user + * @param data reward configuration + * @param placeholders current placeholders + * @return completion stage containing the injection result + */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, data, placeholders)); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } + + /** Serializes a synchronized asynchronous injection through completion, not just invocation. */ + public synchronized CompletionStage runSynchronizedAsync(Supplier> request) { + CompletableFuture result = new CompletableFuture<>(); + synchronizedAsyncTail = synchronizedAsyncTail.handle((ignored, previousFailure) -> null) + .thenCompose(ignored -> { + CompletionStage stage; + try { + stage = request.get(); + if (stage == null) throw new IllegalStateException("Asynchronous reward injection returned null"); + } catch (Throwable failure) { + result.completeExceptionally(failure); + return CompletableFuture.completedFuture(null); + } + return stage.handle((value, failure) -> { + if (failure == null) result.complete(value); + else result.completeExceptionally(failure); + return (Void) null; + }); + }).toCompletableFuture(); + return result; + } public RewardInject postReward() { postReward = true; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectInt.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectInt.java index 8d9400cd20..3b30d74e81 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectInt.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectInt.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.HashMap; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -26,8 +28,8 @@ public RewardInjectInt(String path, int defaultValue) { this.defaultValue = defaultValue; } - @Override - public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, + @Override + public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, HashMap placeholders) { if (data.isInt(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) { int value = data.getInt(getPath(), getDefaultValue()); @@ -39,10 +41,51 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio } return re; } - return null; - } + return null; + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (data.isInt(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) { + int value = data.getInt(getPath(), getDefaultValue()); + AdvancedCorePlugin.getInstance() + .extraDebug(reward.getRewardName() + ": Giving " + getPath() + ", value: " + value); + try { + CompletionStage result = onRewardRequestAsync(reward, user, value, placeholders); + if (result == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null asynchronous result: " + getPath())); + } + return result.thenApply(valueResult -> valueResult == null ? "" + value : valueResult) + .thenApply(valueResult -> (Object) valueResult); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } + return CompletableFuture.completedFuture(null); + } - public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, - HashMap placeholders); + public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, + HashMap placeholders); + + /** + * Typed asynchronous hook for integer injections. Subclasses can override it + * without reimplementing configuration parsing. + * + * @param reward reward being given + * @param user receiving user + * @param num configured integer value + * @param placeholders current placeholders + * @return completion stage containing the typed result + */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, int num, + HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, num, placeholders)); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java new file mode 100644 index 0000000000..870e5b0082 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java @@ -0,0 +1,320 @@ +package com.bencodez.advancedcore.tests.rewards; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.injected.RewardInject; +import com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; + +class RewardAsyncInjectionTest { + + @TempDir + File tempDir; + + private AdvancedCorePlugin plugin; + private RewardHandler handler; + private Reward reward; + private ConfigurationSection data; + private AdvancedCoreUser user; + private AtomicBoolean enabled; + private com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler; + + @BeforeEach + void setUp() { + plugin = mock(AdvancedCorePlugin.class); + enabled = new AtomicBoolean(true); + when(plugin.isEnabled()).thenAnswer(ignored -> enabled.get()); + when(plugin.getDataFolder()).thenReturn(tempDir); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + AdvancedCorePlugin.setInstance(plugin); + handler = new RewardHandler(plugin); + when(plugin.getRewardHandler()).thenReturn(handler); + + YamlConfiguration configuration = new YamlConfiguration(); + data = configuration.createSection("Reward"); + reward = new Reward("AsyncReward", data); + try { + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(reward, plugin); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + user = mock(AdvancedCoreUser.class); + } + + @AfterEach + void tearDown() { + handler.getDelayedTimer().shutdownNow(); + AdvancedCorePlugin.setInstance(null); + } + + @Test + void asyncInjectionsAreSequentialAndPostRewardsWaitForThem() { + List events = new ArrayList<>(); + HashMap placeholders = new HashMap<>(); + CompletableFuture deferred = new CompletableFuture<>(); + + RewardInject first = new RewardInject("First") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, + HashMap ignoredPlaceholders) { + throw new AssertionError("the asynchronous hook should be used"); + } + + @Override + public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("first-start"); + return deferred; + } + }; + first.asPlaceholder("first"); + RewardInject second = new RewardInject("Second") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, + HashMap ignoredPlaceholders) { + throw new AssertionError("the asynchronous hook should be used"); + } + + @Override + public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("second:" + ignoredPlaceholders.get("first")); + return CompletableFuture.completedFuture("second-value"); + } + }; + second.asPlaceholder("second"); + RewardInject post = new RewardInject("Post") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, + HashMap ignoredPlaceholders) { + events.add("post:" + ignoredPlaceholders.get("second")); + return null; + } + }; + post.postReward(); + handler.getInjectedRewards().add(first); + handler.getInjectedRewards().add(second); + handler.getInjectedRewards().add(post); + + CompletionStage result = reward.giveInjectedRewardsAsync(user, placeholders); + assertFalse(result.toCompletableFuture().isDone()); + assertEquals(List.of("first-start"), events); + + deferred.complete("first-value"); + result.toCompletableFuture().join(); + assertEquals(List.of("first-start", "second:first-value", "post:second-value"), events); + assertEquals("first-value", placeholders.get("first")); + assertEquals("second-value", placeholders.get("second")); + } + + @Test + void typedIntegerAsyncHookUsesParsedValue() { + data.set("Amount", 7); + HashMap placeholders = new HashMap<>(); + List received = new ArrayList<>(); + + RewardInjectInt amount = new RewardInjectInt("Amount") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public String onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, int ignoredValue, + HashMap ignoredPlaceholders) { + throw new AssertionError("the asynchronous typed hook should be used"); + } + + @Override + public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, int value, + HashMap ignoredPlaceholders) { + received.add(value); + return CompletableFuture.completedFuture("async-" + value); + } + }; + amount.asPlaceholder("amount"); + handler.getInjectedRewards().add(amount); + + reward.giveInjectedRewardsAsync(user, placeholders).toCompletableFuture().join(); + assertTrue(received.contains(7)); + assertEquals("async-7", placeholders.get("amount")); + } + + @Test + void failedAsyncInjectionStopsDependentRewardsAndPropagates() { + AtomicBoolean dependentRan = new AtomicBoolean(); + RewardInject failing = asyncInjection("Failure", CompletableFuture.failedFuture( + new IllegalStateException("durable update failed")), null); + RewardInject dependent = asyncInjection("Dependent", CompletableFuture.completedFuture(null), dependentRan); + handler.getInjectedRewards().add(failing); + handler.getInjectedRewards().add(dependent); + + CompletionStage result = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertFalse(dependentRan.get()); + } + + @Test + void synchronizedAsyncInjectionSerializesThroughCompletion() { + List> completions = new ArrayList<>(); + List starts = new ArrayList<>(); + RewardInject serialized = new RewardInject("Serialized") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + starts.add(starts.size() + 1); + CompletableFuture completion = new CompletableFuture<>(); + completions.add(completion); + return completion; + } + }; + serialized.synchronize(); + handler.getInjectedRewards().add(serialized); + + CompletionStage first = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + CompletionStage second = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + assertEquals(List.of(1), starts); + + completions.get(0).complete(null); + assertEquals(List.of(1, 2), starts); + completions.get(1).complete(null); + first.toCompletableFuture().join(); + second.toCompletableFuture().join(); + } + + @Test + void shutdownStopsContinuationsAfterPendingInjection() { + CompletableFuture completion = new CompletableFuture<>(); + AtomicBoolean dependentRan = new AtomicBoolean(); + handler.getInjectedRewards().add(asyncInjection("Pending", completion, null)); + handler.getInjectedRewards().add( + asyncInjection("Dependent", CompletableFuture.completedFuture(null), dependentRan)); + CompletionStage result = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + + enabled.set(false); + completion.complete(null); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertFalse(dependentRan.get()); + } + + @Test + void schedulerCancellationCompletesStageExceptionally() { + Reward shortTimeoutReward = new Reward("AsyncReward", data) { + @Override + protected long getServerThreadDispatchTimeoutMillis() { + return 25; + } + }; + try { + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(shortTimeoutReward, plugin); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + AtomicBoolean invoked = new AtomicBoolean(); + handler.getInjectedRewards().add( + asyncInjection("Dropped", CompletableFuture.completedFuture(null), invoked)); + org.mockito.Mockito.reset(scheduler); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + AtomicReference droppedTask = new AtomicReference<>(); + doAnswer(invocation -> { + droppedTask.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + + CompletionStage result = shortTimeoutReward.giveInjectedRewardsAsync(user, new HashMap<>()); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + droppedTask.get().run(); + assertFalse(invoked.get()); + } + + @Test + void injectionCompletionIsNotLimitedBySchedulerHandoffTimeout() throws Exception { + Reward shortTimeoutReward = new Reward("AsyncReward", data) { + @Override + protected long getServerThreadDispatchTimeoutMillis() { + return 25; + } + }; + try { + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(shortTimeoutReward, plugin); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + CompletableFuture completion = new CompletableFuture<>(); + handler.getInjectedRewards().add(asyncInjection("SlowStorage", completion, null)); + + CompletionStage result = shortTimeoutReward.giveInjectedRewardsAsync(user, new HashMap<>()); + Thread.sleep(75); + assertFalse(result.toCompletableFuture().isDone()); + completion.complete(null); + result.toCompletableFuture().join(); + } + + private RewardInject asyncInjection(String path, CompletionStage completion, AtomicBoolean invoked) { + return new RewardInject(path) { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (invoked != null) invoked.set(true); + return completion; + } + }; + } +} From b2a7e976138b00218156e24f09f04294e15aa1f6 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:15:26 -0600 Subject: [PATCH 02/78] Fix async nested reward and queue failures --- .../advancedcore/api/rewards/Reward.java | 112 +++++++++++++++++- .../api/rewards/RewardBuilder.java | 12 ++ .../api/rewards/RewardExecutor.java | 97 +++++++++++++++ .../api/rewards/RewardHandler.java | 22 ++++ .../builtin/RewardAdvancedRewards.java | 36 ++++++ .../api/rewards/builtin/RewardSubRewards.java | 22 ++++ .../api/rewards/injected/RewardInject.java | 18 +++ .../api/user/AdvancedCoreUser.java | 52 +++++++- .../rewards/RewardAsyncInjectionTest.java | 73 ++++++++++++ .../tests/user/AdvancedCoreUserTest.java | 53 ++++++++- 10 files changed, 484 insertions(+), 13 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 bbcdd54e88..0142b28b62 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 @@ -291,7 +291,7 @@ private CompletionStage invokeInjectionAsync(RewardInject inject, Advanc new IllegalStateException("Plugin disabled before reward injection completed")); Supplier> request = () -> requestOnServerThread(user, () -> requestInjectionAsync(inject, user, placeholders)); - CompletionStage result = inject.isSynchronize() + CompletionStage result = inject.isSynchronize() && inject.supportsAsyncSynchronization() ? inject.runSynchronizedAsync(request) : request.get(); if (result == null) { @@ -369,7 +369,15 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan if (inject.supportsAsyncRequest()) { return inject.onRewardRequestAsync(this, user, getConfig().getConfigData(), placeholders); } - return CompletableFuture.completedFuture(inject.onRewardRequest(this, user, getConfig().getConfigData(), placeholders)); + try { + return CompletableFuture.completedFuture( + inject.onRewardRequest(this, user, getConfig().getConfigData(), placeholders)); + } catch (Exception failure) { + // Preserve the legacy per-injection isolation contract while allowing + // opted-in asynchronous injections to propagate durable failures. + failure.printStackTrace(); + return CompletableFuture.completedFuture(null); + } } private void addPlaceholder(RewardInject inject, Object obj, HashMap placeholders) { @@ -390,13 +398,19 @@ private void addPlaceholder(RewardInject inject, Object obj, HashMapThe established void API deliberately remains fire-and-forget. Callers + * that need ordering or durable replay semantics must use this method.

+ * + * @param user receiving user + * @param rewardOptions reward options + * @return completion stage for the complete reward injection chain + */ + public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { + if (!AdvancedCorePlugin.getInstance().getOptions().isProcessRewards()) { + AdvancedCorePlugin.getInstance().debug("Processing rewards is disabled"); + return CompletableFuture.completedFuture(null); + } + + if (rewardOptions == null) rewardOptions = new RewardOptions(); + if (!rewardOptions.getPlaceholders().containsKey("ExecDate")) { + rewardOptions.addPlaceholder("ExecDate", "" + System.currentTimeMillis()); + } + if (!rewardOptions.getPlaceholders().containsKey("date")) { + try { + LocalDateTime ldt = LocalDateTime.now(); + Date date = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant()); + rewardOptions.addPlaceholder("Date", "" + new SimpleDateFormat( + plugin.getOptions().getFormatRewardTimeFormat()).format(date)); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + PlayerRewardEvent event = new PlayerRewardEvent(this, user, rewardOptions); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + plugin.debug("Reward " + name + " was cancelled for " + user.getPlayerName()); + return CompletableFuture.completedFuture(null); + } + if (rewardOptions.isCheckTimed() && (checkDelayed(user, rewardOptions.getPlaceholders()) + || checkTimed(user, rewardOptions.getPlaceholders()))) { + return CompletableFuture.completedFuture(null); + } + if (!rewardOptions.isOnlineSet()) rewardOptions.setOnline(user.isOnline()); + for (RewardPlaceholderHandle handle : plugin.getRewardHandler().getPlaceholders()) { + if (handle.isPreProcess()) rewardOptions.addPlaceholder(handle.getKey(), handle.getValue(this, user)); + } + + boolean allowOffline = false; + boolean canGive = true; + if (!rewardOptions.isIgnoreRequirements()) { + for (RequirementInject inject : plugin.getRewardHandler().getInjectedRequirements()) { + try { + if (!inject.onRequirementRequest(this, user, getConfig().getConfigData(), rewardOptions)) { + canGive = false; + if (!inject.isAllowReattempt()) return CompletableFuture.completedFuture(null); + allowOffline = true; + } + } catch (Exception e) { + plugin.debug("Failed to check requirement " + inject.getPath()); + e.printStackTrace(); + canGive = false; + } + } + } + if (plugin.getOptions().isPauseRewards() || (plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) { + checkRewardFile(); + user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + return CompletableFuture.completedFuture(null); + } + if (((((!rewardOptions.isOnline() || rewardOptions.getServer() != null) && !user.isOnline()) || allowOffline) + && (!isForceOffline() && !rewardOptions.isForceOffline()))) { + if (rewardOptions.isGiveOffline()) { + checkRewardFile(); + user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + } + return CompletableFuture.completedFuture(null); + } + if (canGive || isForceOffline() || rewardOptions.isForceOffline()) { + plugin.debug(name + ": Passed requirements, attempting to give to " + user.getPlayerName() + "/" + + user.getUUID()); + if (hasAsyncRewardInjection()) return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions); + try { + giveRewardUser(user, rewardOptions.getPlaceholders(), rewardOptions); + return CompletableFuture.completedFuture(null); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + return CompletableFuture.completedFuture(null); + } + /** * Give reward user. * diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java index 0c16f10e0a..21a134e719 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java @@ -6,6 +6,8 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.OfflinePlayer; import org.bukkit.configuration.ConfigurationSection; @@ -89,6 +91,16 @@ public void send(AdvancedCoreUser user) { } } + /** Sends this builder's reward and completes when its async injection chain finishes. */ + public CompletionStage sendAsync(AdvancedCoreUser user) { + RewardHandler handler = user.getPlugin().getRewardHandler(); + if (reward == null) { + return data == null ? CompletableFuture.completedFuture(null) + : handler.giveRewardAsync(user, data, path, rewardOptions); + } + return handler.giveRewardAsync(user, reward, rewardOptions); + } + public void send(AdvancedCoreUser... users) { for (AdvancedCoreUser user : users) { send(user); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index 9ec68eb337..a79659701f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -3,6 +3,8 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Bukkit; import org.bukkit.configuration.Configuration; @@ -101,6 +103,31 @@ public void giveReward(AdvancedCoreUser user, ConfigurationSection data, String } } + /** + * Asynchronous counterpart for nested rewards that must preserve injection + * ordering. The legacy void dispatch methods intentionally remain unchanged. + */ + public CompletionStage giveRewardAsync(AdvancedCoreUser user, ConfigurationSection data, String path, + RewardOptions rewardOptions) { + RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); + RewardOptions options = context.getOptions(); + if (path == null || data == null || !plugin.isEnabled()) return CompletableFuture.completedFuture(null); + + if (data.isList(path)) { + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (String nestedReward : new ArrayList<>(data.getStringList(path))) { + sequence = sequence.thenCompose(ignored -> giveRewardAsync(user, nestedReward, options)); + } + return sequence; + } + if (data.isConfigurationSection(path)) { + return giveSectionRewardAsync(user, data, path, context); + } + String nestedReward = data.getString(path, ""); + return nestedReward.isEmpty() ? CompletableFuture.completedFuture(null) + : giveRewardAsync(user, nestedReward, options); + } + public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null) { @@ -116,6 +143,12 @@ public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewar } } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { + if (reward == null || !plugin.isEnabled()) return CompletableFuture.completedFuture(null); + RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); + return reward.giveRewardAsync(user, context.getOptions()); + } + public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null || reward.isEmpty()) { @@ -130,6 +163,16 @@ public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewar giveReward(user, handler.getReward(reward), context.getOptions()); } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { + RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); + if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null); + if (reward.startsWith("/")) { + MiscUtils.getInstance().executeConsoleCommands(user.getPlayerName(), reward, context.getPlaceholders()); + return CompletableFuture.completedFuture(null); + } + return giveRewardAsync(user, handler.getReward(reward), context.getOptions()); + } + public void givePersistedQueueReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null || reward.isEmpty()) { @@ -177,6 +220,43 @@ public void givePersistedQueueReward(AdvancedCoreUser user, String reward, Rewar giveReward(user, resolved, context.getOptions()); } + /** Resolves and awaits a persisted queue item so a failed async injection can be requeued. */ + public CompletionStage givePersistedQueueRewardAsync(AdvancedCoreUser user, String reward, + RewardOptions rewardOptions) { + RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); + if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null); + + String rewardName = reward; + Boolean generatedSnapshot = null; + if (reward.startsWith(QUEUED_REFERENCE_PREFIX)) { + String encoded = reward.substring(QUEUED_REFERENCE_PREFIX.length()); + int modeEnd = encoded.indexOf('/'); + if (modeEnd > 0) { + String mode = encoded.substring(0, modeEnd); + String encodedName = encoded.substring(modeEnd + 1); + if ((mode.equals("snapshot") || mode.equals("normal")) && !encodedName.isEmpty()) { + try { + rewardName = new String(Base64.getUrlDecoder().decode(encodedName), StandardCharsets.UTF_8); + generatedSnapshot = Boolean.valueOf(mode.equals("snapshot")); + } catch (IllegalArgumentException ignored) { + rewardName = reward; + } + } + } + } + Reward resolved; + if (generatedSnapshot != null) { + resolved = generatedSnapshot.booleanValue() ? handler.getQueuedGeneratedReward(rewardName, user.getUUID()) + : handler.getReward(rewardName); + } else if (handler.rewardExist(rewardName) || handler.hasDirectRewardHandle(rewardName)) { + resolved = handler.getReward(rewardName); + } else { + resolved = handler.getQueuedGeneratedReward(rewardName, user.getUUID()); + if (resolved == null) resolved = handler.getReward(rewardName); + } + return giveRewardAsync(user, resolved, context.getOptions()); + } + public void updateReward(Configuration data, String path, RewardOptions rewardOptions) { if (data == null || path == null || !data.isConfigurationSection(path)) { return; @@ -228,4 +308,21 @@ private void giveSectionReward(AdvancedCoreUser user, ConfigurationSection data, + user.getUUID()); giveReward(user, reward, options); } + + private CompletionStage giveSectionRewardAsync(AdvancedCoreUser user, ConfigurationSection data, String path, + RewardExecutionContext context) { + RewardOptions options = context.getOptions(); + String rewardName = context.buildRewardName(path); + DirectlyDefinedReward direct = handler.getDirectlyDefined(path); + SubDirectlyDefinedReward sub = handler.getSubDirectlyDefined(rewardName); + SubRewardResolver resolver = handler.getSubRewardResolver(); + SubDirectlyDefinedReward fileSub = resolver == null ? null : resolver.getFileBackedSubReward(rewardName); + if (context.supportsDirectDispatch() && (direct != null || sub != null || fileSub != null)) { + Reward selected = direct != null ? direct.getReward() : (sub != null ? sub : fileSub).getReward(); + return giveRewardAsync(user, selected, options); + } + Reward selected = new Reward(rewardName, data.getConfigurationSection(path)); + selected.checkRewardFile(); + return giveRewardAsync(user, selected, options); + } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java index 885e062615..4312db52ea 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java @@ -7,6 +7,7 @@ import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -184,14 +185,27 @@ public void giveReward(AdvancedCoreUser user, ConfigurationSection data, String rewardExecutor.giveReward(user, data, path, rewardOptions); } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, ConfigurationSection data, String path, + RewardOptions rewardOptions) { + return rewardExecutor.giveRewardAsync(user, data, path, rewardOptions); + } + public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { rewardExecutor.giveReward(user, reward, rewardOptions); } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { + return rewardExecutor.giveRewardAsync(user, reward, rewardOptions); + } + public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { rewardExecutor.giveReward(user, reward, rewardOptions); } + public CompletionStage giveRewardAsync(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { + return rewardExecutor.giveRewardAsync(user, reward, rewardOptions); + } + public void givePersistedQueueReward(AdvancedCoreUser user, PersistedQueueReference rewardReference, RewardOptions rewardOptions) { if (rewardReference == null) { @@ -200,6 +214,14 @@ public void givePersistedQueueReward(AdvancedCoreUser user, PersistedQueueRefere rewardExecutor.givePersistedQueueReward(user, rewardReference.getReference(), rewardOptions); } + public CompletionStage givePersistedQueueRewardAsync(AdvancedCoreUser user, + PersistedQueueReference rewardReference, RewardOptions rewardOptions) { + if (rewardReference == null) { + return java.util.concurrent.CompletableFuture.completedFuture(null); + } + return rewardExecutor.givePersistedQueueRewardAsync(user, rewardReference.getReference(), rewardOptions); + } + public boolean hasDirectRewardHandle(String reward) { return rewardRegistry.hasDirectRewardHandle(reward); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java index f3701c64ff..f8b2b51d19 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -30,6 +32,23 @@ private RewardAdvancedRewards() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedRewards") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public boolean requiresConfiguredDataForAsync() { + return true; + } + + @Override + public boolean supportsAsyncSynchronization() { + // This injector awaits nested rewards, which can invoke this same + // shared instance. Serializing that recursive chain would deadlock. + return false; + } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -42,6 +61,23 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) + && !isAlwaysForceNoData()) { + return CompletableFuture.completedFuture(null); + } + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (String rewardName : ArrayUtils.convert(data.getConfigurationSection(getPath()).getKeys(false))) { + sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, + data.getConfigurationSection(getPath()), rewardName, + new RewardOptions().setPlaceholders(placeholders) + .setPrefix(reward.getRewardName() + "_AdvancedRewards"))); + } + return sequence.thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java index bc681f1fc7..4cb4beb60d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -27,6 +28,16 @@ private RewardSubRewards() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Rewards") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public boolean requiresConfiguredDataForAsync() { + return true; + } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -36,6 +47,17 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) + && !isAlwaysForceNoData()) { + return java.util.concurrent.CompletableFuture.completedFuture(null); + } + return new RewardBuilder(reward.getConfig().getConfigData(), "Rewards").withPrefix(reward.getName()) + .withPlaceHolder(placeholders).sendAsync(user).thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java index 72ba553e53..b96c055c1e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java @@ -103,6 +103,24 @@ public boolean hasValidator() { public boolean supportsAsyncRequest() { return false; } + + /** + * Whether this asynchronous implementation is relevant only when its + * configured path exists. Existing third-party injectors keep the historic + * always-async opt-in behavior unless they explicitly opt into this guard. + */ + public boolean requiresConfiguredDataForAsync() { + return false; + } + + /** + * Whether asynchronous dispatch may serialize this injection across reward + * chains. Nested reward injectors opt out because awaiting a child that uses + * the same shared injection would otherwise wait on its own unfinished tail. + */ + public boolean supportsAsyncSynchronization() { + return true; + } public boolean isEditable() { return !getEditButtons().isEmpty(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 666e041500..179f53b695 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 @@ -310,6 +310,8 @@ public void checkDelayedTimedRewards() { plugin.debug("Checking timed/delayed for " + getPlayerName()); HashMap timed = getTimedRewards(); HashMap newTimed = new HashMap<>(); + HashMap replayingTimed = new HashMap<>(); + HashMap> replayCompletions = new HashMap<>(); for (Entry entry : timed.entrySet()) { long time = entry.getValue(); @@ -326,7 +328,9 @@ public void checkDelayedTimedRewards() { .withPlaceHolder(ArrayUtils.fromString(placeholders)); replayOptions.addPlaceholder("date", "" + new SimpleDateFormat("EEE, d MMM yyyy HH:mm").format(new Date(time))); - plugin.getRewardHandler().givePersistedQueueReward(this, new PersistedQueueReference(rewardReference), replayOptions); + replayingTimed.put(entry.getKey(), time); + replayCompletions.put(entry.getKey(), plugin.getRewardHandler().givePersistedQueueRewardAsync(this, + new PersistedQueueReference(rewardReference), replayOptions)); String rewardName = rewardReference; plugin.debug("Giving timed/delayed reward " + rewardName + " for " + getPlayerName() + " with placeholders " + ArrayUtils.fromString(placeholders)); @@ -337,6 +341,14 @@ public void checkDelayedTimedRewards() { } setTimedRewards(newTimed); + for (Entry> replay : replayCompletions.entrySet()) { + String rewardEntry = replay.getKey(); + long time = replayingTimed.get(rewardEntry); + replay.getValue().exceptionally(failure -> { + restoreTimedReward(rewardEntry, time, failure); + return null; + }); + } } /** @@ -371,11 +383,39 @@ public void checkOfflineRewards() { RewardOptions options = new RewardOptions().setOnline(false).setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholderStr)); - rewardHandler.givePersistedQueueReward(user, new PersistedQueueReference(rewardReference), options); + rewardHandler.givePersistedQueueRewardAsync(user, new PersistedQueueReference(rewardReference), options) + .exceptionally(failure -> { + restoreOfflineReward(rewardEntry, failure); + return null; + }); } } + /** Restores a queue entry only after an asynchronous replay fails. */ + private void restoreOfflineReward(String rewardEntry, Throwable failure) { + // Match addOfflineRewards' lock so a newly queued reward cannot be lost + // while a failed replay is being restored. + synchronized (plugin) { + ArrayList pending = getOfflineRewards(); + // Queue entries are allowed to repeat; each failed replay must restore + // its own occurrence rather than collapsing identical rewards. + pending.add(rewardEntry); + setOfflineRewards(pending); + } + plugin.getLogger().warning("Could not deliver queued offline reward for " + getPlayerName() + + "; it will be retried: " + failure.getMessage()); + } + + /** Restores a due timed entry after an asynchronous replay fails. */ + private synchronized void restoreTimedReward(String rewardEntry, long time, Throwable failure) { + HashMap pending = getTimedRewards(); + pending.putIfAbsent(rewardEntry, time); + setTimedRewards(pending); + plugin.getLogger().warning("Could not deliver queued timed reward for " + getPlayerName() + + "; it will be retried: " + failure.getMessage()); + } + /** * Clears the user cache. */ @@ -452,7 +492,11 @@ public void forceRunOfflineRewards() { RewardOptions options = new RewardOptions().setOnline(false).setGiveOffline(false).forceOffline() .setCheckTimed(false).withPlaceHolder(ArrayUtils.fromString(placeholderStr)); - rewardHandler.givePersistedQueueReward(user, new PersistedQueueReference(rewardReference), options); + rewardHandler.givePersistedQueueRewardAsync(user, new PersistedQueueReference(rewardReference), options) + .exceptionally(failure -> { + restoreOfflineReward(rewardEntry, failure); + return null; + }); } } @@ -1669,4 +1713,4 @@ public void updateTempCacheWithColumns(ArrayList cols) { getUserData().updateTempCacheWithColumns(cols); } -} \ No newline at end of file +} 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 870e5b0082..3d814623f4 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 @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -17,6 +18,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -32,6 +34,7 @@ import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.injected.RewardInject; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt; +import com.bencodez.advancedcore.api.rewards.builtin.RewardSubRewards; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; class RewardAsyncInjectionTest { @@ -201,6 +204,22 @@ void failedAsyncInjectionStopsDependentRewardsAndPropagates() { assertFalse(dependentRan.get()); } + @Test + void failingLegacyInjectionDoesNotStopLaterAsyncChainSteps() { + AtomicBoolean laterRan = new AtomicBoolean(); + handler.getInjectedRewards().add(new RewardInject("BrokenLegacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + throw new IllegalStateException("bad legacy configuration"); + } + }); + handler.getInjectedRewards().add(asyncInjection("Later", CompletableFuture.completedFuture(null), laterRan)); + + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + assertTrue(laterRan.get()); + } + @Test void synchronizedAsyncInjectionSerializesThroughCompletion() { List> completions = new ArrayList<>(); @@ -305,6 +324,60 @@ protected long getServerThreadDispatchTimeoutMillis() { result.toCompletableFuture().join(); } + @Test + void nestedPostRewardWaitsForChildRewardBeforeAdvancing() { + handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); + when(plugin.getRewardHandler()).thenReturn(handler); + when(user.getPlugin()).thenReturn(plugin); + data.createSection("Rewards"); + CompletableFuture child = new CompletableFuture<>(); + doReturn(child).when(handler).giveRewardAsync(eq(user), any(ConfigurationSection.class), eq("Rewards"), + any()); + List events = new ArrayList<>(); + RewardSubRewards.register(handler, plugin); + RewardInject after = new RewardInject("After") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("after-child"); + return null; + } + }; + after.postReward(); + handler.getInjectedRewards().add(after); + + CompletionStage result = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + assertFalse(result.toCompletableFuture().isDone()); + assertTrue(events.isEmpty()); + + child.complete(null); + result.toCompletableFuture().join(); + assertEquals(List.of("after-child"), events); + } + + @Test + void nestedAsyncInjectorCanAwaitTheSameSharedInjectorWithoutDeadlocking() { + AtomicInteger invocations = new AtomicInteger(); + RewardInject nested = new RewardInject("Nested") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean supportsAsyncSynchronization() { return false; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward current, AdvancedCoreUser currentUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + if (invocations.incrementAndGet() == 1) { + return current.giveInjectedRewardsAsync(currentUser, new HashMap<>()).thenApply(ignored -> null); + } + return CompletableFuture.completedFuture(null); + } + }; + nested.synchronize(); + handler.getInjectedRewards().add(nested); + + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + assertEquals(2, invocations.get()); + } + private RewardInject asyncInjection(String path, CompletionStage completion, AtomicBoolean invoked) { return new RewardInject(path) { @Override public boolean supportsAsyncRequest() { return true; } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 780107790d..030c207c4b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -4,12 +4,15 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -53,6 +56,7 @@ public void setUp() { when(plugin.getUserManager()).thenReturn(userManager); when(plugin.getRewardHandler()).thenReturn(rewardHandler); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); when(userManager.getOfflineRewardsPath()).thenReturn("offlineRewardsPath"); when(configOptions.isProcessRewards()).thenReturn(true); @@ -66,11 +70,12 @@ void checkOfflineRewards_preservesServerRequirementForNormalReplay() { rewards.add("VoteReward%placeholders%Server%pair%server-a"); when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); - user.checkOfflineRewards(); - ArgumentCaptor referenceCaptor = ArgumentCaptor.forClass(PersistedQueueReference.class); ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(RewardOptions.class); - verify(rewardHandler).givePersistedQueueReward(eq(user), referenceCaptor.capture(), optionsCaptor.capture()); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + user.checkOfflineRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), referenceCaptor.capture(), optionsCaptor.capture()); assertEquals("VoteReward", referenceCaptor.getValue().getReference()); RewardOptions options = optionsCaptor.getValue(); assertFalse(options.isForceOffline()); @@ -85,11 +90,12 @@ void forceRunOfflineRewards_stillBypassesNormalReplayChecks() { rewards.add("VoteReward%placeholders%Server%pair%server-a"); when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); - user.forceRunOfflineRewards(); - ArgumentCaptor referenceCaptor = ArgumentCaptor.forClass(PersistedQueueReference.class); ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(RewardOptions.class); - verify(rewardHandler).givePersistedQueueReward(eq(user), referenceCaptor.capture(), optionsCaptor.capture()); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + user.forceRunOfflineRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), referenceCaptor.capture(), optionsCaptor.capture()); assertEquals("VoteReward", referenceCaptor.getValue().getReference()); RewardOptions options = optionsCaptor.getValue(); assertTrue(options.isForceOffline()); @@ -143,4 +149,39 @@ void setOfflineRewards_withinLimit() { user.setOfflineRewards(rewards); verify(data).setStringList("offlineRewardsPath", rewards); } + + @Test + void failedAsyncOfflineReplayIsRestoredForRetry() { + ArrayList initial = new ArrayList<>(); + initial.add("VoteReward%placeholders%Server%pair%server-a"); + initial.add("VoteReward%placeholders%Server%pair%server-a"); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenReturn(new ArrayList<>(initial)).thenReturn(new ArrayList<>()); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); + + user.checkOfflineRewards(); + + ArgumentCaptor> saved = ArgumentCaptor.forClass(ArrayList.class); + verify(data, org.mockito.Mockito.times(3)).setStringList(eq("offlineRewardsPath"), saved.capture()); + assertEquals(initial, saved.getAllValues().get(2)); + } + + @Test + void failedAsyncTimedReplayIsRestoredAfterDueEntriesAreSaved() { + long due = System.currentTimeMillis() - 1_000; + String entry = "VoteReward%extime%1%placeholders%Server%pair%server-a"; + ArrayList timed = new ArrayList<>(); + timed.add(entry + "%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenReturn(new ArrayList<>(timed)).thenReturn(new ArrayList<>()); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); + + user.checkDelayedTimedRewards(); + + ArgumentCaptor> saved = ArgumentCaptor.forClass(ArrayList.class); + verify(data, org.mockito.Mockito.times(2)).setStringList(eq("TimedRewards"), saved.capture()); + assertEquals(timed, saved.getAllValues().get(1)); + } } From 31ef89f8e838a88a8f74f322fe476b16f876cd67 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:18:03 -0600 Subject: [PATCH 03/78] Fix durable async reward replay --- .../advancedcore/api/rewards/Reward.java | 217 +++++++++- .../api/rewards/RewardExecutor.java | 10 +- .../api/rewards/RewardOptions.java | 68 +++- .../builtin/RewardAdvancedPriority.java | 35 ++ .../builtin/RewardAdvancedRandomReward.java | 22 ++ .../builtin/RewardAdvancedRewards.java | 8 +- .../rewards/builtin/RewardAdvancedWorld.java | 32 +- .../api/rewards/builtin/RewardJavascript.java | 21 + .../api/rewards/builtin/RewardLucky.java | 51 +++ .../api/rewards/builtin/RewardPriority.java | 31 ++ .../api/rewards/builtin/RewardRandom.java | 34 ++ .../rewards/builtin/RewardRandomReward.java | 20 + .../rewards/builtin/RewardSpecialChance.java | 53 +++ .../RewardInjectConfigurationSection.java | 29 +- .../injected/RewardInjectStringList.java | 42 +- .../api/user/AdvancedCoreUser.java | 369 ++++++++++++++---- .../api/user/usercache/UserDataCache.java | 25 ++ .../rewards/RewardAsyncInjectionTest.java | 111 ++++++ .../tests/rewards/RewardExecutorTest.java | 20 + .../tests/user/AdvancedCoreUserTest.java | 206 +++++++++- .../user/UserDataCacheSchedulingTest.java | 31 ++ 21 files changed, 1321 insertions(+), 114 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 0142b28b62..cf0b541861 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 @@ -6,13 +6,18 @@ import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; +import java.util.function.Consumer; import java.util.logging.Level; import org.bukkit.Bukkit; @@ -36,6 +41,9 @@ * The Class Reward. */ public class Reward { + private static final ThreadLocal ACTIVE_REPLAY_STATE = new ThreadLocal<>(); + private static final ThreadLocal ACTIVE_REPLAY_KEY = new ThreadLocal<>(); + private static final String REPLAY_SELECTION_PREFIX = "__advancedcore_replay_selection_"; @Getter @Setter @@ -263,34 +271,193 @@ public void giveInjectedRewards(AdvancedCoreUser user, HashMap p */ public CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, HashMap placeholders) { - List postRewards = new ArrayList<>(); - CompletionStage sequence = CompletableFuture.completedFuture(null); + return giveInjectedRewardsAsync(user, placeholders, 0, new ReplayState(null), getRewardName()); + } + /** + * Resumes a persisted asynchronous reward after the supplied number of + * injection stages have completed. Queue recovery uses this checkpoint to + * avoid replaying already-applied non-idempotent injections. + */ + private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, + HashMap placeholders, int completedStages, ReplayState replayState, String replayKey) { + List postRewards = new ArrayList<>(); + List orderedRewards = new ArrayList<>(); for (RewardInject inject : plugin.getRewardHandler().getInjectedRewards()) { - if (inject.isPostReward()) { - postRewards.add(inject); - continue; - } - sequence = sequence.thenCompose(ignored -> invokeInjectionAsync(inject, user, placeholders)) - .thenCompose(result -> resumeOnServerThread(user).thenRun(() -> { + if (inject.isPostReward()) postRewards.add(inject); + else orderedRewards.add(inject); + } + orderedRewards.addAll(postRewards); + int resumeAfter = Math.max(0, replayState.getCompleted(replayKey, completedStages)); + AtomicInteger completed = new AtomicInteger(resumeAfter); + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = 0; index < orderedRewards.size(); index++) { + if (index < resumeAfter) continue; + final RewardInject inject = orderedRewards.get(index); + final String injectionKey = replayKey + "/" + index; + sequence = sequence.thenCompose(ignored -> invokeInjectionAsync(inject, user, placeholders, replayState, + injectionKey)) + .thenApply(result -> { if (inject.isAddAsPlaceholder() && result != null) addPlaceholder(inject, result, placeholders); - })); + int checkpoint = completed.incrementAndGet(); + replayState.setCompleted(replayKey, checkpoint); + replayState.persistCheckpoint(placeholders); + return result; + }).thenCompose(ignored -> resumeOnServerThread(user)); } + return sequence.handle((ignored, failure) -> { + if (failure == null) return null; + RewardReplayFailure nestedFailure = findReplayFailure(failure); + if (nestedFailure != null) throw nestedFailure; + replayState.setCompleted(replayKey, completed.get()); + throw new RewardReplayFailure(replayState, placeholders, failure); + }); + } - for (RewardInject inject : postRewards) { - sequence = sequence.thenCompose(ignored -> invokeInjectionAsync(inject, user, placeholders)) - .thenCompose(result -> resumeOnServerThread(user)); + // Kept as a narrow compatibility bridge for internal callers/tests that only + // have a single reward checkpoint. + private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, + HashMap placeholders, int completedStages) { + return giveInjectedRewardsAsync(user, placeholders, completedStages, new ReplayState(null), getRewardName()); + } + + /** Details the last durably safe replay checkpoint when an async chain fails. */ + public static final class RewardReplayFailure extends RuntimeException { + private static final long serialVersionUID = 1L; + private final ReplayState replayState; + @Getter private final int completedInjectionCount; + @Getter private final HashMap replayPlaceholders; + + private RewardReplayFailure(ReplayState replayState, HashMap replayPlaceholders, Throwable cause) { + super("Asynchronous reward replay failed after a completed asynchronous reward stage", + cause); + this.replayState = replayState; + this.completedInjectionCount = replayState.highestCompletedCount(); + this.replayPlaceholders = new HashMap<>(replayPlaceholders); + } + + public Map getReplayProgress() { return replayState.copyProgress(); } + } + + private static RewardReplayFailure findReplayFailure(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof RewardReplayFailure) return (RewardReplayFailure) current; + } + return null; + } + + /** Captures the active nested replay state for deferred child dispatch. */ + public static ReplayState currentReplayState() { return ACTIVE_REPLAY_STATE.get(); } + + /** Captures the active parent execution path before deferred work runs. */ + public static String currentReplayKey() { return ACTIVE_REPLAY_KEY.get(); } + + /** Obtains one shared replay state for a group of nested dispatches. */ + public static ReplayState replayStateFor(RewardOptions options) { + ReplayState replayState = options.getAsyncReplayState(); + if (replayState == null) { + replayState = ACTIVE_REPLAY_STATE.get(); + if (replayState == null) replayState = new ReplayState(options.getAsyncReplayProgress()); + options.setAsyncReplayState(replayState); + } + if (options.getAsyncReplayCheckpointConsumer() != null) { + replayState.setCheckpointConsumer(options.getAsyncReplayCheckpointConsumer()); + } + return replayState; + } + + /** + * Records a nondeterministic nested-reward decision in the placeholder + * snapshot carried by {@link RewardReplayFailure}. A retry consequently + * executes the same selected branch instead of rolling a new outcome after a + * child has already performed a side effect. + */ + public static String replaySelection(HashMap placeholders, Supplier selector) { + String activeKey = ACTIVE_REPLAY_KEY.get(); + String storageKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding() + .encodeToString((activeKey == null ? "root" : activeKey).getBytes(StandardCharsets.UTF_8)); + if (placeholders.containsKey(storageKey)) { + String stored = placeholders.get(storageKey); + return stored.isEmpty() ? null : new String(Base64.getUrlDecoder().decode(stored), StandardCharsets.UTF_8); + } + String selected = selector.get(); + placeholders.put(storageKey, selected == null ? "" : Base64.getUrlEncoder().withoutPadding() + .encodeToString(selected.getBytes(StandardCharsets.UTF_8))); + return selected; + } + + /** Attaches a captured replay state to an option object for a deferred child. */ + public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState) { + if (replayState != null) options.setAsyncReplayState(replayState); + return options; + } + + /** + * Associates a deferred nested reward with a deterministic child occurrence. + * A list may intentionally contain the same reward more than once, so its + * replay checkpoint must never be shared merely because its name is equal. + */ + public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState, + String childOccurrence) { + return withReplayState(options, replayState, ACTIVE_REPLAY_KEY.get(), childOccurrence); + } + + /** + * Associates deferred nested work with the parent path captured while the + * injector was invoked. Later completion callbacks do not retain ThreadLocal + * context, so they must use this explicit form. + */ + public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState, + String parentKey, String childOccurrence) { + withReplayState(options, replayState); + if (parentKey != null && childOccurrence != null) { + options.setAsyncReplayKey(parentKey + "/" + childOccurrence); + } + return options; + } + + /** Internal shared state passed through nested async reward dispatch. */ + public static final class ReplayState { + private final HashMap completed = new HashMap<>(); + private Consumer checkpointConsumer; + private ReplayState(Map initial) { if (initial != null) completed.putAll(initial); } + private synchronized int getCompleted(String rewardName, int fallback) { + return completed.getOrDefault(rewardName, fallback); + } + private synchronized void setCompleted(String rewardName, int count) { completed.put(rewardName, count); } + private synchronized Map copyProgress() { return new HashMap<>(completed); } + private synchronized int highestCompletedCount() { + int highest = 0; + for (int count : completed.values()) highest = Math.max(highest, count); + return highest; + } + private synchronized void setCheckpointConsumer(Consumer consumer) { + checkpointConsumer = consumer; + } + private void persistCheckpoint(HashMap placeholders) { + Consumer consumer; + synchronized (this) { consumer = checkpointConsumer; } + if (consumer != null) consumer.accept(new ReplayCheckpoint(copyProgress(), placeholders)); + } + } + + /** Immutable durable replay data emitted after every completed injection. */ + public static final class ReplayCheckpoint { + @Getter private final Map replayProgress; + @Getter private final HashMap placeholders; + private ReplayCheckpoint(Map replayProgress, HashMap placeholders) { + this.replayProgress = new HashMap<>(replayProgress); + this.placeholders = new HashMap<>(placeholders); } - return sequence; } private CompletionStage invokeInjectionAsync(RewardInject inject, AdvancedCoreUser user, - HashMap placeholders) { + HashMap placeholders, ReplayState replayState, String injectionKey) { try { if (!plugin.isEnabled()) return CompletableFuture.failedFuture( new IllegalStateException("Plugin disabled before reward injection completed")); Supplier> request = () -> requestOnServerThread(user, - () -> requestInjectionAsync(inject, user, placeholders)); + () -> requestInjectionAsync(inject, user, placeholders, replayState, injectionKey)); CompletionStage result = inject.isSynchronize() && inject.supportsAsyncSynchronization() ? inject.runSynchronizedAsync(request) : request.get(); @@ -365,7 +532,12 @@ protected long getServerThreadDispatchTimeoutMillis() { } private CompletionStage requestInjectionAsync(RewardInject inject, AdvancedCoreUser user, - HashMap placeholders) { + HashMap placeholders, ReplayState replayState, String injectionKey) { + ReplayState previous = ACTIVE_REPLAY_STATE.get(); + String previousKey = ACTIVE_REPLAY_KEY.get(); + ACTIVE_REPLAY_STATE.set(replayState); + ACTIVE_REPLAY_KEY.set(injectionKey); + try { if (inject.supportsAsyncRequest()) { return inject.onRewardRequestAsync(this, user, getConfig().getConfigData(), placeholders); } @@ -378,6 +550,10 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan failure.printStackTrace(); return CompletableFuture.completedFuture(null); } + } finally { + if (previous == null) ACTIVE_REPLAY_STATE.remove(); else ACTIVE_REPLAY_STATE.set(previous); + if (previousKey == null) ACTIVE_REPLAY_KEY.remove(); else ACTIVE_REPLAY_KEY.set(previousKey); + } } private void addPlaceholder(RewardInject inject, Object obj, HashMap placeholders) { @@ -658,7 +834,14 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< if (placeholders == null) { return CompletableFuture.completedFuture(null); } - return giveInjectedRewardsAsync(user, placeholders) + ReplayState replayState = replayStateFor(rewardOptions); + String replayKey = rewardOptions.getAsyncReplayKey(); + if (replayKey == null) { + String parentKey = ACTIVE_REPLAY_KEY.get(); + replayKey = parentKey == null ? getRewardName() : parentKey + "/" + getRewardName(); + rewardOptions.setAsyncReplayKey(replayKey); + } + return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, replayKey) .thenRun(() -> plugin.debug("Gave " + user.getPlayerName() + " reward " + name)); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index a79659701f..faeebd80d1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -115,8 +115,16 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio if (data.isList(path)) { CompletionStage sequence = CompletableFuture.completedFuture(null); + Reward.ReplayState replayState = Reward.replayStateFor(options); + String parentReplayKey = options.getAsyncReplayKey(); + if (parentReplayKey == null) parentReplayKey = Reward.currentReplayKey(); + if (parentReplayKey == null) parentReplayKey = "list:" + path; + int rewardIndex = 0; for (String nestedReward : new ArrayList<>(data.getStringList(path))) { - sequence = sequence.thenCompose(ignored -> giveRewardAsync(user, nestedReward, options)); + RewardOptions nestedOptions = options.copyForNestedDispatch( + parentReplayKey + "/" + nestedReward + ":" + rewardIndex++); + nestedOptions.setAsyncReplayState(replayState); + sequence = sequence.thenCompose(ignored -> giveRewardAsync(user, nestedReward, nestedOptions)); } return sequence; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java index 014c611637..b26f3b77d7 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java @@ -1,7 +1,9 @@ package com.bencodez.advancedcore.api.rewards; -import java.util.HashMap; -import java.util.Map.Entry; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Consumer; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.simpleapi.array.ArrayUtils; @@ -41,8 +43,37 @@ public class RewardOptions { @Getter private boolean useDefaultWorlds = true; - @Getter - private long orginalTrigger = -1; + @Getter + private long orginalTrigger = -1; + + /** + * Number of completed injection stages recorded for a persisted replay. This + * is intentionally internal queue metadata: normal reward dispatch always + * starts at zero. + */ + @Getter + @Setter + private int completedAsyncInjections; + + @Getter + @Setter + private Map asyncReplayProgress = new HashMap<>(); + + @Getter + @Setter + private Reward.ReplayState asyncReplayState; + + /** + * Stable execution path for a nested asynchronous reward. The path is queue + * metadata only; callers that do not participate in replay leave it unset. + */ + @Getter + @Setter + private String asyncReplayKey; + + @Getter + @Setter + private Consumer asyncReplayCheckpointConsumer; public RewardOptions() { } @@ -172,11 +203,30 @@ public String toString() { } - public RewardOptions withPlaceHolder(HashMap placeholders2) { + public RewardOptions withPlaceHolder(HashMap placeholders2) { for (Entry entry : placeholders2.entrySet()) { placeholders.put(entry.getKey(), entry.getValue()); } - return this; - } - -} + return this; + } + + /** + * Makes an isolated option object for one member of an asynchronously + * dispatched list. Mutable placeholders and replay metadata must not leak + * from one list member into the next. + */ + RewardOptions copyForNestedDispatch(String replayKey) { + RewardOptions copy = new RewardOptions().setCheckTimed(checkTimed).setGiveOffline(giveOffline) + .setIgnoreChance(ignoreChance).setIgnoreRequirements(ignoreRequirements).setPrefix(prefix).setSuffix(suffix) + .orginalTrigger(orginalTrigger).setPlaceholders(new HashMap<>(placeholders)); + if (forceOffline) copy.forceOffline(); + if (!useDefaultWorlds) copy.disableDefaultWorlds(); + if (onlineSet) copy.setOnline(online); + if (!server.isEmpty()) copy.setServer(server); + copy.setAsyncReplayState(asyncReplayState); + copy.setAsyncReplayKey(replayKey); + copy.setAsyncReplayCheckpointConsumer(asyncReplayCheckpointConsumer); + return copy; + } + +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java index fc1bb37eb7..b1569b4179 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -28,6 +30,15 @@ private RewardAdvancedPriority() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedPriority") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -46,6 +57,30 @@ public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, Conf return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward sourceReward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + String selectedKey = Reward.replaySelection(placeholders, () -> { + for (String key : section.getKeys(false)) { + RewardOptions namingOptions = new RewardOptions() + .setPrefix(sourceReward.getName() + "_AdvancedPriority"); + Reward reward = handler.getReward(section, key, namingOptions); + if (reward != null && reward.canGiveReward(user, + new RewardOptions().withPlaceHolder(placeholders))) { + return key; + } + } + return null; + }); + if (selectedKey == null) return CompletableFuture.completedFuture(null); + Reward selected = handler.getReward(section, selectedKey, new RewardOptions() + .setPrefix(sourceReward.getName() + "_AdvancedPriority")); + return selected == null ? CompletableFuture.completedFuture(null) + : handler.giveRewardAsync(user, selected, new RewardOptions().setIgnoreChance(true) + .setIgnoreRequirements(true).setPrefix(sourceReward.getName() + "_AdvancedPriority") + .withPlaceHolder(placeholders)).thenApply(ignored -> selected.getName()); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java index 1003eab2bc..fc3ce1af7d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java @@ -4,6 +4,8 @@ import java.util.HashMap; import java.util.Set; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -31,6 +33,15 @@ private RewardAdvancedRandomReward() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedRandomReward") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -45,6 +56,17 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + ArrayList rewards = ArrayUtils.convert(section.getKeys(false)); + if (rewards.isEmpty()) return CompletableFuture.completedFuture(null); + String selected = Reward.replaySelection(placeholders, + () -> rewards.get(ThreadLocalRandom.current().nextInt(rewards.size()))); + return handler.giveRewardAsync(user, section, selected, new RewardOptions().setPlaceholders(placeholders) + .setPrefix(reward.getRewardName() + "_AdvancedRandomReward")).thenApply(ignored -> selected); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java index f8b2b51d19..c61277d1b1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java @@ -69,10 +69,16 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU return CompletableFuture.completedFuture(null); } CompletionStage sequence = CompletableFuture.completedFuture(null); + com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); + String parentReplayKey = Reward.currentReplayKey(); + int rewardIndex = 0; for (String rewardName : ArrayUtils.convert(data.getConfigurationSection(getPath()).getKeys(false))) { + final int childIndex = rewardIndex++; sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, data.getConfigurationSection(getPath()), rewardName, - new RewardOptions().setPlaceholders(placeholders) + Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), replayState, + parentReplayKey, + rewardName + ":" + childIndex) .setPrefix(reward.getRewardName() + "_AdvancedRewards"))); } return sequence.thenApply(ignored -> null); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java index f0e7cdaa78..1ccd426457 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -29,10 +31,19 @@ private RewardAdvancedWorld() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedWorld") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { - for (String key : section.getKeys(false)) { + for (String key : section.getKeys(false)) { plugin.extraDebug("AdvancedWorld: Giving reward " + sourceReward.getName() + "_AdvancedWorld"); section.set(key + ".Worlds", ArrayUtils.convert(new String[] { key })); handler.giveReward(user, section, key, new RewardOptions().withPlaceHolder(placeholders) @@ -41,6 +52,25 @@ public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, Conf return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward sourceReward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + CompletionStage sequence = CompletableFuture.completedFuture(null); + com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); + String parentReplayKey = Reward.currentReplayKey(); + int worldIndex = 0; + for (String key : section.getKeys(false)) { + final int childIndex = worldIndex++; + section.set(key + ".Worlds", ArrayUtils.convert(new String[] { key })); + sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, section, key, + Reward.withReplayState(new RewardOptions().withPlaceHolder(placeholders), replayState, + parentReplayKey, + key + ":" + childIndex) + .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld"))); + } + return sequence.thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java index d80fcd9f66..188935d6b7 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -55,6 +56,15 @@ public void setValue(Player player, ArrayList value) { }.addLore("Javascript expressions to run")))); handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Javascript") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -70,6 +80,17 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + if (!section.getBoolean("Enabled")) return java.util.concurrent.CompletableFuture.completedFuture(null); + String path = Reward.replaySelection(placeholders, + () -> new JavascriptEngine().addPlayer(user.getOfflinePlayer()).addPlaceholders(placeholders) + .getBooleanValue(section.getString("Expression")) ? "TrueRewards" : "FalseRewards"); + return new RewardBuilder(section, path).withPrefix(reward.getName() + ".Javascript").sendAsync(user) + .thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java index fd6fcdf3c0..b3d0373d03 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java @@ -4,6 +4,8 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map.Entry; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -35,6 +37,15 @@ private RewardLucky() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Lucky") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -71,6 +82,46 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + HashMap luckyRewards = new HashMap<>(); + for (String key : section.getKeys(false)) { + if (MessageAPI.isInt(key)) { + int num = Integer.parseInt(key); + if (num > 0) luckyRewards.put(num, "Lucky." + num); + } + } + String choices = Reward.replaySelection(placeholders, () -> { + HashMap selected = new LinkedHashMap<>(); + for (Entry entry : luckyRewards.entrySet()) { + if (MiscUtils.getInstance().checkChance(1, entry.getKey())) selected.put(entry.getValue(), entry.getKey()); + } + selected = ArrayUtils.sortByValuesStr(selected, false); + ArrayList paths = new ArrayList<>(selected.keySet()); + if (reward.getConfig().getConfigData().getBoolean("OnlyOneLucky", false) && paths.size() > 1) { + paths.subList(1, paths.size()).clear(); + } + return paths.isEmpty() ? null : String.join("\n", paths); + }); + if (choices == null) return CompletableFuture.completedFuture(null); + CompletionStage sequence = CompletableFuture.completedFuture(null); + com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); + String parentReplayKey = Reward.currentReplayKey(); + int luckyIndex = 0; + for (String path : choices.split("\\n")) { + final int childIndex = luckyIndex++; + sequence = sequence.thenCompose(ignored -> { + RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) + .withPrefix(reward.getName()).withPlaceHolder(placeholders); + Reward.withReplayState(builder.getRewardOptions(), replayState, parentReplayKey, + path + ":" + childIndex); + return builder.sendAsync(user); + }); + } + return sequence.thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java index 21a6b98ab6..a9543b1353 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -25,6 +27,15 @@ private RewardPriority() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectStringList("Priority") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequest(Reward source, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -38,6 +49,26 @@ public String onRewardRequest(Reward source, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward source, AdvancedCoreUser user, + ArrayList list, HashMap placeholders) { + String selectedName = Reward.replaySelection(placeholders, () -> { + for (String rewardName : list) { + Reward reward = handler.getReward(rewardName); + if (reward != null && reward.canGiveReward(user, + new RewardOptions().withPlaceHolder(placeholders))) { + return rewardName; + } + } + return null; + }); + if (selectedName == null) return CompletableFuture.completedFuture(null); + Reward selected = handler.getReward(selectedName); + return selected == null ? CompletableFuture.completedFuture(null) + : handler.giveRewardAsync(user, selected, new RewardOptions().withPlaceHolder(placeholders) + .setIgnoreChance(true).setIgnoreRequirements(true)).thenApply(ignored -> selected.getName()); + } }.asPlaceholder("Priority").addEditButton( new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("Priority", null) { @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java index e63f8cbb37..ed98e100db 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -24,6 +26,15 @@ private RewardRandom() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Random") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @SuppressWarnings("unchecked") @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, @@ -48,6 +59,29 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @SuppressWarnings("unchecked") + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + String selection = Reward.replaySelection(placeholders, () -> { + if (!MiscUtils.getInstance().checkChance(section.getDouble("Chance", 100), 100)) return "fallback"; + if (!section.getBoolean("PickRandom", true)) return "rewards"; + ArrayList rewards = (ArrayList) section.getList("Rewards", new ArrayList<>()); + return rewards == null || rewards.isEmpty() ? "none" + : "pick:" + rewards.get(ThreadLocalRandom.current().nextInt(rewards.size())); + }); + if (selection == null || selection.equals("none")) return CompletableFuture.completedFuture(null); + if (selection.startsWith("pick:")) { + String selected = selection.substring("pick:".length()); + return selected.isEmpty() ? CompletableFuture.completedFuture(null) + : handler.giveRewardAsync(user, selected, + new RewardOptions().setPlaceholders(placeholders)).thenApply(ignored -> null); + } + String path = selection.equals("rewards") ? "Random.Rewards" : "Random.FallBack"; + return new RewardBuilder(reward.getConfig().getConfigData(), path).withPrefix(reward.getName()) + .withPlaceHolder(placeholders).sendAsync(user).thenApply(ignored -> null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java index 03dab009e5..f2f39bf124 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ThreadLocalRandom; import org.bukkit.Material; @@ -25,6 +26,15 @@ private RewardRandomReward() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectStringList("RandomReward") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -35,6 +45,16 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ArrayList list, HashMap placeholders) { + if (list.isEmpty()) return java.util.concurrent.CompletableFuture.completedFuture(null); + String selected = Reward.replaySelection(placeholders, + () -> list.get(ThreadLocalRandom.current().nextInt(list.size()))); + return handler.giveRewardAsync(user, selected, new RewardOptions().setPlaceholders(placeholders)) + .thenApply(ignored -> selected); + } }.asPlaceholder("RandomReward").priority(20).addEditButton( new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("RandomReward", null) { @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java index ae5be20a20..2ce15237c4 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java @@ -7,6 +7,8 @@ import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -34,6 +36,15 @@ private RewardSpecialChance() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("SpecialChance") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -71,6 +82,48 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat return null; } + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + double totalChance = 0; + LinkedHashMap map = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + String path = key; + key = key.replaceAll("_", "."); + if (MessageAPI.isDouble(key)) { + double chance = Double.valueOf(key); + totalChance += chance; + map.put(chance, path); + } + } + Set> copy = new HashSet<>(map.entrySet()); + double currentNum = 0; + map.clear(); + for (Entry entry : copy) { + currentNum += entry.getKey(); + map.put(currentNum, entry.getValue()); + } + final double chanceTotal = totalChance; + String selection = Reward.replaySelection(placeholders, () -> { + double randomNum = ThreadLocalRandom.current().nextDouble(chanceTotal); + for (Entry entry : map.entrySet()) { + if (randomNum <= entry.getKey()) return entry.getValue() + "\n" + entry.getKey(); + } + return null; + }); + if (selection == null) return CompletableFuture.completedFuture(null); + String[] selected = selection.split("\n", 2); + if (selected.length != 2) return CompletableFuture.completedFuture(null); + for (Entry entry : map.entrySet()) { + if (entry.getValue().equals(selected[0])) { + return new RewardBuilder(section, entry.getValue()) + .withPrefix(reward.getName() + "_SpecialChance").withPlaceHolder(placeholders) + .withPlaceHolder("chance", selected[1]).sendAsync(user).thenApply(ignored -> null); + } + } + return CompletableFuture.completedFuture(null); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java index 8a2d6bb889..b6e11843c7 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.HashMap; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -25,7 +27,28 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio return null; } - public abstract String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, - HashMap placeholders); + public abstract String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, + HashMap placeholders); + + /** Completion-aware counterpart for section injections. */ + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequested(reward, user, section, placeholders)); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) + && !isAlwaysForceNoData()) { + return CompletableFuture.completedFuture(null); + } + return onRewardRequestedAsync(reward, user, data.getConfigurationSection(getPath()), placeholders) + .thenApply(result -> result); + } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java index c92f2638b4..7bc405ad0c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java @@ -1,7 +1,10 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.ArrayList; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -28,12 +31,26 @@ public RewardInjectStringList(String path, ArrayList defaultValue) { this.defaultValue = defaultValue; } - public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList num, - HashMap placeholders); + public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList num, + HashMap placeholders); + + /** + * Completion-aware counterpart for list injections. Existing list injections + * retain their synchronous behavior unless they opt in by overriding this + * method. + */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, ArrayList value, + HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, value, placeholders)); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } @SuppressWarnings("unchecked") @Override - public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, + public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, HashMap placeholders) { if (data.isList(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData()) { ArrayList value = (ArrayList) data.getList(getPath(), getDefaultValue()); @@ -43,6 +60,19 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio } return null; - } + } + + @SuppressWarnings("unchecked") + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!data.isList(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) && !isAlwaysForceNoData()) { + return CompletableFuture.completedFuture(null); + } + List stored = data.getList(getPath(), getDefaultValue()); + ArrayList value = new ArrayList<>(); + for (Object entry : stored) value.add(String.valueOf(entry)); + return onRewardRequestAsync(reward, user, value, placeholders).thenApply(result -> result); + } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 179f53b695..de3d3103e2 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 @@ -10,9 +10,12 @@ import java.util.Base64; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; import java.util.Map.Entry; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.bukkit.Bukkit; import org.bukkit.Effect; @@ -56,6 +59,10 @@ public class AdvancedCoreUser { private static final String QUEUED_REFERENCE_PREFIX = "\\AdvancedCoreQueue/1/"; + private static final String ASYNC_PROGRESS_DELIMITER = "%asyncprogress%"; + private static final String ASYNC_RETRY_DELIMITER = "%asyncretry%"; + private final HashMap offlineReplayInFlight = new HashMap<>(); + private final HashSet timedReplayInFlight = new HashSet<>(); /** * User data fetch mode for this user. @@ -249,7 +256,7 @@ public void addPermission(String permission, long delay) { * given */ public synchronized void addTimedReward(Reward reward, HashMap placeholders, long epochMilli) { - HashMap timed = getTimedRewards(); + HashMap timed = new HashMap<>(getTimedRewards()); String rewardName = queuedRewardReference(reward); rewardName += "%extime%" + System.currentTimeMillis(); @@ -265,6 +272,136 @@ private String queuedRewardReference(Reward reward) { + encodedName; } + private static QueuedReplay parseQueuedReplay(String storedReference) { + int marker = storedReference.lastIndexOf(ASYNC_PROGRESS_DELIMITER); + if (marker < 0) return new QueuedReplay(storedReference, 0, new HashMap<>()); + String value = storedReference.substring(marker + ASYNC_PROGRESS_DELIMITER.length()); + if (value.startsWith("v2-")) { + try { + HashMap progress = new HashMap<>(); + String decoded = new String(Base64.getUrlDecoder().decode(value.substring(3)), StandardCharsets.UTF_8); + for (String line : decoded.split("\\n")) { + String[] pair = line.split("\\t", 2); + if (pair.length == 2) progress.put(pair[0], Integer.parseInt(pair[1])); + } + return new QueuedReplay(storedReference.substring(0, marker), 0, progress); + } catch (IllegalArgumentException ignored) { + return new QueuedReplay(storedReference, 0, new HashMap<>()); + } + } + try { + int progress = Integer.parseInt(value); + return progress > 0 ? new QueuedReplay(storedReference.substring(0, marker), progress, new HashMap<>()) + : new QueuedReplay(storedReference.substring(0, marker), 0, new HashMap<>()); + } catch (NumberFormatException ignored) { + // Preserve malformed legacy values as a normal reward reference instead + // of accidentally skipping an arbitrary portion of a reward chain. + return new QueuedReplay(storedReference, 0, new HashMap<>()); + } + } + + private static String encodeAsyncReplayProgress(Map progress) { + if (progress.isEmpty()) return ""; + StringBuilder encoded = new StringBuilder(); + for (Entry entry : progress.entrySet()) { + if (entry.getValue() != null && entry.getValue() > 0) { + encoded.append(entry.getKey()).append('\t').append(entry.getValue()).append('\n'); + } + } + return encoded.length() == 0 ? "" : "v2-" + Base64.getUrlEncoder().withoutPadding() + .encodeToString(encoded.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static String stripTimedExecutionMarker(String storedReference) { + int marker = storedReference.indexOf("%extime%"); + if (marker < 0) return stripAsyncRetryMarker(storedReference); + int valueStart = marker + "%extime%".length(); + int nextMarker = storedReference.indexOf('%', valueStart); + return stripAsyncRetryMarker(storedReference.substring(0, marker) + + (nextMarker < 0 ? "" : storedReference.substring(nextMarker))); + } + + private static String stripAsyncRetryMarker(String storedReference) { + int marker = storedReference.indexOf(ASYNC_RETRY_DELIMITER); + if (marker < 0) return storedReference; + int valueStart = marker + ASYNC_RETRY_DELIMITER.length(); + int nextMarker = storedReference.indexOf('%', valueStart); + return storedReference.substring(0, marker) + (nextMarker < 0 ? "" : storedReference.substring(nextMarker)); + } + + private static int asyncRetryCount(String rewardEntry) { + int marker = rewardEntry.indexOf(ASYNC_RETRY_DELIMITER); + if (marker < 0) return 0; + int valueStart = marker + ASYNC_RETRY_DELIMITER.length(); + int nextMarker = rewardEntry.indexOf('%', valueStart); + try { return Integer.parseInt(rewardEntry.substring(valueStart, nextMarker < 0 ? rewardEntry.length() : nextMarker)); } + catch (NumberFormatException ignored) { return 0; } + } + + private static String withAsyncRetryCount(String rewardEntry, int count) { + int placeholders = rewardEntry.indexOf("%placeholders%"); + String reference = placeholders < 0 ? rewardEntry : rewardEntry.substring(0, placeholders); + String suffix = placeholders < 0 ? "" : rewardEntry.substring(placeholders); + return stripAsyncRetryMarker(reference) + ASYNC_RETRY_DELIMITER + count + suffix; + } + + private static String withAsyncReplayProgress(String rewardEntry, Throwable failure) { + Reward.RewardReplayFailure replayFailure = replayFailure(failure); + int completed = completedAsyncInjections(failure); + String serializedProgress = replayFailure == null ? "" : encodeAsyncReplayProgress(replayFailure.getReplayProgress()); + if (completed <= 0 && serializedProgress.isEmpty()) return rewardEntry; + int placeholders = rewardEntry.indexOf("%placeholders%"); + String storedReference = placeholders < 0 ? rewardEntry : rewardEntry.substring(0, placeholders); + String suffix = placeholders < 0 ? "" : rewardEntry.substring(placeholders); + if (replayFailure != null) suffix = "%placeholders%" + ArrayUtils.makeString(replayFailure.getReplayPlaceholders()); + QueuedReplay queuedReplay = parseQueuedReplay(stripAsyncRetryMarker(storedReference)); + if (!serializedProgress.isEmpty()) return queuedReplay.rewardReference + ASYNC_PROGRESS_DELIMITER + + serializedProgress + suffix; + return queuedReplay.rewardReference + ASYNC_PROGRESS_DELIMITER + + Math.max(queuedReplay.completedAsyncInjections, completed) + suffix; + } + + private static String withAsyncReplayProgress(String rewardEntry, Map progress, + HashMap placeholders) { + int marker = rewardEntry.indexOf("%placeholders%"); + String reference = marker < 0 ? rewardEntry : rewardEntry.substring(0, marker); + QueuedReplay queuedReplay = parseQueuedReplay(stripAsyncRetryMarker(reference)); + String serialized = encodeAsyncReplayProgress(progress); + return queuedReplay.rewardReference + (serialized.isEmpty() ? "" : ASYNC_PROGRESS_DELIMITER + serialized) + + "%placeholders%" + ArrayUtils.makeString(placeholders); + } + + private static int completedAsyncInjections(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof Reward.RewardReplayFailure) { + return ((Reward.RewardReplayFailure) current).getCompletedInjectionCount(); + } + current = current.getCause(); + } + return 0; + } + + private static Reward.RewardReplayFailure replayFailure(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof Reward.RewardReplayFailure) return (Reward.RewardReplayFailure) current; + } + return null; + } + + private static final class QueuedReplay { + private final String rewardReference; + private final int completedAsyncInjections; + private final Map asyncReplayProgress; + + private QueuedReplay(String rewardReference, int completedAsyncInjections, + Map asyncReplayProgress) { + this.rewardReference = rewardReference; + this.completedAsyncInjections = completedAsyncInjections; + this.asyncReplayProgress = asyncReplayProgress; + } + } + /** * Adds an unclaimed choice reward. * @@ -309,46 +446,44 @@ public void cacheIfNeeded() { public void checkDelayedTimedRewards() { plugin.debug("Checking timed/delayed for " + getPlayerName()); HashMap timed = getTimedRewards(); - HashMap newTimed = new HashMap<>(); - HashMap replayingTimed = new HashMap<>(); - HashMap> replayCompletions = new HashMap<>(); for (Entry entry : timed.entrySet()) { long time = entry.getValue(); if (time != 0) { Date timeDate = new Date(time); - if (new Date().after(timeDate)) { + if (new Date().after(timeDate) && claimTimedReward(entry.getKey(), time)) { String[] data = entry.getKey().split("%placeholders%", 2); - String rewardReference = data[0].split("%extime%", 2)[0]; + QueuedReplay queuedReplay = parseQueuedReplay(stripTimedExecutionMarker(data[0])); + String rewardReference = queuedReplay.rewardReference; String placeholders = ""; if (data.length > 1) { placeholders = data[1]; } RewardOptions replayOptions = new RewardOptions().setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholders)); + replayOptions.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); + replayOptions.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); replayOptions.addPlaceholder("date", "" + new SimpleDateFormat("EEE, d MMM yyyy HH:mm").format(new Date(time))); - replayingTimed.put(entry.getKey(), time); - replayCompletions.put(entry.getKey(), plugin.getRewardHandler().givePersistedQueueRewardAsync(this, - new PersistedQueueReference(rewardReference), replayOptions)); + // Keep the due entry durable while asynchronous stages are running. A + // checkpoint can change its serialized key (for example by adding the + // v2 progress marker), so completion and failure must follow this + // reference rather than the original entry key. + AtomicReference currentEntry = new AtomicReference<>(entry.getKey()); + replayOptions.setAsyncReplayCheckpointConsumer( + checkpoint -> checkpointTimedReward(currentEntry, time, checkpoint)); + plugin.getRewardHandler().givePersistedQueueRewardAsync(this, + new PersistedQueueReference(rewardReference), replayOptions).whenComplete((ignored, failure) -> { + if (failure == null) completeTimedReward(currentEntry.get(), time); + else restoreTimedReward(currentEntry.get(), time, failure); + }); String rewardName = rewardReference; plugin.debug("Giving timed/delayed reward " + rewardName + " for " + getPlayerName() + " with placeholders " + ArrayUtils.fromString(placeholders)); - } else { - newTimed.put(entry.getKey(), time); } } } - setTimedRewards(newTimed); - for (Entry> replay : replayCompletions.entrySet()) { - String rewardEntry = replay.getKey(); - long time = replayingTimed.get(rewardEntry); - replay.getValue().exceptionally(failure -> { - restoreTimedReward(rewardEntry, time, failure); - return null; - }); - } } /** @@ -362,56 +497,150 @@ public void checkOfflineRewards() { if (isCheckWorld()) { setCheckWorld(false); } - ArrayList rewards = getOfflineRewards(); - if (rewards.isEmpty()) { - return; - } - - setOfflineRewards(new ArrayList<>()); - RewardHandler rewardHandler = plugin.getRewardHandler(); - AdvancedCoreUser user = this; + dispatchOfflineRewards(false); + } + private void dispatchOfflineRewards(boolean force) { + ArrayList rewards = new ArrayList<>(getOfflineRewards()); for (String rewardEntry : rewards) { if (rewardEntry == null || rewardEntry.equals("null")) { continue; } + if (!claimOfflineReward(rewardEntry)) continue; String[] parts = rewardEntry.split("%placeholders%", 2); - String rewardReference = parts[0]; + QueuedReplay queuedReplay = parseQueuedReplay(parts[0]); + String rewardReference = queuedReplay.rewardReference; String placeholderStr = parts.length > 1 ? parts[1] : ""; RewardOptions options = new RewardOptions().setOnline(false).setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholderStr)); - - rewardHandler.givePersistedQueueRewardAsync(user, new PersistedQueueReference(rewardReference), options) - .exceptionally(failure -> { - restoreOfflineReward(rewardEntry, failure); - return null; + if (force) options.setGiveOffline(false).forceOffline(); + options.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); + options.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); + AtomicReference currentEntry = new AtomicReference<>(rewardEntry); + options.setAsyncReplayCheckpointConsumer(checkpoint -> checkpointOfflineReward(currentEntry, checkpoint)); + + plugin.getRewardHandler().givePersistedQueueRewardAsync(this, + new PersistedQueueReference(rewardReference), options).whenComplete((ignored, failure) -> { + if (failure == null) completeOfflineReward(currentEntry.get()); + else restoreOfflineReward(currentEntry.get(), failure); }); } + } + + private void checkpointOfflineReward(AtomicReference currentEntry, Reward.ReplayCheckpoint checkpoint) { + synchronized (plugin) { + String current = currentEntry.get(); + String updated = withAsyncReplayProgress(current, checkpoint.getReplayProgress(), checkpoint.getPlaceholders()); + ArrayList pending = getOfflineRewards(); + int index = pending.indexOf(current); + if (index >= 0) { + pending.set(index, updated); + int claimed = offlineReplayInFlight.getOrDefault(current, 0); + // Migrate this occurrence only. Identical legacy queue entries share a + // serialized key, but may each already be executing; moving the whole + // count would make the remaining old entry appear unclaimed. + if (claimed <= 1) offlineReplayInFlight.remove(current); + else offlineReplayInFlight.put(current, claimed - 1); + offlineReplayInFlight.put(updated, offlineReplayInFlight.getOrDefault(updated, 0) + 1); + currentEntry.set(updated); + setOfflineRewardsDurably(pending); + } + } + } + private boolean claimOfflineReward(String rewardEntry) { + synchronized (plugin) { + int occurrences = 0; + for (String pending : getOfflineRewards()) if (rewardEntry.equals(pending)) occurrences++; + int claimed = offlineReplayInFlight.getOrDefault(rewardEntry, 0); + if (claimed >= occurrences) return false; + offlineReplayInFlight.put(rewardEntry, claimed + 1); + return true; + } } - /** Restores a queue entry only after an asynchronous replay fails. */ + private void completeOfflineReward(String rewardEntry) { + synchronized (plugin) { + ArrayList pending = getOfflineRewards(); + pending.remove(rewardEntry); + setOfflineRewards(pending); + releaseOfflineReward(rewardEntry); + } + } + + /** Retains and updates a queue entry only after an asynchronous replay fails. */ private void restoreOfflineReward(String rewardEntry, Throwable failure) { // Match addOfflineRewards' lock so a newly queued reward cannot be lost // while a failed replay is being restored. synchronized (plugin) { ArrayList pending = getOfflineRewards(); - // Queue entries are allowed to repeat; each failed replay must restore - // its own occurrence rather than collapsing identical rewards. - pending.add(rewardEntry); + int index = pending.indexOf(rewardEntry); + if (index >= 0) pending.set(index, withAsyncReplayProgress(rewardEntry, failure)); + releaseOfflineReward(rewardEntry); setOfflineRewards(pending); } plugin.getLogger().warning("Could not deliver queued offline reward for " + getPlayerName() + "; it will be retried: " + failure.getMessage()); } + private void releaseOfflineReward(String rewardEntry) { + int claimed = offlineReplayInFlight.getOrDefault(rewardEntry, 0); + if (claimed <= 1) offlineReplayInFlight.remove(rewardEntry); + else offlineReplayInFlight.put(rewardEntry, claimed - 1); + } + + private synchronized boolean claimTimedReward(String rewardEntry, long time) { + if (timedReplayInFlight.contains(rewardEntry) || !Long.valueOf(time).equals(getTimedRewards().get(rewardEntry))) { + return false; + } + timedReplayInFlight.add(rewardEntry); + return true; + } + + private synchronized void completeTimedReward(String rewardEntry, long time) { + HashMap pending = getTimedRewards(); + if (Long.valueOf(time).equals(pending.get(rewardEntry))) { + pending.remove(rewardEntry); + setTimedRewards(pending); + } + timedReplayInFlight.remove(rewardEntry); + } + + /** + * Persists every completed asynchronous stage before the next one can run. + * Timed reward keys are map keys, so migrate the exact current key while + * retaining its execution time and its in-flight claim. + */ + private synchronized void checkpointTimedReward(AtomicReference currentEntry, long time, + Reward.ReplayCheckpoint checkpoint) { + String current = currentEntry.get(); + String updated = withAsyncReplayProgress(current, checkpoint.getReplayProgress(), checkpoint.getPlaceholders()); + if (current.equals(updated)) return; + HashMap pending = getTimedRewards(); + if (!Long.valueOf(time).equals(pending.get(current))) return; + pending.remove(current); + pending.put(updated, time); + setTimedRewardsDurably(pending); + timedReplayInFlight.remove(current); + timedReplayInFlight.add(updated); + currentEntry.set(updated); + } + /** Restores a due timed entry after an asynchronous replay fails. */ private synchronized void restoreTimedReward(String rewardEntry, long time, Throwable failure) { HashMap pending = getTimedRewards(); - pending.putIfAbsent(rewardEntry, time); + int retry = Math.min(8, asyncRetryCount(rewardEntry) + 1); + long retryDelay = Math.min(TimeUnit.MINUTES.toMillis(5), TimeUnit.SECONDS.toMillis(1L << retry)); + long retryTime = System.currentTimeMillis() + retryDelay; + pending.remove(rewardEntry); + pending.put(withAsyncRetryCount(withAsyncReplayProgress(rewardEntry, failure), retry), retryTime); setTimedRewards(pending); + timedReplayInFlight.remove(rewardEntry); + // 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()); } @@ -471,33 +700,7 @@ public void forceRunOfflineRewards() { } setCheckWorld(false); - ArrayList rewards = getOfflineRewards(); - if (rewards.isEmpty()) { - return; - } - - setOfflineRewards(new ArrayList<>()); - RewardHandler rewardHandler = plugin.getRewardHandler(); - AdvancedCoreUser user = this; - - for (String rewardEntry : rewards) { - if (rewardEntry == null || rewardEntry.equals("null")) { - continue; - } - - String[] parts = rewardEntry.split("%placeholders%", 2); - String rewardReference = parts[0]; - String placeholderStr = parts.length > 1 ? parts[1] : ""; - - RewardOptions options = new RewardOptions().setOnline(false).setGiveOffline(false).forceOffline() - .setCheckTimed(false).withPlaceHolder(ArrayUtils.fromString(placeholderStr)); - - rewardHandler.givePersistedQueueRewardAsync(user, new PersistedQueueReference(rewardReference), options) - .exceptionally(failure -> { - restoreOfflineReward(rewardEntry, failure); - return null; - }); - } + dispatchOfflineRewards(true); } /** @@ -1604,6 +1807,15 @@ public void setLastOnline(long online) { * @param offlineRewards the offline rewards */ public void setOfflineRewards(ArrayList offlineRewards) { + setOfflineRewards(offlineRewards, true); + } + + /** Writes an async replay checkpoint before the next stage can execute. */ + private void setOfflineRewardsDurably(ArrayList offlineRewards) { + persistReplayCheckpoint(() -> setOfflineRewards(offlineRewards, false)); + } + + private void setOfflineRewards(ArrayList offlineRewards, boolean queue) { // MySQL TEXT max length is 65535 bytes int maxLength = 65535; String str = String.join("%line%", offlineRewards); @@ -1614,7 +1826,8 @@ public void setOfflineRewards(ArrayList offlineRewards) { str = String.join("%line%", offlineRewards); } - data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards); + if (queue) data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards); + else data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards, false); } /** @@ -1642,6 +1855,25 @@ public void setRepeatAmount(Reward reward, int amount) { * @param timed the timed rewards */ public void setTimedRewards(HashMap timed) { + setTimedRewards(timed, true); + } + + /** Writes an async replay checkpoint before the next stage can execute. */ + private void setTimedRewardsDurably(HashMap timed) { + persistReplayCheckpoint(() -> setTimedRewards(timed, false)); + } + + /** + * A cached user may already have an older queued value for this key. Drain it + * before the direct write so the delayed cache flush cannot replace a replay + * checkpoint that an asynchronous reward stage has already relied on. + */ + private void persistReplayCheckpoint(Runnable write) { + if (isCached()) getCache().flushChangesAndRun(write); + else write.run(); + } + + private void setTimedRewards(HashMap timed, boolean queue) { ArrayList timedRewards = new ArrayList<>(); for (Entry entry : timed.entrySet()) { @@ -1651,7 +1883,8 @@ public void setTimedRewards(HashMap timed) { timedRewards.add(str); } - data.setStringList("TimedRewards", timedRewards); + if (queue) data.setStringList("TimedRewards", timedRewards); + else data.setStringList("TimedRewards", timedRewards, false); } /** diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java index b2b60078bd..c58b17fc9d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java @@ -100,6 +100,31 @@ public synchronized void clearChanges() { } } + /** + * Drains every queued or in-flight cache batch before running a synchronous + * write. The action runs while holding the cache monitor, preventing an old + * queued value from being persisted after a durable replacement. + */ + public void flushChangesAndRun(Runnable action) { + if (action == null) return; + while (true) { + processChanges(); + synchronized (this) { + while (inFlightBatches > 0) { + try { + wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while flushing cached user changes", e); + } + } + if (cachedChanges != null && !cachedChanges.isEmpty()) continue; + action.run(); + return; + } + } + } + public void displayCache() { manager.getPlugin().devDebug(displayCacheStringList().toString()); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index 3d814623f4..e0389a42ab 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 @@ -35,6 +35,7 @@ import com.bencodez.advancedcore.api.rewards.injected.RewardInject; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt; import com.bencodez.advancedcore.api.rewards.builtin.RewardSubRewards; +import com.bencodez.advancedcore.api.rewards.builtin.RewardRandomReward; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; class RewardAsyncInjectionTest { @@ -355,6 +356,109 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, assertEquals(List.of("after-child"), events); } + @Test + void everyNestedRewardInjectorWaitsForItsSelectedChild() { + handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); + when(plugin.getRewardHandler()).thenReturn(handler); + when(user.getPlugin()).thenReturn(plugin); + data.set("RandomReward", new ArrayList<>(List.of("child"))); + CompletableFuture child = new CompletableFuture<>(); + doReturn(child).when(handler).giveRewardAsync(eq(user), eq("child"), any()); + List events = new ArrayList<>(); + RewardRandomReward.register(handler, plugin); + handler.getInjectedRewards().add(new RewardInject("After") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("after-child"); + return null; + } + }.postReward()); + + CompletionStage result = reward.giveInjectedRewardsAsync(user, new HashMap<>()); + assertFalse(result.toCompletableFuture().isDone()); + assertTrue(events.isEmpty()); + child.complete(null); + result.toCompletableFuture().join(); + assertEquals(List.of("after-child"), events); + } + + @Test + void replayCheckpointSkipsAlreadyAppliedInjectionAfterLaterFailure() throws Exception { + AtomicInteger alreadyApplied = new AtomicInteger(); + AtomicBoolean fail = new AtomicBoolean(true); + RewardInject applied = new RewardInject("Money") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + alreadyApplied.incrementAndGet(); + return "receipt-1"; + } + }; + applied.asPlaceholder("receipt"); + handler.getInjectedRewards().add(applied); + handler.getInjectedRewards().add(new RewardInject("Durable") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return fail.get() ? CompletableFuture.failedFuture(new IllegalStateException("temporary")) + : CompletableFuture.completedFuture(null); + } + }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class); + replay.setAccessible(true); + CompletionStage first = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> first.toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertEquals(1, checkpoint.getCompletedInjectionCount()); + assertEquals("receipt-1", checkpoint.getReplayPlaceholders().get("receipt")); + fail.set(false); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), + checkpoint.getCompletedInjectionCount()); + resumed.toCompletableFuture().join(); + assertEquals(1, alreadyApplied.get()); + } + + @Test + void replayProgressDoesNotSuppressASecondSameNamedChildOccurrence() throws Exception { + AtomicInteger deliveries = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Deliver") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + deliveries.incrementAndGet(); + return CompletableFuture.completedFuture(null); + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(java.util.Map.class); + constructor.setAccessible(true); + Object state = constructor.newInstance((Object) null); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + ((CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, state, + "root/Rewards/child:0")).toCompletableFuture().join(); + ((CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, state, + "root/Rewards/child:1")).toCompletableFuture().join(); + assertEquals(2, deliveries.get()); + } + + @Test + void replaySelectionKeepsTheFirstNondeterministicChoice() { + HashMap placeholders = new HashMap<>(); + AtomicInteger selections = new AtomicInteger(); + assertEquals("first", Reward.replaySelection(placeholders, + () -> selections.incrementAndGet() == 1 ? "first" : "second")); + assertEquals("first", Reward.replaySelection(placeholders, + () -> selections.incrementAndGet() == 1 ? "first" : "second")); + assertEquals(1, selections.get()); + } + @Test void nestedAsyncInjectorCanAwaitTheSameSharedInjectorWithoutDeadlocking() { AtomicInteger invocations = new AtomicInteger(); @@ -390,4 +494,11 @@ private RewardInject asyncInjection(String path, CompletionStage complet } }; } + + private Reward.RewardReplayFailure findCheckpoint(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof Reward.RewardReplayFailure) return (Reward.RewardReplayFailure) current; + } + throw new AssertionError("missing replay checkpoint", failure); + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 52926d2b3c..8dafc8ece8 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -17,6 +17,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -124,6 +125,25 @@ public void listRewardDispatchesEveryNamedReward() { assertTrue(options.isOnlineSet()); } + @Test + public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("Child", "Child"))); + Reward child = mock(Reward.class); + when(handler.getReward("Child")).thenReturn(child); + when(child.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + + executor.giveRewardAsync(user, data, "Rewards", new RewardOptions()).toCompletableFuture().join(); + + ArgumentCaptor captured = ArgumentCaptor.forClass(RewardOptions.class); + verify(child, org.mockito.Mockito.times(2)).giveRewardAsync(eq(user), captured.capture()); + List children = captured.getAllValues(); + assertFalse(children.get(0) == children.get(1)); + assertFalse(children.get(0).getAsyncReplayKey().equals(children.get(1).getAsyncReplayKey())); + assertSame(children.get(0).getAsyncReplayState(), children.get(1).getAsyncReplayState()); + } + @Test public void stringRewardDispatchesNamedReward() { YamlConfiguration data = new YamlConfiguration(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 030c207c4b..5218386c3f 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -10,8 +10,12 @@ import static org.mockito.Mockito.when; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import org.junit.jupiter.api.BeforeEach; @@ -20,6 +24,7 @@ import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; @@ -37,6 +42,7 @@ public class AdvancedCoreUserTest { private UserDataManager dataManager; private UserData data; private RewardHandler rewardHandler; + private ScheduledExecutorService delayedTimer; private AdvancedCoreUser user; @BeforeEach @@ -46,6 +52,7 @@ public void setUp() { dataManager = mock(UserDataManager.class); data = mock(UserData.class); rewardHandler = mock(RewardHandler.class); + delayedTimer = mock(ScheduledExecutorService.class); MySQL mysql = mock(MySQL.class); AdvancedCoreConfigOptions configOptions = mock(AdvancedCoreConfigOptions.class); @@ -55,6 +62,7 @@ public void setUp() { when(userManager.getDataManager()).thenReturn(dataManager); when(plugin.getUserManager()).thenReturn(userManager); when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(rewardHandler.getDelayedTimer()).thenReturn(delayedTimer); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getLogger()).thenReturn(mock(Logger.class)); when(userManager.getOfflineRewardsPath()).thenReturn("offlineRewardsPath"); @@ -104,6 +112,23 @@ void forceRunOfflineRewards_stillBypassesNormalReplayChecks() { assertEquals("server-a", options.getPlaceholders().get("Server")); } + @Test + void queuedAsyncReplayResumesAtItsStoredCheckpoint() { + ArrayList rewards = new ArrayList<>(); + rewards.add("VoteReward%asyncprogress%2%placeholders%Server%pair%server-a"); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + + ArgumentCaptor reference = ArgumentCaptor.forClass(PersistedQueueReference.class); + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkOfflineRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), reference.capture(), options.capture()); + assertEquals("VoteReward", reference.getValue().getReference()); + assertEquals(2, options.getValue().getCompletedAsyncInjections()); + } + @Test void setOfflineRewards_emptyList() { ArrayList rewards = new ArrayList<>(); @@ -155,16 +180,15 @@ void failedAsyncOfflineReplayIsRestoredForRetry() { ArrayList initial = new ArrayList<>(); initial.add("VoteReward%placeholders%Server%pair%server-a"); initial.add("VoteReward%placeholders%Server%pair%server-a"); - when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) - .thenReturn(new ArrayList<>(initial)).thenReturn(new ArrayList<>()); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(new ArrayList<>(initial)); when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), any(RewardOptions.class))).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); user.checkOfflineRewards(); ArgumentCaptor> saved = ArgumentCaptor.forClass(ArrayList.class); - verify(data, org.mockito.Mockito.times(3)).setStringList(eq("offlineRewardsPath"), saved.capture()); - assertEquals(initial, saved.getAllValues().get(2)); + verify(data, org.mockito.Mockito.atLeastOnce()).setStringList(eq("offlineRewardsPath"), saved.capture()); + assertTrue(saved.getAllValues().stream().allMatch(value -> value.size() == initial.size())); } @Test @@ -173,15 +197,181 @@ void failedAsyncTimedReplayIsRestoredAfterDueEntriesAreSaved() { String entry = "VoteReward%extime%1%placeholders%Server%pair%server-a"; ArrayList timed = new ArrayList<>(); timed.add(entry + "%ExecutionTime/%" + due); - when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) - .thenReturn(new ArrayList<>(timed)).thenReturn(new ArrayList<>()); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)).thenReturn(new ArrayList<>(timed)); when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), any(RewardOptions.class))).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); user.checkDelayedTimedRewards(); ArgumentCaptor> saved = ArgumentCaptor.forClass(ArrayList.class); - verify(data, org.mockito.Mockito.times(2)).setStringList(eq("TimedRewards"), saved.capture()); - assertEquals(timed, saved.getAllValues().get(1)); + verify(data).setStringList(eq("TimedRewards"), saved.capture()); + String restored = saved.getValue().get(0); + assertTrue(restored.contains("%asyncretry%1%")); + assertTrue(restored.contains("VoteReward%extime%1")); + verify(delayedTimer).schedule(any(Runnable.class), org.mockito.ArgumentMatchers.anyLong(), + eq(TimeUnit.MILLISECONDS)); + } + + @Test + void pendingAsyncOfflineReplayRemainsDurableAndIsNotDispatchedTwice() { + ArrayList rewards = new ArrayList<>(); + rewards.add("VoteReward%placeholders%Server%pair%server-a"); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + user.checkOfflineRewards(); + user.checkOfflineRewards(); + + verify(rewardHandler, org.mockito.Mockito.times(1)).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + verify(data, org.mockito.Mockito.never()).setStringList(eq("offlineRewardsPath"), any()); + pending.complete(null); + assertTrue(rewards.isEmpty()); + verify(data).setStringList(eq("offlineRewardsPath"), any()); + } + + @Test + void offlineCheckpointMigratesOnlyItsOwnDuplicateInFlightClaim() throws Exception { + String entry = "VoteReward%placeholders%Server%pair%server-a"; + ArrayList persisted = new ArrayList<>(); + persisted.add(entry); + persisted.add(entry); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkOfflineRewards(); + verify(rewardHandler, org.mockito.Mockito.times(2)).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), options.capture()); + options.getAllValues().get(0).getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("VoteReward", 1), + new HashMap<>(Map.of("Server", "server-a")))); + + assertEquals(2, persisted.size()); + assertTrue(persisted.stream().anyMatch(value -> value.contains("%asyncprogress%v2-"))); + assertTrue(persisted.stream().anyMatch(value -> value.equals(entry))); + user.checkOfflineRewards(); + verify(rewardHandler, org.mockito.Mockito.times(2)).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + verify(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + } + + @Test + void timedAsyncReplayPreservesItsStoredCheckpointAfterExecutionMarker() { + long due = System.currentTimeMillis() - 1_000; + ArrayList timed = new ArrayList<>(); + timed.add("VoteReward%extime%1%asyncprogress%2%placeholders%Server%pair%server-a%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)).thenReturn(timed); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + + ArgumentCaptor reference = ArgumentCaptor.forClass(PersistedQueueReference.class); + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), reference.capture(), options.capture()); + assertEquals("VoteReward", reference.getValue().getReference()); + assertEquals(2, options.getValue().getCompletedAsyncInjections()); + } + + @Test + void timedAsyncCheckpointIsDurableAndMigratesTheExactEntryWhileInFlight() throws Exception { + long due = System.currentTimeMillis() - 1_000; + String original = "VoteReward%extime%12345%placeholders%Server%pair%server-a"; + ArrayList persisted = new ArrayList<>(); + persisted.add(original + "%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), eq(false)); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), options.capture()); + options.getValue().getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("VoteReward", 1), + new HashMap<>(Map.of("Server", "server-a")))); + + assertEquals(1, persisted.size()); + assertTrue(persisted.get(0).startsWith("VoteReward%extime%12345%asyncprogress%v2-")); + assertTrue(persisted.get(0).endsWith("%ExecutionTime/%" + due)); + verify(data).setStringList(eq("TimedRewards"), any(), eq(false)); + // The key has migrated, but its in-flight claim must migrate with it too. + user.checkDelayedTimedRewards(); + verify(rewardHandler, org.mockito.Mockito.times(1)).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + } + + @Test + void timedAsyncCheckpointSurvivesRestartBeforeTheInFlightCompletion() throws Exception { + long due = System.currentTimeMillis() - 1_000; + ArrayList persisted = new ArrayList<>(); + persisted.add("VoteReward%extime%12345%placeholders%Server%pair%server-a%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), eq(false)); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + ArgumentCaptor firstOptions = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), firstOptions.capture()); + firstOptions.getValue().getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("root/Grant:0", 1), + new HashMap<>(Map.of("Server", "server-a")))); + + AdvancedCoreUser restarted = new AdvancedCoreUser(plugin, UUID.randomUUID(), "Test"); + restarted.setData(data); + when(rewardHandler.givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + ArgumentCaptor resumed = ArgumentCaptor.forClass(RewardOptions.class); + restarted.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), resumed.capture()); + assertEquals(1, resumed.getValue().getAsyncReplayProgress().get("root/Grant:0")); + assertEquals("server-a", resumed.getValue().getPlaceholders().get("Server")); + } + + @Test + void timedRetryParsesProgressAfterTheExecutionMarkerWasConsumed() { + long due = System.currentTimeMillis() - 1_000; + ArrayList timed = new ArrayList<>(); + timed.add("VoteReward%asyncprogress%2%asyncretry%1%placeholders%Server%pair%server-a%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)).thenReturn(timed); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + + ArgumentCaptor reference = ArgumentCaptor.forClass(PersistedQueueReference.class); + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), reference.capture(), options.capture()); + assertEquals("VoteReward", reference.getValue().getReference()); + assertEquals(2, options.getValue().getCompletedAsyncInjections()); + } + + private Reward.ReplayCheckpoint replayCheckpoint(Map progress, + HashMap placeholders) throws Exception { + java.lang.reflect.Constructor constructor = Reward.ReplayCheckpoint.class + .getDeclaredConstructor(Map.class, HashMap.class); + constructor.setAccessible(true); + return constructor.newInstance(progress, placeholders); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java index eaf79a2554..838513c60e 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java @@ -195,4 +195,35 @@ public void updateCacheDefensivelyCopiesInput() { assertTrue(cache.isCached("PlayerName")); } + + @Test + public void flushChangesAndRunPersistsQueuedWritesBeforeTheSynchronousAction() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager userManager = mock(UserManager.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserData userData = mock(UserData.class); + UserDataManager manager = mock(UserDataManager.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + ScheduledFuture future = mock(ScheduledFuture.class); + UUID uuid = UUID.randomUUID(); + + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + when(plugin.getUserManager()).thenReturn(userManager); + when(userManager.getUser(uuid, false)).thenReturn(user); + when(user.getUserData()).thenReturn(userData); + doReturn(future).when(timer).schedule(any(Runnable.class), eq(3L), eq(TimeUnit.SECONDS)); + + java.util.concurrent.atomic.AtomicBoolean flushed = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(ignored -> { + flushed.set(true); + return null; + }).when(userData).setValues(any(HashMap.class)); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.addChange(new UserDataChangeString("TimedRewards", "old-value"), true); + + cache.flushChangesAndRun(() -> assertTrue(flushed.get(), + "the durable replay checkpoint must follow the queued cache flush")); + assertFalse(cache.hasChangesToProcess()); + } } From f5551d82f1c8853e47fd5bfa6c5cc64fa0f312c4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:14:44 -0600 Subject: [PATCH 04/78] Bind async replay to stable occurrences --- .../advancedcore/api/rewards/Reward.java | 202 ++++++++++++--- .../api/rewards/RewardOptions.java | 21 ++ .../builtin/RewardAdvancedRewards.java | 3 +- .../rewards/builtin/RewardAdvancedWorld.java | 3 +- .../api/rewards/builtin/RewardLucky.java | 3 +- .../api/user/AdvancedCoreUser.java | 123 +++++++-- .../rewards/RewardAsyncInjectionTest.java | 238 ++++++++++++++++++ .../tests/user/AdvancedCoreUserTest.java | 59 +++++ 8 files changed, 600 insertions(+), 52 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 cf0b541861..473bdaba4b 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.rewards; import java.io.File; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.text.SimpleDateFormat; import java.time.LocalDateTime; import java.time.ZoneId; @@ -12,6 +14,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.Map.Entry; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.TimeUnit; @@ -43,6 +47,7 @@ public class Reward { private static final ThreadLocal ACTIVE_REPLAY_STATE = new ThreadLocal<>(); private static final ThreadLocal ACTIVE_REPLAY_KEY = new ThreadLocal<>(); + private static final ThreadLocal ACTIVE_REPLAY_OCCURRENCE_ID = new ThreadLocal<>(); private static final String REPLAY_SELECTION_PREFIX = "__advancedcore_replay_selection_"; @Getter @@ -271,7 +276,16 @@ public void giveInjectedRewards(AdvancedCoreUser user, HashMap p */ public CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, HashMap placeholders) { - return giveInjectedRewardsAsync(user, placeholders, 0, new ReplayState(null), getRewardName()); + return giveInjectedRewardsAsync(user, placeholders, 0, new ReplayState(null), getRewardName(), + UUID.randomUUID().toString()); + } + + // Compatibility bridge for internal callers/tests that only carry replay path + // metadata. Persisted queue dispatch supplies the explicit occurrence id below. + private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, + HashMap placeholders, int completedStages, ReplayState replayState, String replayKey) { + return giveInjectedRewardsAsync(user, placeholders, completedStages, replayState, replayKey, + UUID.randomUUID().toString()); } /** @@ -280,27 +294,32 @@ public CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, * avoid replaying already-applied non-idempotent injections. */ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, - HashMap placeholders, int completedStages, ReplayState replayState, String replayKey) { - List postRewards = new ArrayList<>(); - List orderedRewards = new ArrayList<>(); - for (RewardInject inject : plugin.getRewardHandler().getInjectedRewards()) { - if (inject.isPostReward()) postRewards.add(inject); - else orderedRewards.add(inject); - } - orderedRewards.addAll(postRewards); + HashMap placeholders, int completedStages, ReplayState replayState, String replayKey, + String occurrenceId) { + List orderedRewards = orderedInjectedRewards(); int resumeAfter = Math.max(0, replayState.getCompleted(replayKey, completedStages)); + String registryFingerprint = injectionRegistryFingerprint(orderedRewards); + if (!replayState.matchesRegistryFingerprint(registryFingerprint)) { + return CompletableFuture.failedFuture(new IncompatibleReplayCheckpointException(replayKey)); + } AtomicInteger completed = new AtomicInteger(resumeAfter); CompletionStage sequence = CompletableFuture.completedFuture(null); for (int index = 0; index < orderedRewards.size(); index++) { if (index < resumeAfter) continue; final RewardInject inject = orderedRewards.get(index); final String injectionKey = replayKey + "/" + index; - sequence = sequence.thenCompose(ignored -> invokeInjectionAsync(inject, user, placeholders, replayState, - injectionKey)) + sequence = sequence.thenCompose(ignored -> { + // A nested injector can checkpoint a child before this parent injector + // completes. Bind the parent's registry first so that child checkpoint + // cannot later be resumed against a shifted parent ordinal. + replayState.setRegistryFingerprint(replayKey, registryFingerprint); + return invokeInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId); + }) .thenApply(result -> { if (inject.isAddAsPlaceholder() && result != null) addPlaceholder(inject, result, placeholders); int checkpoint = completed.incrementAndGet(); replayState.setCompleted(replayKey, checkpoint); + replayState.setRegistryFingerprint(replayKey, registryFingerprint); replayState.persistCheckpoint(placeholders); return result; }).thenCompose(ignored -> resumeOnServerThread(user)); @@ -314,11 +333,47 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, }); } + private List orderedInjectedRewards() { + List postRewards = new ArrayList<>(); + List orderedRewards = new ArrayList<>(); + for (RewardInject inject : plugin.getRewardHandler().getInjectedRewards()) { + if (inject.isPostReward()) postRewards.add(inject); + else orderedRewards.add(inject); + } + orderedRewards.addAll(postRewards); + return orderedRewards; + } + + /** + * A persisted checkpoint is valid only for this exact ordered injector + * registry. Paths, implementation classes, priorities, and post-reward + * placement are all included because each affects which side effect an + * ordinal identifies. + */ + private static String injectionRegistryFingerprint(List orderedRewards) { + StringBuilder registry = new StringBuilder(); + for (RewardInject inject : orderedRewards) { + String path = inject.getPath() == null ? "" : inject.getPath(); + registry.append(inject.getClass().getName()).append('\t').append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(path.getBytes(StandardCharsets.UTF_8))).append('\t') + .append(inject.getPriority()).append('\t').append(inject.isPostReward()).append('\n'); + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(registry.toString().getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte value : digest) hex.append(String.format("%02x", value & 0xff)); + return hex.toString(); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable for reward replay checkpoints", failure); + } + } + // Kept as a narrow compatibility bridge for internal callers/tests that only // have a single reward checkpoint. private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, HashMap placeholders, int completedStages) { - return giveInjectedRewardsAsync(user, placeholders, completedStages, new ReplayState(null), getRewardName()); + return giveInjectedRewardsAsync(user, placeholders, completedStages, new ReplayState(null), getRewardName(), + UUID.randomUUID().toString()); } /** Details the last durably safe replay checkpoint when an async chain fails. */ @@ -337,6 +392,16 @@ private RewardReplayFailure(ReplayState replayState, HashMap rep } public Map getReplayProgress() { return replayState.copyProgress(); } + public Map getReplayRegistryFingerprints() { return replayState.copyRegistryFingerprints(); } + } + + /** Prevents an ordinal checkpoint from targeting a changed injector registry. */ + private static final class IncompatibleReplayCheckpointException extends IllegalStateException { + private static final long serialVersionUID = 1L; + private IncompatibleReplayCheckpointException(String replayKey) { + super("Cannot safely resume asynchronous reward replay '" + replayKey + + "' because its injector registry changed or its legacy checkpoint has no registry fingerprint"); + } } private static RewardReplayFailure findReplayFailure(Throwable failure) { @@ -352,12 +417,16 @@ private static RewardReplayFailure findReplayFailure(Throwable failure) { /** Captures the active parent execution path before deferred work runs. */ public static String currentReplayKey() { return ACTIVE_REPLAY_KEY.get(); } + /** Returns the unique logical reward occurrence currently invoking an injector. */ + public static String currentReplayOccurrenceId() { return ACTIVE_REPLAY_OCCURRENCE_ID.get(); } + /** Obtains one shared replay state for a group of nested dispatches. */ public static ReplayState replayStateFor(RewardOptions options) { ReplayState replayState = options.getAsyncReplayState(); if (replayState == null) { replayState = ACTIVE_REPLAY_STATE.get(); - if (replayState == null) replayState = new ReplayState(options.getAsyncReplayProgress()); + if (replayState == null) replayState = new ReplayState(options.getAsyncReplayProgress(), + options.getAsyncReplayRegistryFingerprints(), options.isLegacyAsyncReplayCheckpoint()); options.setAsyncReplayState(replayState); } if (options.getAsyncReplayCheckpointConsumer() != null) { @@ -389,6 +458,8 @@ public static String replaySelection(HashMap placeholders, Suppl /** Attaches a captured replay state to an option object for a deferred child. */ public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState) { if (replayState != null) options.setAsyncReplayState(replayState); + String occurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get(); + if (occurrenceId != null) options.setAsyncReplayOccurrenceId(occurrenceId); return options; } @@ -409,7 +480,18 @@ public static RewardOptions withReplayState(RewardOptions options, ReplayState r */ public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState, String parentKey, String childOccurrence) { - withReplayState(options, replayState); + return withReplayState(options, replayState, parentKey, childOccurrence, ACTIVE_REPLAY_OCCURRENCE_ID.get()); + } + + /** + * Explicit deferred-child form. Completion callbacks must carry both the + * replay path and its logical occurrence because ThreadLocal context is not + * retained after an asynchronous parent injector returns. + */ + public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState, + String parentKey, String childOccurrence, String occurrenceId) { + if (replayState != null) options.setAsyncReplayState(replayState); + if (occurrenceId != null) options.setAsyncReplayOccurrenceId(occurrenceId); if (parentKey != null && childOccurrence != null) { options.setAsyncReplayKey(parentKey + "/" + childOccurrence); } @@ -419,13 +501,41 @@ public static RewardOptions withReplayState(RewardOptions options, ReplayState r /** Internal shared state passed through nested async reward dispatch. */ public static final class ReplayState { private final HashMap completed = new HashMap<>(); + private final HashMap registryFingerprints = new HashMap<>(); + private final boolean legacyCheckpoint; private Consumer checkpointConsumer; - private ReplayState(Map initial) { if (initial != null) completed.putAll(initial); } + private ReplayState(Map initial) { this(initial, null, false); } + private ReplayState(Map initial, Map initialFingerprints, + boolean legacyCheckpoint) { + if (initial != null) completed.putAll(initial); + if (initialFingerprints != null) registryFingerprints.putAll(initialFingerprints); + this.legacyCheckpoint = legacyCheckpoint; + } private synchronized int getCompleted(String rewardName, int fallback) { return completed.getOrDefault(rewardName, fallback); } private synchronized void setCompleted(String rewardName, int count) { completed.put(rewardName, count); } private synchronized Map copyProgress() { return new HashMap<>(completed); } + private synchronized void setRegistryFingerprint(String rewardName, String fingerprint) { + registryFingerprints.put(rewardName, fingerprint); + } + private synchronized Map copyRegistryFingerprints() { + return new HashMap<>(registryFingerprints); + } + private synchronized boolean hasPersistedCheckpoint() { + return legacyCheckpoint || !completed.isEmpty() || !registryFingerprints.isEmpty(); + } + private synchronized boolean matchesRegistryFingerprint(String currentFingerprint) { + if (legacyCheckpoint) return false; + for (Entry entry : completed.entrySet()) { + if (entry.getValue() != null && entry.getValue() > 0 + && !currentFingerprint.equals(registryFingerprints.get(entry.getKey()))) return false; + } + for (String fingerprint : registryFingerprints.values()) { + if (!currentFingerprint.equals(fingerprint)) return false; + } + return true; + } private synchronized int highestCompletedCount() { int highest = 0; for (int count : completed.values()) highest = Math.max(highest, count); @@ -437,27 +547,33 @@ private synchronized void setCheckpointConsumer(Consumer consu private void persistCheckpoint(HashMap placeholders) { Consumer consumer; synchronized (this) { consumer = checkpointConsumer; } - if (consumer != null) consumer.accept(new ReplayCheckpoint(copyProgress(), placeholders)); + if (consumer != null) consumer.accept(new ReplayCheckpoint(copyProgress(), copyRegistryFingerprints(), placeholders)); } } /** Immutable durable replay data emitted after every completed injection. */ public static final class ReplayCheckpoint { @Getter private final Map replayProgress; + @Getter private final Map replayRegistryFingerprints; @Getter private final HashMap placeholders; private ReplayCheckpoint(Map replayProgress, HashMap placeholders) { + this(replayProgress, new HashMap<>(), placeholders); + } + private ReplayCheckpoint(Map replayProgress, Map replayRegistryFingerprints, + HashMap placeholders) { this.replayProgress = new HashMap<>(replayProgress); + this.replayRegistryFingerprints = new HashMap<>(replayRegistryFingerprints); this.placeholders = new HashMap<>(placeholders); } } private CompletionStage invokeInjectionAsync(RewardInject inject, AdvancedCoreUser user, - HashMap placeholders, ReplayState replayState, String injectionKey) { + HashMap placeholders, ReplayState replayState, String injectionKey, String occurrenceId) { try { if (!plugin.isEnabled()) return CompletableFuture.failedFuture( new IllegalStateException("Plugin disabled before reward injection completed")); Supplier> request = () -> requestOnServerThread(user, - () -> requestInjectionAsync(inject, user, placeholders, replayState, injectionKey)); + () -> requestInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId)); CompletionStage result = inject.isSynchronize() && inject.supportsAsyncSynchronization() ? inject.runSynchronizedAsync(request) : request.get(); @@ -532,11 +648,14 @@ protected long getServerThreadDispatchTimeoutMillis() { } private CompletionStage requestInjectionAsync(RewardInject inject, AdvancedCoreUser user, - HashMap placeholders, ReplayState replayState, String injectionKey) { + HashMap placeholders, ReplayState replayState, String injectionKey, String occurrenceId) { ReplayState previous = ACTIVE_REPLAY_STATE.get(); String previousKey = ACTIVE_REPLAY_KEY.get(); + String previousOccurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get(); ACTIVE_REPLAY_STATE.set(replayState); ACTIVE_REPLAY_KEY.set(injectionKey); + if (occurrenceId == null) ACTIVE_REPLAY_OCCURRENCE_ID.remove(); + else ACTIVE_REPLAY_OCCURRENCE_ID.set(occurrenceId); try { if (inject.supportsAsyncRequest()) { return inject.onRewardRequestAsync(this, user, getConfig().getConfigData(), placeholders); @@ -553,6 +672,8 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan } finally { if (previous == null) ACTIVE_REPLAY_STATE.remove(); else ACTIVE_REPLAY_STATE.set(previous); if (previousKey == null) ACTIVE_REPLAY_KEY.remove(); else ACTIVE_REPLAY_KEY.set(previousKey); + if (previousOccurrenceId == null) ACTIVE_REPLAY_OCCURRENCE_ID.remove(); + else ACTIVE_REPLAY_OCCURRENCE_ID.set(previousOccurrenceId); } } @@ -778,7 +899,9 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption if (canGive || isForceOffline() || rewardOptions.isForceOffline()) { plugin.debug(name + ": Passed requirements, attempting to give to " + user.getPlayerName() + "/" + user.getUUID()); - if (hasAsyncRewardInjection()) return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions); + if (hasAsyncRewardInjection() || hasPersistedReplayCheckpoint(rewardOptions)) { + return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions); + } try { giveRewardUser(user, rewardOptions.getPlaceholders(), rewardOptions); return CompletableFuture.completedFuture(null); @@ -797,7 +920,7 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption * @param rewardOptions rewardOptions */ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { - if (hasAsyncRewardInjection()) { + if (hasAsyncRewardInjection() || hasPersistedReplayCheckpoint(rewardOptions)) { giveRewardUserAsync(user, phs, rewardOptions).exceptionally(failure -> { logRewardUserFailure(failure); return null; @@ -825,6 +948,27 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, R */ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { + ReplayState replayState = replayStateFor(rewardOptions); + String replayKey = rewardOptions.getAsyncReplayKey(); + if (replayKey == null) { + String parentKey = ACTIVE_REPLAY_KEY.get(); + replayKey = parentKey == null ? getRewardName() : parentKey + "/" + getRewardName(); + rewardOptions.setAsyncReplayKey(replayKey); + } + if (!replayState.matchesRegistryFingerprint(injectionRegistryFingerprint(orderedInjectedRewards()))) { + return CompletableFuture.failedFuture(new IncompatibleReplayCheckpointException(replayKey)); + } + String occurrenceId = rewardOptions.getAsyncReplayOccurrenceId(); + if (occurrenceId == null || occurrenceId.isEmpty()) { + occurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get(); + // A pre-occurrence persisted entry cannot safely survive an ambiguous + // side effect, so leave it on the legacy compatibility path rather than + // inventing a different id for each retry. + if (occurrenceId == null && rewardOptions.getAsyncReplayCheckpointConsumer() == null) { + occurrenceId = UUID.randomUUID().toString(); + rewardOptions.setAsyncReplayOccurrenceId(occurrenceId); + } + } final HashMap placeholders; try { placeholders = prepareRewardUser(user, phs); @@ -834,17 +978,17 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< if (placeholders == null) { return CompletableFuture.completedFuture(null); } - ReplayState replayState = replayStateFor(rewardOptions); - String replayKey = rewardOptions.getAsyncReplayKey(); - if (replayKey == null) { - String parentKey = ACTIVE_REPLAY_KEY.get(); - replayKey = parentKey == null ? getRewardName() : parentKey + "/" + getRewardName(); - rewardOptions.setAsyncReplayKey(replayKey); - } - return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, replayKey) + return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, replayKey, + occurrenceId) .thenRun(() -> plugin.debug("Gave " + user.getPlayerName() + " reward " + name)); } + private static boolean hasPersistedReplayCheckpoint(RewardOptions options) { + return options.isLegacyAsyncReplayCheckpoint() || options.getCompletedAsyncInjections() > 0 + || !options.getAsyncReplayProgress().isEmpty() || !options.getAsyncReplayRegistryFingerprints().isEmpty() + || (options.getAsyncReplayState() != null && options.getAsyncReplayState().hasPersistedCheckpoint()); + } + private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap phs) { Player player = user.getPlayer(); 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 b26f3b77d7..8a42f4c027 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 @@ -59,6 +59,15 @@ public class RewardOptions { @Setter private Map asyncReplayProgress = new HashMap<>(); + @Getter + @Setter + private Map asyncReplayRegistryFingerprints = new HashMap<>(); + + /** True when this queued entry used the old count-only replay format. */ + @Getter + @Setter + private boolean legacyAsyncReplayCheckpoint; + @Getter @Setter private Reward.ReplayState asyncReplayState; @@ -71,6 +80,15 @@ public class RewardOptions { @Setter private String asyncReplayKey; + /** + * Stable identity for one logical queued reward occurrence. It is distinct + * from {@link #asyncReplayKey}, which identifies a stage path shared by + * independent executions of the same reward definition. + */ + @Getter + @Setter + private String asyncReplayOccurrenceId; + @Getter @Setter private Consumer asyncReplayCheckpointConsumer; @@ -224,7 +242,10 @@ RewardOptions copyForNestedDispatch(String replayKey) { if (onlineSet) copy.setOnline(online); if (!server.isEmpty()) copy.setServer(server); copy.setAsyncReplayState(asyncReplayState); + copy.setAsyncReplayRegistryFingerprints(new HashMap<>(asyncReplayRegistryFingerprints)); + copy.setLegacyAsyncReplayCheckpoint(legacyAsyncReplayCheckpoint); copy.setAsyncReplayKey(replayKey); + copy.setAsyncReplayOccurrenceId(asyncReplayOccurrenceId); copy.setAsyncReplayCheckpointConsumer(asyncReplayCheckpointConsumer); return copy; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java index c61277d1b1..87e876bd52 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java @@ -71,6 +71,7 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU CompletionStage sequence = CompletableFuture.completedFuture(null); com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); String parentReplayKey = Reward.currentReplayKey(); + String parentOccurrenceId = Reward.currentReplayOccurrenceId(); int rewardIndex = 0; for (String rewardName : ArrayUtils.convert(data.getConfigurationSection(getPath()).getKeys(false))) { final int childIndex = rewardIndex++; @@ -78,7 +79,7 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU data.getConfigurationSection(getPath()), rewardName, Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), replayState, parentReplayKey, - rewardName + ":" + childIndex) + rewardName + ":" + childIndex, parentOccurrenceId) .setPrefix(reward.getRewardName() + "_AdvancedRewards"))); } return sequence.thenApply(ignored -> null); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java index 1ccd426457..d6c6d1418f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java @@ -58,6 +58,7 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan CompletionStage sequence = CompletableFuture.completedFuture(null); com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); String parentReplayKey = Reward.currentReplayKey(); + String parentOccurrenceId = Reward.currentReplayOccurrenceId(); int worldIndex = 0; for (String key : section.getKeys(false)) { final int childIndex = worldIndex++; @@ -65,7 +66,7 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, section, key, Reward.withReplayState(new RewardOptions().withPlaceHolder(placeholders), replayState, parentReplayKey, - key + ":" + childIndex) + key + ":" + childIndex, parentOccurrenceId) .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld"))); } return sequence.thenApply(ignored -> null); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java index b3d0373d03..341541a6d5 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java @@ -108,6 +108,7 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor CompletionStage sequence = CompletableFuture.completedFuture(null); com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); String parentReplayKey = Reward.currentReplayKey(); + String parentOccurrenceId = Reward.currentReplayOccurrenceId(); int luckyIndex = 0; for (String path : choices.split("\\n")) { final int childIndex = luckyIndex++; @@ -115,7 +116,7 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) .withPrefix(reward.getName()).withPlaceHolder(placeholders); Reward.withReplayState(builder.getRewardOptions(), replayState, parentReplayKey, - path + ":" + childIndex); + path + ":" + childIndex, parentOccurrenceId); return builder.sendAsync(user); }); } 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 de3d3103e2..cd9255d0c4 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 @@ -61,6 +61,7 @@ public class AdvancedCoreUser { private static final String QUEUED_REFERENCE_PREFIX = "\\AdvancedCoreQueue/1/"; private static final String ASYNC_PROGRESS_DELIMITER = "%asyncprogress%"; private static final String ASYNC_RETRY_DELIMITER = "%asyncretry%"; + private static final String ASYNC_OCCURRENCE_DELIMITER = "%asyncoccurrence%"; private final HashMap offlineReplayInFlight = new HashMap<>(); private final HashSet timedReplayInFlight = new HashSet<>(); @@ -269,13 +270,34 @@ private String queuedRewardReference(Reward reward) { String encodedName = Base64.getUrlEncoder().withoutPadding() .encodeToString(reward.getRewardName().getBytes(StandardCharsets.UTF_8)); return QUEUED_REFERENCE_PREFIX + (reward.isGeneratedSnapshotCreated() ? "snapshot/" : "normal/") - + encodedName; + + encodedName + ASYNC_OCCURRENCE_DELIMITER + UUID.randomUUID(); } private static QueuedReplay parseQueuedReplay(String storedReference) { - int marker = storedReference.lastIndexOf(ASYNC_PROGRESS_DELIMITER); - if (marker < 0) return new QueuedReplay(storedReference, 0, new HashMap<>()); - String value = storedReference.substring(marker + ASYNC_PROGRESS_DELIMITER.length()); + String occurrenceId = occurrenceId(storedReference); + String withoutOccurrence = stripAsyncOccurrenceMarker(storedReference); + int marker = withoutOccurrence.lastIndexOf(ASYNC_PROGRESS_DELIMITER); + if (marker < 0) return new QueuedReplay(withoutOccurrence, 0, new HashMap<>(), new HashMap<>(), false, + occurrenceId); + String value = withoutOccurrence.substring(marker + ASYNC_PROGRESS_DELIMITER.length()); + if (value.startsWith("v3-")) { + try { + HashMap progress = new HashMap<>(); + HashMap fingerprints = new HashMap<>(); + String decoded = new String(Base64.getUrlDecoder().decode(value.substring(3)), StandardCharsets.UTF_8); + for (String line : decoded.split("\\n")) { + String[] pair = line.split("\\t", 3); + if (pair.length != 3) throw new IllegalArgumentException("Malformed replay checkpoint"); + progress.put(new String(Base64.getUrlDecoder().decode(pair[0]), StandardCharsets.UTF_8), + Integer.parseInt(pair[1])); + fingerprints.put(new String(Base64.getUrlDecoder().decode(pair[0]), StandardCharsets.UTF_8), pair[2]); + } + return new QueuedReplay(withoutOccurrence.substring(0, marker), 0, progress, fingerprints, false, + occurrenceId); + } catch (IllegalArgumentException ignored) { + return new QueuedReplay(withoutOccurrence, 0, new HashMap<>(), new HashMap<>(), false, occurrenceId); + } + } if (value.startsWith("v2-")) { try { HashMap progress = new HashMap<>(); @@ -284,22 +306,51 @@ private static QueuedReplay parseQueuedReplay(String storedReference) { String[] pair = line.split("\\t", 2); if (pair.length == 2) progress.put(pair[0], Integer.parseInt(pair[1])); } - return new QueuedReplay(storedReference.substring(0, marker), 0, progress); + return new QueuedReplay(withoutOccurrence.substring(0, marker), 0, progress, new HashMap<>(), true, + occurrenceId); } catch (IllegalArgumentException ignored) { - return new QueuedReplay(storedReference, 0, new HashMap<>()); + return new QueuedReplay(withoutOccurrence, 0, new HashMap<>(), new HashMap<>(), false, occurrenceId); } } try { int progress = Integer.parseInt(value); - return progress > 0 ? new QueuedReplay(storedReference.substring(0, marker), progress, new HashMap<>()) - : new QueuedReplay(storedReference.substring(0, marker), 0, new HashMap<>()); + return progress > 0 ? new QueuedReplay(withoutOccurrence.substring(0, marker), progress, new HashMap<>(), new HashMap<>(), true, + occurrenceId) + : new QueuedReplay(withoutOccurrence.substring(0, marker), 0, new HashMap<>(), new HashMap<>(), false, + occurrenceId); } catch (NumberFormatException ignored) { // Preserve malformed legacy values as a normal reward reference instead // of accidentally skipping an arbitrary portion of a reward chain. - return new QueuedReplay(storedReference, 0, new HashMap<>()); + return new QueuedReplay(withoutOccurrence, 0, new HashMap<>(), new HashMap<>(), false, occurrenceId); + } + } + + private static String occurrenceId(String storedReference) { + int marker = storedReference.indexOf(ASYNC_OCCURRENCE_DELIMITER); + if (marker < 0) return null; + int start = marker + ASYNC_OCCURRENCE_DELIMITER.length(); + int end = storedReference.indexOf('%', start); + String candidate = storedReference.substring(start, end < 0 ? storedReference.length() : end); + try { + return UUID.fromString(candidate).toString(); + } catch (IllegalArgumentException ignored) { + return null; } } + private static String stripAsyncOccurrenceMarker(String storedReference) { + int marker = storedReference.indexOf(ASYNC_OCCURRENCE_DELIMITER); + if (marker < 0) return storedReference; + int start = marker + ASYNC_OCCURRENCE_DELIMITER.length(); + int end = storedReference.indexOf('%', start); + return storedReference.substring(0, marker) + (end < 0 ? "" : storedReference.substring(end)); + } + + private static String queuedReference(QueuedReplay replay) { + return replay.rewardReference + (replay.asyncReplayOccurrenceId == null ? "" + : ASYNC_OCCURRENCE_DELIMITER + replay.asyncReplayOccurrenceId); + } + private static String encodeAsyncReplayProgress(Map progress) { if (progress.isEmpty()) return ""; StringBuilder encoded = new StringBuilder(); @@ -312,6 +363,25 @@ private static String encodeAsyncReplayProgress(Map progress) { .encodeToString(encoded.toString().getBytes(StandardCharsets.UTF_8)); } + private static String encodeAsyncReplayProgress(Map progress, Map fingerprints) { + if (fingerprints.isEmpty()) return encodeAsyncReplayProgress(progress); + StringBuilder encoded = new StringBuilder(); + for (Entry entry : progress.entrySet()) { + String fingerprint = fingerprints.get(entry.getKey()); + if (entry.getValue() != null && entry.getValue() > 0 && fingerprint == null) { + return encodeAsyncReplayProgress(progress); + } + } + for (Entry entry : fingerprints.entrySet()) { + if (entry.getValue() == null) continue; + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(entry.getKey().getBytes(StandardCharsets.UTF_8))).append('\t') + .append(progress.getOrDefault(entry.getKey(), 0)).append('\t').append(entry.getValue()).append('\n'); + } + return encoded.length() == 0 ? encodeAsyncReplayProgress(progress) : "v3-" + + Base64.getUrlEncoder().withoutPadding().encodeToString(encoded.toString().getBytes(StandardCharsets.UTF_8)); + } + private static String stripTimedExecutionMarker(String storedReference) { int marker = storedReference.indexOf("%extime%"); if (marker < 0) return stripAsyncRetryMarker(storedReference); @@ -348,27 +418,27 @@ private static String withAsyncRetryCount(String rewardEntry, int count) { private static String withAsyncReplayProgress(String rewardEntry, Throwable failure) { Reward.RewardReplayFailure replayFailure = replayFailure(failure); int completed = completedAsyncInjections(failure); - String serializedProgress = replayFailure == null ? "" : encodeAsyncReplayProgress(replayFailure.getReplayProgress()); + String serializedProgress = replayFailure == null ? "" : encodeAsyncReplayProgress(replayFailure.getReplayProgress(), + replayFailure.getReplayRegistryFingerprints()); if (completed <= 0 && serializedProgress.isEmpty()) return rewardEntry; int placeholders = rewardEntry.indexOf("%placeholders%"); String storedReference = placeholders < 0 ? rewardEntry : rewardEntry.substring(0, placeholders); String suffix = placeholders < 0 ? "" : rewardEntry.substring(placeholders); if (replayFailure != null) suffix = "%placeholders%" + ArrayUtils.makeString(replayFailure.getReplayPlaceholders()); QueuedReplay queuedReplay = parseQueuedReplay(stripAsyncRetryMarker(storedReference)); - if (!serializedProgress.isEmpty()) return queuedReplay.rewardReference + ASYNC_PROGRESS_DELIMITER + if (!serializedProgress.isEmpty()) return queuedReference(queuedReplay) + ASYNC_PROGRESS_DELIMITER + serializedProgress + suffix; - return queuedReplay.rewardReference + ASYNC_PROGRESS_DELIMITER + return queuedReference(queuedReplay) + ASYNC_PROGRESS_DELIMITER + Math.max(queuedReplay.completedAsyncInjections, completed) + suffix; } - private static String withAsyncReplayProgress(String rewardEntry, Map progress, - HashMap placeholders) { + private static String withAsyncReplayProgress(String rewardEntry, Reward.ReplayCheckpoint checkpoint) { int marker = rewardEntry.indexOf("%placeholders%"); String reference = marker < 0 ? rewardEntry : rewardEntry.substring(0, marker); QueuedReplay queuedReplay = parseQueuedReplay(stripAsyncRetryMarker(reference)); - String serialized = encodeAsyncReplayProgress(progress); - return queuedReplay.rewardReference + (serialized.isEmpty() ? "" : ASYNC_PROGRESS_DELIMITER + serialized) - + "%placeholders%" + ArrayUtils.makeString(placeholders); + String serialized = encodeAsyncReplayProgress(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints()); + return queuedReference(queuedReplay) + (serialized.isEmpty() ? "" : ASYNC_PROGRESS_DELIMITER + serialized) + + "%placeholders%" + ArrayUtils.makeString(checkpoint.getPlaceholders()); } private static int completedAsyncInjections(Throwable failure) { @@ -393,12 +463,19 @@ private static final class QueuedReplay { private final String rewardReference; private final int completedAsyncInjections; private final Map asyncReplayProgress; + private final Map asyncReplayRegistryFingerprints; + private final boolean legacyAsyncReplayCheckpoint; + private final String asyncReplayOccurrenceId; private QueuedReplay(String rewardReference, int completedAsyncInjections, - Map asyncReplayProgress) { + Map asyncReplayProgress, Map asyncReplayRegistryFingerprints, + boolean legacyAsyncReplayCheckpoint, String asyncReplayOccurrenceId) { this.rewardReference = rewardReference; this.completedAsyncInjections = completedAsyncInjections; this.asyncReplayProgress = asyncReplayProgress; + this.asyncReplayRegistryFingerprints = asyncReplayRegistryFingerprints; + this.legacyAsyncReplayCheckpoint = legacyAsyncReplayCheckpoint; + this.asyncReplayOccurrenceId = asyncReplayOccurrenceId; } } @@ -463,6 +540,9 @@ public void checkDelayedTimedRewards() { .withPlaceHolder(ArrayUtils.fromString(placeholders)); replayOptions.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); replayOptions.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); + replayOptions.setAsyncReplayRegistryFingerprints(queuedReplay.asyncReplayRegistryFingerprints); + replayOptions.setLegacyAsyncReplayCheckpoint(queuedReplay.legacyAsyncReplayCheckpoint); + replayOptions.setAsyncReplayOccurrenceId(queuedReplay.asyncReplayOccurrenceId); replayOptions.addPlaceholder("date", "" + new SimpleDateFormat("EEE, d MMM yyyy HH:mm").format(new Date(time))); // Keep the due entry durable while asynchronous stages are running. A @@ -518,6 +598,9 @@ private void dispatchOfflineRewards(boolean force) { if (force) options.setGiveOffline(false).forceOffline(); options.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); options.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); + options.setAsyncReplayRegistryFingerprints(queuedReplay.asyncReplayRegistryFingerprints); + options.setLegacyAsyncReplayCheckpoint(queuedReplay.legacyAsyncReplayCheckpoint); + options.setAsyncReplayOccurrenceId(queuedReplay.asyncReplayOccurrenceId); AtomicReference currentEntry = new AtomicReference<>(rewardEntry); options.setAsyncReplayCheckpointConsumer(checkpoint -> checkpointOfflineReward(currentEntry, checkpoint)); @@ -532,7 +615,7 @@ private void dispatchOfflineRewards(boolean force) { private void checkpointOfflineReward(AtomicReference currentEntry, Reward.ReplayCheckpoint checkpoint) { synchronized (plugin) { String current = currentEntry.get(); - String updated = withAsyncReplayProgress(current, checkpoint.getReplayProgress(), checkpoint.getPlaceholders()); + String updated = withAsyncReplayProgress(current, checkpoint); ArrayList pending = getOfflineRewards(); int index = pending.indexOf(current); if (index >= 0) { @@ -616,7 +699,7 @@ private synchronized void completeTimedReward(String rewardEntry, long time) { private synchronized void checkpointTimedReward(AtomicReference currentEntry, long time, Reward.ReplayCheckpoint checkpoint) { String current = currentEntry.get(); - String updated = withAsyncReplayProgress(current, checkpoint.getReplayProgress(), checkpoint.getPlaceholders()); + String updated = withAsyncReplayProgress(current, checkpoint); if (current.equals(updated)) return; HashMap pending = getTimedRewards(); if (!Long.valueOf(time).equals(pending.get(current))) return; 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 e0389a42ab..64ec2ee90a 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 @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -15,6 +16,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicBoolean; @@ -32,6 +34,7 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.rewards.injected.RewardInject; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt; import com.bencodez.advancedcore.api.rewards.builtin.RewardSubRewards; @@ -421,6 +424,193 @@ void replayCheckpointSkipsAlreadyAppliedInjectionAfterLaterFailure() throws Exce assertEquals(1, alreadyApplied.get()); } + @Test + void persistedReplayRejectsAChangedInjectorRegistryBeforeAnyStageRuns() throws Exception { + AtomicInteger applied = new AtomicInteger(); + AtomicInteger inserted = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Applied") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + applied.incrementAndGet(); + return null; + } + }); + handler.getInjectedRewards().add(new RewardInject("Fails") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return CompletableFuture.failedFuture(new IllegalStateException("temporary")); + } + }); + + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertFalse(checkpoint.getReplayRegistryFingerprints().isEmpty()); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage matchingRegistry = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertThrows(java.util.concurrent.CompletionException.class, () -> matchingRegistry.toCompletableFuture().join()); + assertEquals(1, applied.get()); + + handler.getInjectedRewards().add(0, new RewardInject("Inserted") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + inserted.incrementAndGet(); + return null; + } + }); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + assertEquals(1, applied.get()); + assertEquals(0, inserted.get()); + } + + @Test + void legacyCountOnlyReplayCheckpointDoesNotResumeAgainstAnUnknownRegistry() throws Exception { + AtomicInteger invoked = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Applied") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invoked.incrementAndGet(); + return null; + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 1, + state.newInstance(Map.of(), Map.of(), true), "AsyncReward"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + assertEquals(0, invoked.get()); + } + + @Test + void persistedReplayDoesNotFallBackToSynchronousDispatchWhenAsyncInjectorsAreGone() { + AtomicInteger invoked = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Synchronous") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invoked.incrementAndGet(); + return null; + } + }); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayProgress(Map.of("AsyncReward", 1)); + options.setAsyncReplayRegistryFingerprints(Map.of("AsyncReward", "removed-async-injector")); + + reward.giveRewardUser(user, new HashMap<>(), options); + + assertEquals(0, invoked.get()); + } + + @Test + void sharedNestedReplayStatePreventsSynchronousFallbackWhenOptionMapsAreEmpty() throws Exception { + AtomicInteger invoked = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Synchronous") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invoked.incrementAndGet(); + return null; + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayKey("AsyncReward/0/child"); + options.setAsyncReplayState((Reward.ReplayState) state.newInstance(Map.of("AsyncReward/0/child", 1), + Map.of("AsyncReward/0/child", "removed-async-injector"), false)); + assertTrue(options.getAsyncReplayProgress().isEmpty()); + assertTrue(options.getAsyncReplayRegistryFingerprints().isEmpty()); + + reward.giveRewardUser(user, new HashMap<>(), options); + + assertEquals(0, invoked.get()); + } + + @Test + void childCheckpointBindsItsUncompletedParentRegistryBeforeNestedDispatch() throws Exception { + AtomicBoolean dispatchingChild = new AtomicBoolean(); + AtomicInteger parentInvocations = new AtomicInteger(); + AtomicInteger insertedInvocations = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Nested") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward current, AdvancedCoreUser currentUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + if (dispatchingChild.get()) return CompletableFuture.completedFuture(null); + parentInvocations.incrementAndGet(); + dispatchingChild.set(true); + try { + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage child = (CompletionStage) replay.invoke(current, currentUser, new HashMap<>(), 0, + Reward.currentReplayState(), Reward.currentReplayKey() + "/child"); + return child.whenComplete((ignored, failure) -> dispatchingChild.set(false)).thenApply(ignored -> null); + } catch (ReflectiveOperationException failure) { + return CompletableFuture.failedFuture(failure); + } + } + }); + handler.getInjectedRewards().add(new RewardInject("ChildFailure") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return dispatchingChild.get() ? CompletableFuture.failedFuture(new IllegalStateException("temporary")) + : CompletableFuture.completedFuture(null); + } + }); + + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertTrue(checkpoint.getReplayRegistryFingerprints().containsKey("AsyncReward")); + assertTrue(checkpoint.getReplayRegistryFingerprints().containsKey("AsyncReward/0/child")); + + handler.getInjectedRewards().add(0, new RewardInject("Inserted") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + insertedInvocations.incrementAndGet(); + return null; + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + assertEquals(1, parentInvocations.get()); + assertEquals(0, insertedInvocations.get()); + } + @Test void replayProgressDoesNotSuppressASecondSameNamedChildOccurrence() throws Exception { AtomicInteger deliveries = new AtomicInteger(); @@ -459,6 +649,54 @@ void replaySelectionKeepsTheFirstNondeterministicChoice() { assertEquals(1, selections.get()); } + @Test + void independentAsyncRewardOccurrencesReceiveDifferentDurableIds() { + List occurrences = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("Capture") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + occurrences.add(Reward.currentReplayOccurrenceId()); + return CompletableFuture.completedFuture(null); + } + }); + + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + + assertEquals(2, occurrences.size()); + assertNotEquals(occurrences.get(0), occurrences.get(1)); + } + + @Test + void retryOfTheSameOccurrenceRetainsItsId() throws Exception { + List occurrences = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("Capture") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + occurrences.add(Reward.currentReplayOccurrenceId()); + return CompletableFuture.completedFuture(null); + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + for (int attempt = 0; attempt < 2; attempt++) { + ((CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, state.newInstance((Object) null), + "AsyncReward", "occurrence-1")).toCompletableFuture().join(); + } + + assertEquals(List.of("occurrence-1", "occurrence-1"), occurrences); + } + @Test void nestedAsyncInjectorCanAwaitTheSameSharedInjectorWithoutDeadlocking() { AtomicInteger invocations = new AtomicInteger(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 5218386c3f..9534ead464 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -129,6 +129,22 @@ void queuedAsyncReplayResumesAtItsStoredCheckpoint() { assertEquals(2, options.getValue().getCompletedAsyncInjections()); } + @Test + void queuedOccurrenceIsForwardedBeforeAnyAsyncInjectorRuns() { + String occurrence = UUID.randomUUID().toString(); + ArrayList rewards = new ArrayList<>(); + rewards.add("VoteReward%asyncoccurrence%" + occurrence + "%placeholders%Server%pair%server-a"); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkOfflineRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), options.capture()); + assertEquals(occurrence, options.getValue().getAsyncReplayOccurrenceId()); + } + @Test void setOfflineRewards_emptyList() { ArrayList rewards = new ArrayList<>(); @@ -349,6 +365,41 @@ void timedAsyncCheckpointSurvivesRestartBeforeTheInFlightCompletion() throws Exc assertEquals("server-a", resumed.getValue().getPlaceholders().get("Server")); } + @Test + void timedAsyncCheckpointPersistsAndRestoresItsRegistryFingerprint() throws Exception { + long due = System.currentTimeMillis() - 1_000; + ArrayList persisted = new ArrayList<>(); + persisted.add("VoteReward%placeholders%Server%pair%server-a%ExecutionTime/%" + due); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), eq(false)); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(pending); + + ArgumentCaptor firstOptions = ArgumentCaptor.forClass(RewardOptions.class); + user.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), firstOptions.capture()); + firstOptions.getValue().getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("VoteReward", 1), + Map.of("VoteReward", "fingerprint", "VoteReward/0", "parent-fingerprint"), + new HashMap<>(Map.of("Server", "server-a")))); + assertTrue(persisted.get(0).contains("%asyncprogress%v3-")); + + AdvancedCoreUser restarted = new AdvancedCoreUser(plugin, UUID.randomUUID(), "Test"); + restarted.setData(data); + when(rewardHandler.givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.completedFuture(null)); + ArgumentCaptor resumed = ArgumentCaptor.forClass(RewardOptions.class); + restarted.checkDelayedTimedRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), resumed.capture()); + assertEquals("fingerprint", resumed.getValue().getAsyncReplayRegistryFingerprints().get("VoteReward")); + assertEquals("parent-fingerprint", resumed.getValue().getAsyncReplayRegistryFingerprints().get("VoteReward/0")); + } + @Test void timedRetryParsesProgressAfterTheExecutionMarkerWasConsumed() { long due = System.currentTimeMillis() - 1_000; @@ -374,4 +425,12 @@ private Reward.ReplayCheckpoint replayCheckpoint(Map progress, constructor.setAccessible(true); return constructor.newInstance(progress, placeholders); } + + private Reward.ReplayCheckpoint replayCheckpoint(Map progress, Map fingerprints, + HashMap placeholders) throws Exception { + java.lang.reflect.Constructor constructor = Reward.ReplayCheckpoint.class + .getDeclaredConstructor(Map.class, Map.class, HashMap.class); + constructor.setAccessible(true); + return constructor.newInstance(progress, fingerprints, placeholders); + } } From f530282707c5a712a3a518c95ee86240b11ec3ed Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:06:34 -0600 Subject: [PATCH 05/78] Await durable reward command stages --- .../advancedcore/api/misc/MiscUtils.java | 94 +++++++++++++++++-- .../advancedcore/api/rewards/Reward.java | 28 +++++- .../api/rewards/RewardExecutor.java | 4 +- .../api/rewards/builtin/RewardCommands.java | 83 ++++++++++++++++ .../rewards/injected/RewardInjectString.java | 33 ++++++- .../api/user/AdvancedCoreUser.java | 53 +++++++++++ .../tests/misc/MiscUtilsTest.java | 58 ++++++++++++ .../rewards/BuiltinRewardBehaviorTest.java | 71 ++++++++++++++ .../rewards/RewardAsyncInjectionTest.java | 53 +++++++++++ .../tests/rewards/RewardExecutorTest.java | 4 + 10 files changed, 460 insertions(+), 21 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java index f0a8276fa2..89c0a415b2 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java @@ -19,7 +19,12 @@ import java.util.List; import java.util.Map.Entry; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; import org.bukkit.Bukkit; @@ -232,7 +237,18 @@ public void executeConsoleCommands(final String playerName, final ArrayList placeholders) { - if (command != null && !command.isEmpty()) { + executeConsoleCommandsAsync(playerName, command, placeholders); + } + + /** + * Schedules one console command and completes only after Bukkit has dispatched + * it. Async reward replay uses this completion boundary so it never records a + * durable checkpoint ahead of a queued command. + */ + public CompletionStage executeConsoleCommandsAsync(String playerName, String command, + HashMap placeholders) { + if (command == null || command.isEmpty()) return CompletableFuture.completedFuture(null); + try { OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); if (p != null) { command = PlaceholderUtils.replaceJavascriptOnly(p, command); @@ -244,16 +260,40 @@ public void executeConsoleCommands(String playerName, String command, HashMap executeConsoleCommandsAsync(final String playerName, final ArrayList cmds, + final HashMap placeholders, final boolean stagger) { + if (cmds == null || cmds.isEmpty()) return CompletableFuture.completedFuture(null); + try { + 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> completions = new ArrayList<>(); + int delay = 0; + for (String command : commands) { + plugin.debug("Executing console command: " + command); + completions.add(runConsoleCommandAsync(stripLeadingSlash(command), delay++, stagger, true) + .toCompletableFuture()); + } + return CompletableFuture.allOf(completions.toArray(new CompletableFuture[0])); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } } public Object getBlockMeta(Block block, String str) { @@ -479,6 +519,42 @@ public void run() { } } + /** + * Schedules one command with a completion boundary. The timeout claims the + * runnable before it can execute, so a task delayed past shutdown cannot run + * after a failed replay has already been retried. + */ + private CompletionStage runConsoleCommandAsync(String command, int delay, boolean hasDelay, + boolean scheduleAsTask) { + CompletableFuture completion = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); + Runnable dispatch = () -> { + if (!claimed.compareAndSet(false, true)) return; + try { + Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), command); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }; + try { + if (hasDelay && delay > 0) plugin.getBukkitScheduler().runTaskLater(plugin, dispatch, delay); + else if (scheduleAsTask) plugin.getBukkitScheduler().runTask(plugin, dispatch); + else plugin.getBukkitScheduler().executeOrScheduleSync(plugin, dispatch); + } catch (Throwable failure) { + claimed.set(true); + completion.completeExceptionally(failure); + return completion; + } + long timeoutSeconds = 30L + Math.max(0, delay); + CompletableFuture.delayedExecutor(timeoutSeconds, TimeUnit.SECONDS).execute(() -> { + if (claimed.compareAndSet(false, true)) { + completion.completeExceptionally(new TimeoutException("Timed out waiting for scheduled console command")); + } + }); + return completion; + } + private String stripLeadingSlash(String command) { if (command != null && command.startsWith("/")) { return command.substring(1); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java index 473bdaba4b..b2460af6d3 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 @@ -315,13 +315,12 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, replayState.setRegistryFingerprint(replayKey, registryFingerprint); return invokeInjectionAsync(inject, user, placeholders, replayState, injectionKey, occurrenceId); }) - .thenApply(result -> { + .thenCompose(result -> { if (inject.isAddAsPlaceholder() && result != null) addPlaceholder(inject, result, placeholders); int checkpoint = completed.incrementAndGet(); replayState.setCompleted(replayKey, checkpoint); replayState.setRegistryFingerprint(replayKey, registryFingerprint); - replayState.persistCheckpoint(placeholders); - return result; + return replayState.persistCheckpointAsync(plugin, placeholders).thenApply(ignored -> result); }).thenCompose(ignored -> resumeOnServerThread(user)); } return sequence.handle((ignored, failure) -> { @@ -544,10 +543,29 @@ private synchronized int highestCompletedCount() { private synchronized void setCheckpointConsumer(Consumer consumer) { checkpointConsumer = consumer; } - private void persistCheckpoint(HashMap placeholders) { + private CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, + HashMap placeholders) { Consumer consumer; synchronized (this) { consumer = checkpointConsumer; } - if (consumer != null) consumer.accept(new ReplayCheckpoint(copyProgress(), copyRegistryFingerprints(), placeholders)); + if (consumer == null) return CompletableFuture.completedFuture(null); + ReplayCheckpoint checkpoint = new ReplayCheckpoint(copyProgress(), copyRegistryFingerprints(), placeholders); + CompletableFuture persisted = new CompletableFuture<>(); + try { + plugin.getTimer().execute(() -> { + try { + consumer.accept(checkpoint); + persisted.complete(null); + } catch (Throwable failure) { + persisted.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + persisted.completeExceptionally(failure); + } + // shutdownNow() may remove a task that was accepted just before runtime + // teardown. Bound that indeterminate state so the replay fails closed + // instead of waiting forever without advancing its checkpoint. + return persisted.orTimeout(30, TimeUnit.SECONDS); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index faeebd80d1..a772860d1c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -175,8 +175,8 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, String rewar RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null); if (reward.startsWith("/")) { - MiscUtils.getInstance().executeConsoleCommands(user.getPlayerName(), reward, context.getPlaceholders()); - return CompletableFuture.completedFuture(null); + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), reward, + context.getPlaceholders()); } return giveRewardAsync(user, handler.getReward(reward), context.getOptions()); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java index ca8cf3bf0a..df15792ab4 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ThreadLocalRandom; import org.bukkit.Material; @@ -44,6 +46,12 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { public static void registerNumberCommand(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("NumberCommand") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -54,6 +62,19 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat MiscUtils.getInstance().executeConsoleCommands(user.getPlayerName(), command, placeholders); return String.valueOf(number); } + + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + int min = section.getInt("Min", 0); + int max = section.getInt("Max", 100); + String number = Reward.replaySelection(placeholders, + () -> String.valueOf(ThreadLocalRandom.current().nextInt(min, max + 1))); + if (number == null) return CompletableFuture.completedFuture(null); + String command = section.getString("Command", "").replace("%number%", number); + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), command, placeholders) + .thenApply(ignored -> number); + } }.asPlaceholder("Number").priority(100).validator(new RewardInjectValidator() { @Override public void onValidate(Reward reward, RewardInject inject, ConfigurationSection data) { @@ -76,12 +97,25 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public static void registerCommand(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectString("Command") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, String value, HashMap placeholders) { MiscUtils.getInstance().executeConsoleCommands(user.getPlayerName(), value, placeholders); return null; } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, String value, + HashMap placeholders) { + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), value, placeholders) + .thenApply(ignored -> null); + } }.addEditButton(new EditGUIButton(new ItemBuilder("COMMAND_BLOCK"), new EditGUIValueString("Command", null) { @Override public void setValue(Player player, String value) { @@ -103,6 +137,12 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public static void registerCommands(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectStringList("Commands") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -111,6 +151,13 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ArrayList list, HashMap placeholders) { + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), list, placeholders, true) + .thenApply(ignored -> null); + } }.addEditButton(new EditGUIButton(new ItemBuilder("COMMAND_BLOCK"), new EditGUIValueList("Commands", null) { @Override public void setValue(Player player, ArrayList value) { @@ -137,6 +184,12 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection })); handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Commands") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + @SuppressWarnings("unchecked") @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, @@ -152,6 +205,20 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } + + @SuppressWarnings("unchecked") + @Override + public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection section, HashMap placeholders) { + ArrayList consoleCommands = (ArrayList) section.getList("Console", new ArrayList<>()); + ArrayList userCommands = (ArrayList) section.getList("Player", new ArrayList<>()); + CompletionStage console = consoleCommands.isEmpty() ? CompletableFuture.completedFuture(null) + : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, + placeholders, section.getBoolean("Stagger", true)); + CompletionStage player = user.preformCommandAsync(userCommands, placeholders); + return CompletableFuture.allOf(console.toCompletableFuture(), player.toCompletableFuture()) + .thenApply(ignored -> null); + } }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("Commands.Console", null) { @Override public void setValue(Player player, ArrayList value) { @@ -174,6 +241,12 @@ public void setValue(Player player, ArrayList value) { public static void registerRandomCommand(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectStringList("RandomCommand") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -183,6 +256,16 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ArrayList list, HashMap placeholders) { + if (list.isEmpty()) return CompletableFuture.completedFuture(null); + String command = Reward.replaySelection(placeholders, + () -> list.get(ThreadLocalRandom.current().nextInt(list.size()))); + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), command, placeholders) + .thenApply(ignored -> null); + } }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("RandomCommand", null) { @Override public void setValue(Player player, ArrayList value) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java index e43c7d1f08..e50324e67a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.HashMap; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -43,7 +45,28 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio return null; } - public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, String value, - HashMap placeholders); - -} + public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, String value, + HashMap placeholders); + + /** Completion-aware counterpart for string injections. */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, String value, + HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, value, placeholders)); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!((data.isString(getPath()) && !data.getString(getPath(), "").isEmpty()) + || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData())) { + return CompletableFuture.completedFuture(null); + } + String value = data.getString(getPath(), getDefaultValue()); + return onRewardRequestAsync(reward, user, value, placeholders).thenApply(result -> result); + } + +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index cd9255d0c4..e5c0730011 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 @@ -15,7 +15,11 @@ import java.util.Map.Entry; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.TimeoutException; import org.bukkit.Bukkit; import org.bukkit.Effect; @@ -1568,6 +1572,55 @@ public void run() { } } + /** + * Completion-aware player-command dispatch used by durable asynchronous reward + * replay. It completes only after each queued player command has run. + */ + public CompletionStage preformCommandAsync(ArrayList commands, HashMap placeholders) { + if (commands == null || commands.isEmpty()) return CompletableFuture.completedFuture(null); + try { + final ArrayList cmds = PlaceholderUtils.replaceJavascript(getPlayer(), + PlaceholderUtils.replacePlaceHolder(commands, placeholders)); + final Player player = getPlayer(); + if (player == null || !plugin.isEnabled()) return CompletableFuture.completedFuture(null); + ArrayList> completions = new ArrayList<>(); + for (String command : cmds) { + plugin.debug("Executing player command for " + getPlayerName() + ": " + command); + completions.add(runPlayerCommandAsync(player, command)); + } + return CompletableFuture.allOf(completions.toArray(new CompletableFuture[0])); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + private CompletableFuture runPlayerCommandAsync(Player player, String command) { + CompletableFuture completion = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); + Runnable dispatch = () -> { + if (!claimed.compareAndSet(false, true)) return; + try { + player.chat("/" + command); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }; + try { + getPlugin().getBukkitScheduler().runTask(plugin, dispatch); + } catch (Throwable failure) { + claimed.set(true); + completion.completeExceptionally(failure); + return completion; + } + CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { + if (claimed.compareAndSet(false, true)) { + completion.completeExceptionally(new TimeoutException("Timed out waiting for scheduled player command")); + } + }); + return completion; + } + /** * Performs a command as the player with placeholders. * diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java index 306995e729..1c548e0006 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java @@ -1,10 +1,14 @@ package com.bencodez.advancedcore.tests.misc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -83,4 +87,58 @@ public void consoleCommandsStripLeadingSlashBeforeDispatch() { verify(server).dispatchCommand(console, "say hi"); } } + + @Test + public void asyncConsoleCommandCompletesOnlyAfterItsScheduledDispatch() { + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.getOfflinePlayer("Ben")).thenReturn(null); + + java.util.concurrent.CompletionStage completion = miscUtils.executeConsoleCommandsAsync("Ben", "/say hi", + new HashMap<>()); + verify(scheduler).executeOrScheduleSync(eq(plugin), task.capture()); + assertFalse(completion.toCompletableFuture().isDone()); + + task.getValue().run(); + completion.toCompletableFuture().join(); + assertTrue(completion.toCompletableFuture().isDone()); + verify(server).dispatchCommand(console, "say hi"); + } + } + + @Test + public void asyncStaggeredCommandsCompleteOnlyAfterEveryScheduledDispatch() { + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + ArgumentCaptor immediate = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor delayed = ArgumentCaptor.forClass(Runnable.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.getOfflinePlayer("Ben")).thenReturn(null); + + java.util.concurrent.CompletionStage completion = miscUtils.executeConsoleCommandsAsync("Ben", + new ArrayList<>(List.of("say one", "say two", "say three")), new HashMap<>(), true); + verify(scheduler).runTask(eq(plugin), immediate.capture()); + verify(scheduler, times(2)).runTaskLater(eq(plugin), delayed.capture(), anyLong()); + assertFalse(completion.toCompletableFuture().isDone()); + + immediate.getValue().run(); + assertFalse(completion.toCompletableFuture().isDone()); + delayed.getAllValues().get(0).run(); + assertFalse(completion.toCompletableFuture().isDone()); + delayed.getAllValues().get(1).run(); + completion.toCompletableFuture().join(); + + verify(server).dispatchCommand(console, "say one"); + verify(server).dispatchCommand(console, "say two"); + verify(server).dispatchCommand(console, "say three"); + } + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index c42ea695b6..2f731cae72 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -1,6 +1,7 @@ package com.bencodez.advancedcore.tests.rewards; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -19,6 +20,8 @@ import java.util.HashMap; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.function.BiConsumer; import org.bukkit.Location; @@ -298,6 +301,74 @@ public void commandsActuallyDispatchEveryCommandForm() { } } + @Test + public void asyncCommandsWaitForTheirDispatchStages() { + RewardCommands.register(handler, plugin); + MiscUtils misc = mock(MiscUtils.class); + CompletableFuture numberCommand = new CompletableFuture<>(); + CompletableFuture command = new CompletableFuture<>(); + CompletableFuture commandList = new CompletableFuture<>(); + CompletableFuture sectionConsole = new CompletableFuture<>(); + CompletableFuture sectionPlayer = new CompletableFuture<>(); + CompletableFuture random = new CompletableFuture<>(); + try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { + miscStatic.when(MiscUtils::getInstance).thenReturn(misc); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("give Ben stone 5"), eq(placeholders))) + .thenReturn(numberCommand); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("say hi"), eq(placeholders))).thenReturn(command); + ArrayList list = new ArrayList<>(List.of("say one", "say two")); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq(list), eq(placeholders), eq(true))) + .thenReturn(commandList); + ArrayList console = new ArrayList<>(List.of("say console")); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq(console), eq(placeholders), eq(false))) + .thenReturn(sectionConsole); + ArrayList player = new ArrayList<>(List.of("spawn")); + when(user.preformCommandAsync(eq(player), eq(placeholders))).thenReturn(sectionPlayer); + ArrayList randomList = new ArrayList<>(List.of("say random")); + HashMap randomPlaceholders = new HashMap<>(); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("say random"), eq(randomPlaceholders))).thenReturn(random); + + ConfigurationSection number = section("NumberCommand"); + number.set("Min", 5); + number.set("Max", 5); + number.set("Command", "give Ben stone %number%"); + CompletionStage numberStage = ((RewardInjectConfigurationSection) injects.get(0)) + .onRewardRequestedAsync(reward, user, number, placeholders); + assertFalse(numberStage.toCompletableFuture().isDone()); + numberCommand.complete(null); + assertEquals("5", numberStage.toCompletableFuture().join()); + + CompletionStage commandStage = ((RewardInjectString) injects.get(1)) + .onRewardRequestAsync(reward, user, "say hi", placeholders); + assertFalse(commandStage.toCompletableFuture().isDone()); + command.complete(null); + commandStage.toCompletableFuture().join(); + + CompletionStage listStage = ((RewardInjectStringList) injects.get(2)) + .onRewardRequestAsync(reward, user, list, placeholders); + assertFalse(listStage.toCompletableFuture().isDone()); + commandList.complete(null); + listStage.toCompletableFuture().join(); + + ConfigurationSection commands = section("Commands"); + commands.set("Console", console); + commands.set("Player", player); + commands.set("Stagger", false); + CompletionStage sectionStage = ((RewardInjectConfigurationSection) injects.get(3)) + .onRewardRequestedAsync(reward, user, commands, placeholders); + sectionConsole.complete(null); + assertFalse(sectionStage.toCompletableFuture().isDone()); + sectionPlayer.complete(null); + sectionStage.toCompletableFuture().join(); + + CompletionStage randomStage = ((RewardInjectStringList) injects.get(4)) + .onRewardRequestAsync(reward, user, randomList, randomPlaceholders); + assertFalse(randomStage.toCompletableFuture().isDone()); + random.complete(null); + randomStage.toCompletableFuture().join(); + } + } + @Test public void messagesActuallySendPlayerAndBroadcastForms() { RewardMessages.register(handler, plugin); 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 64ec2ee90a..86c41b6665 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 @@ -10,6 +10,8 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.File; @@ -19,6 +21,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -30,6 +33,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.rewards.Reward; @@ -160,6 +164,55 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, Conf assertEquals("second-value", placeholders.get("second")); } + @Test + void checkpointWriteRunsOffServerThreadAndGatesTheNextInjection() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + List events = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("First") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("first"); + return null; + } + }); + handler.getInjectedRewards().add(new RewardInject("Second") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("second"); + return null; + } + }); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(null, null, false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, (java.util.function.Consumer) checkpoint -> + events.add("checkpoint:" + checkpoint.getReplayProgress().get("AsyncReward"))); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + assertEquals(List.of("first"), events); + assertFalse(result.toCompletableFuture().isDone()); + + writes.getValue().run(); + verify(storageExecutor, times(2)).execute(writes.capture()); + assertEquals(List.of("first", "checkpoint:1", "second"), events); + writes.getAllValues().get(writes.getAllValues().size() - 1).run(); + result.toCompletableFuture().join(); + assertEquals(List.of("first", "checkpoint:1", "second", "checkpoint:2"), events); + } + @Test void typedIntegerAsyncHookUsesParsedValue() { data.set("Amount", 7); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 8dafc8ece8..b132b62ba0 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -216,13 +216,17 @@ public void inlineSectionConstructsChecksAndExecutesReward() { public void commandStyleRewardExecutesConsoleCommand() { MiscUtils misc = mock(MiscUtils.class); RewardOptions options = new RewardOptions().addPlaceholder("player", "Ben"); + when(misc.executeConsoleCommandsAsync("Ben", "/say hi", options.getPlaceholders())) + .thenReturn(CompletableFuture.completedFuture(null)); try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { miscStatic.when(MiscUtils::getInstance).thenReturn(misc); executor.giveReward(user, "/say hi", options); + executor.giveRewardAsync(user, "/say hi", options).toCompletableFuture().join(); } verify(misc).executeConsoleCommands("Ben", "/say hi", options.getPlaceholders()); + verify(misc).executeConsoleCommandsAsync("Ben", "/say hi", options.getPlaceholders()); } @Test From c43dab411520afa1eb878acae8b5586f0ba80278 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:22:23 -0600 Subject: [PATCH 06/78] Fail closed when async rewards cannot dispatch --- .../api/rewards/RewardExecutor.java | 13 +++++++++-- .../rewards/injected/RewardInjectString.java | 7 +++++- .../rewards/RewardAsyncInjectionTest.java | 18 +++++++++++++++ .../tests/rewards/RewardExecutorTest.java | 22 +++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index a772860d1c..6af01b4d83 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -111,7 +111,8 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); RewardOptions options = context.getOptions(); - if (path == null || data == null || !plugin.isEnabled()) return CompletableFuture.completedFuture(null); + if (path == null || data == null) return CompletableFuture.completedFuture(null); + if (!plugin.isEnabled()) return disabledDispatch(); if (data.isList(path)) { CompletionStage sequence = CompletableFuture.completedFuture(null); @@ -152,7 +153,8 @@ public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewar } public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { - if (reward == null || !plugin.isEnabled()) return CompletableFuture.completedFuture(null); + if (reward == null) return CompletableFuture.completedFuture(null); + if (!plugin.isEnabled()) return disabledDispatch(); RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); return reward.giveRewardAsync(user, context.getOptions()); } @@ -174,6 +176,7 @@ public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewar public CompletionStage giveRewardAsync(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null); + if (!plugin.isEnabled()) return disabledDispatch(); if (reward.startsWith("/")) { return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), reward, context.getPlaceholders()); @@ -233,6 +236,7 @@ public CompletionStage givePersistedQueueRewardAsync(AdvancedCoreUser user RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null); + if (!plugin.isEnabled()) return disabledDispatch(); String rewardName = reward; Boolean generatedSnapshot = null; @@ -265,6 +269,11 @@ public CompletionStage givePersistedQueueRewardAsync(AdvancedCoreUser user return giveRewardAsync(user, resolved, context.getOptions()); } + private CompletionStage disabledDispatch() { + return CompletableFuture.failedFuture( + new IllegalStateException("Plugin disabled before asynchronous reward dispatch")); + } + public void updateReward(Configuration data, String path, RewardOptions rewardOptions) { if (data == null || path == null || !data.isConfigurationSection(path)) { return; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java index e50324e67a..ab5eb80f1c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java @@ -66,7 +66,12 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU return CompletableFuture.completedFuture(null); } String value = data.getString(getPath(), getDefaultValue()); - return onRewardRequestAsync(reward, user, value, placeholders).thenApply(result -> result); + CompletionStage result = onRewardRequestAsync(reward, user, value, placeholders); + if (result == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null asynchronous result: " + getPath())); + } + return result.thenApply(valueResult -> (Object) (valueResult == null ? value : valueResult)); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index 86c41b6665..1bdae2f188 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 @@ -41,6 +41,7 @@ import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.rewards.injected.RewardInject; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt; +import com.bencodez.advancedcore.api.rewards.injected.RewardInjectString; import com.bencodez.advancedcore.api.rewards.builtin.RewardSubRewards; import com.bencodez.advancedcore.api.rewards.builtin.RewardRandomReward; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; @@ -246,6 +247,23 @@ public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCore assertEquals("async-7", placeholders.get("amount")); } + @Test + void typedStringAsyncHookPreservesConfiguredValueWhenCallbackReturnsNull() { + data.set("Message", "configured-value"); + HashMap placeholders = new HashMap<>(); + RewardInjectString message = new RewardInjectString("Message") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public String onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, String value, + HashMap ignoredPlaceholders) { return null; } + }; + message.asPlaceholder("message"); + handler.getInjectedRewards().add(message); + + reward.giveInjectedRewardsAsync(user, placeholders).toCompletableFuture().join(); + + assertEquals("configured-value", placeholders.get("message")); + } + @Test void failedAsyncInjectionStopsDependentRewardsAndPropagates() { AtomicBoolean dependentRan = new AtomicBoolean(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index b132b62ba0..9ed41d3fb4 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -261,6 +262,27 @@ public void asyncRewardExecutionRunsImmediately() { verify(reward).giveReward(user, options); } + @Test + public void disabledPluginFailsEveryConfiguredAsyncDispatchWithoutExecuting() { + Reward reward = mock(Reward.class); + RewardOptions options = new RewardOptions(); + YamlConfiguration data = new YamlConfiguration(); + data.set("Reward", "Daily"); + when(handler.getReward("Daily")).thenReturn(reward); + when(plugin.isEnabled()).thenReturn(false); + + assertThrows(java.util.concurrent.CompletionException.class, + () -> executor.giveRewardAsync(user, reward, options).toCompletableFuture().join()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> executor.giveRewardAsync(user, "Daily", options).toCompletableFuture().join()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Reward", options).toCompletableFuture().join()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> executor.givePersistedQueueRewardAsync(user, "Daily", options).toCompletableFuture().join()); + + verify(reward, never()).giveRewardAsync(eq(user), any(RewardOptions.class)); + } + @Test public void choicesDispatchThroughRewardBuilder() { Reward mainReward = mock(Reward.class); From a83cae5baaa4a14131c229a580f72ba28c1427cd Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:47:01 -0600 Subject: [PATCH 07/78] Make asynchronous reward replay durable --- .../builtin/RewardAdvancedPriority.java | 8 +- .../builtin/RewardAdvancedRandomReward.java | 7 +- .../api/rewards/builtin/RewardCommands.java | 10 +- .../api/rewards/builtin/RewardPriority.java | 8 +- .../api/rewards/builtin/RewardRandom.java | 13 +- .../rewards/builtin/RewardRandomReward.java | 5 +- .../api/user/AdvancedCoreUser.java | 133 ++++++++++++------ .../rewards/BuiltinRewardBehaviorTest.java | 26 ++++ .../rewards/RewardAsyncInjectionTest.java | 6 +- .../tests/user/AdvancedCoreUserTest.java | 53 +++++++ 10 files changed, 208 insertions(+), 61 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java index b1569b4179..40ff9a985c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java @@ -75,10 +75,12 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan if (selectedKey == null) return CompletableFuture.completedFuture(null); Reward selected = handler.getReward(section, selectedKey, new RewardOptions() .setPrefix(sourceReward.getName() + "_AdvancedPriority")); + RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setIgnoreChance(true) + .setIgnoreRequirements(true).setPrefix(sourceReward.getName() + "_AdvancedPriority") + .withPlaceHolder(placeholders), Reward.currentReplayState(), Reward.currentReplayKey(), + "selected:" + selectedKey, Reward.currentReplayOccurrenceId()); return selected == null ? CompletableFuture.completedFuture(null) - : handler.giveRewardAsync(user, selected, new RewardOptions().setIgnoreChance(true) - .setIgnoreRequirements(true).setPrefix(sourceReward.getName() + "_AdvancedPriority") - .withPlaceHolder(placeholders)).thenApply(ignored -> selected.getName()); + : handler.giveRewardAsync(user, selected, childOptions).thenApply(ignored -> selected.getName()); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java index fc3ce1af7d..acaf145bb6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java @@ -63,8 +63,11 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor if (rewards.isEmpty()) return CompletableFuture.completedFuture(null); String selected = Reward.replaySelection(placeholders, () -> rewards.get(ThreadLocalRandom.current().nextInt(rewards.size()))); - return handler.giveRewardAsync(user, section, selected, new RewardOptions().setPlaceholders(placeholders) - .setPrefix(reward.getRewardName() + "_AdvancedRandomReward")).thenApply(ignored -> selected); + RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), + Reward.currentReplayState(), Reward.currentReplayKey(), "selected:" + selected, + Reward.currentReplayOccurrenceId()) + .setPrefix(reward.getRewardName() + "_AdvancedRandomReward"); + return handler.giveRewardAsync(user, section, selected, childOptions).thenApply(ignored -> selected); } @Override 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 df15792ab4..f99f5baa49 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 @@ -212,11 +212,13 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor ConfigurationSection section, HashMap placeholders) { ArrayList consoleCommands = (ArrayList) section.getList("Console", new ArrayList<>()); ArrayList userCommands = (ArrayList) section.getList("Player", new ArrayList<>()); - CompletionStage console = consoleCommands.isEmpty() ? CompletableFuture.completedFuture(null) - : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, - placeholders, section.getBoolean("Stagger", true)); CompletionStage player = user.preformCommandAsync(userCommands, placeholders); - return CompletableFuture.allOf(console.toCompletableFuture(), player.toCompletableFuture()) + // Player availability is a prerequisite for the mixed section. Do not + // schedule console side effects until that prerequisite has completed, + // otherwise a disconnect can fail the replay after grants already ran. + return player.thenCompose(ignored -> consoleCommands.isEmpty() ? CompletableFuture.completedFuture(null) + : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, + placeholders, section.getBoolean("Stagger", true))) .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/RewardPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java index a9543b1353..5d31d1d14e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java @@ -65,9 +65,13 @@ public CompletionStage onRewardRequestAsync(Reward source, AdvancedCoreU }); if (selectedName == null) return CompletableFuture.completedFuture(null); Reward selected = handler.getReward(selectedName); + RewardOptions childOptions = Reward.withReplayState( + new RewardOptions().withPlaceHolder(placeholders).setIgnoreChance(true) + .setIgnoreRequirements(true), + Reward.currentReplayState(), Reward.currentReplayKey(), "selected:" + selectedName, + Reward.currentReplayOccurrenceId()); return selected == null ? CompletableFuture.completedFuture(null) - : handler.giveRewardAsync(user, selected, new RewardOptions().withPlaceHolder(placeholders) - .setIgnoreChance(true).setIgnoreRequirements(true)).thenApply(ignored -> selected.getName()); + : handler.giveRewardAsync(user, selected, childOptions).thenApply(ignored -> selected.getName()); } }.asPlaceholder("Priority").addEditButton( new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("Priority", null) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java index ed98e100db..739701eb78 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java @@ -73,13 +73,18 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor if (selection == null || selection.equals("none")) return CompletableFuture.completedFuture(null); if (selection.startsWith("pick:")) { String selected = selection.substring("pick:".length()); + RewardOptions childOptions = Reward.withReplayState( + new RewardOptions().setPlaceholders(placeholders), Reward.currentReplayState(), + Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()); return selected.isEmpty() ? CompletableFuture.completedFuture(null) - : handler.giveRewardAsync(user, selected, - new RewardOptions().setPlaceholders(placeholders)).thenApply(ignored -> null); + : handler.giveRewardAsync(user, selected, childOptions).thenApply(ignored -> null); } String path = selection.equals("rewards") ? "Random.Rewards" : "Random.FallBack"; - return new RewardBuilder(reward.getConfig().getConfigData(), path).withPrefix(reward.getName()) - .withPlaceHolder(placeholders).sendAsync(user).thenApply(ignored -> null); + RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) + .withPrefix(reward.getName()).withPlaceHolder(placeholders); + Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), + Reward.currentReplayKey(), "path:" + path, Reward.currentReplayOccurrenceId()); + return builder.sendAsync(user).thenApply(ignored -> null); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java index f2f39bf124..803880a5cb 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java @@ -52,7 +52,10 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU if (list.isEmpty()) return java.util.concurrent.CompletableFuture.completedFuture(null); String selected = Reward.replaySelection(placeholders, () -> list.get(ThreadLocalRandom.current().nextInt(list.size()))); - return handler.giveRewardAsync(user, selected, new RewardOptions().setPlaceholders(placeholders)) + RewardOptions childOptions = Reward.withReplayState( + new RewardOptions().setPlaceholders(placeholders), Reward.currentReplayState(), + Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()); + return handler.giveRewardAsync(user, selected, childOptions) .thenApply(ignored -> selected); } }.asPlaceholder("RandomReward").priority(20).addEditButton( 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 e5c0730011..528c3f5e35 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 @@ -14,6 +14,7 @@ import java.util.Map; import java.util.Map.Entry; import java.util.UUID; +import java.util.WeakHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -66,8 +67,13 @@ 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 final HashMap offlineReplayInFlight = new HashMap<>(); - private final HashSet timedReplayInFlight = new HashSet<>(); + private static final Object REPLAY_CLAIMS_LOCK = new Object(); + private static final WeakHashMap> REPLAY_CLAIMS = new WeakHashMap<>(); + + private static final class ReplayClaims { + private final HashMap offline = new HashMap<>(); + private final HashSet timed = new HashSet<>(); + } /** * User data fetch mode for this user. @@ -618,19 +624,20 @@ private void dispatchOfflineRewards(boolean force) { private void checkpointOfflineReward(AtomicReference currentEntry, Reward.ReplayCheckpoint checkpoint) { synchronized (plugin) { + ReplayClaims claims = replayClaims(); String current = currentEntry.get(); String updated = withAsyncReplayProgress(current, checkpoint); ArrayList pending = getOfflineRewards(); int index = pending.indexOf(current); if (index >= 0) { pending.set(index, updated); - int claimed = offlineReplayInFlight.getOrDefault(current, 0); + int claimed = claims.offline.getOrDefault(current, 0); // Migrate this occurrence only. Identical legacy queue entries share a // serialized key, but may each already be executing; moving the whole // count would make the remaining old entry appear unclaimed. - if (claimed <= 1) offlineReplayInFlight.remove(current); - else offlineReplayInFlight.put(current, claimed - 1); - offlineReplayInFlight.put(updated, offlineReplayInFlight.getOrDefault(updated, 0) + 1); + if (claimed <= 1) claims.offline.remove(current); + else claims.offline.put(current, claimed - 1); + claims.offline.put(updated, claims.offline.getOrDefault(updated, 0) + 1); currentEntry.set(updated); setOfflineRewardsDurably(pending); } @@ -639,11 +646,12 @@ private void checkpointOfflineReward(AtomicReference currentEntry, Rewar private boolean claimOfflineReward(String rewardEntry) { synchronized (plugin) { + ReplayClaims claims = replayClaims(); int occurrences = 0; for (String pending : getOfflineRewards()) if (rewardEntry.equals(pending)) occurrences++; - int claimed = offlineReplayInFlight.getOrDefault(rewardEntry, 0); + int claimed = claims.offline.getOrDefault(rewardEntry, 0); if (claimed >= occurrences) return false; - offlineReplayInFlight.put(rewardEntry, claimed + 1); + claims.offline.put(rewardEntry, claimed + 1); return true; } } @@ -673,26 +681,35 @@ private void restoreOfflineReward(String rewardEntry, Throwable failure) { } private void releaseOfflineReward(String rewardEntry) { - int claimed = offlineReplayInFlight.getOrDefault(rewardEntry, 0); - if (claimed <= 1) offlineReplayInFlight.remove(rewardEntry); - else offlineReplayInFlight.put(rewardEntry, claimed - 1); + ReplayClaims claims = replayClaims(); + int claimed = claims.offline.getOrDefault(rewardEntry, 0); + if (claimed <= 1) claims.offline.remove(rewardEntry); + else claims.offline.put(rewardEntry, claimed - 1); + releaseReplayClaimsIfEmpty(claims); } - private synchronized boolean claimTimedReward(String rewardEntry, long time) { - if (timedReplayInFlight.contains(rewardEntry) || !Long.valueOf(time).equals(getTimedRewards().get(rewardEntry))) { - return false; + private boolean claimTimedReward(String rewardEntry, long time) { + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + if (claims.timed.contains(rewardEntry) || !Long.valueOf(time).equals(getTimedRewards().get(rewardEntry))) { + return false; + } + claims.timed.add(rewardEntry); + return true; } - timedReplayInFlight.add(rewardEntry); - return true; } - private synchronized void completeTimedReward(String rewardEntry, long time) { - HashMap pending = getTimedRewards(); - if (Long.valueOf(time).equals(pending.get(rewardEntry))) { - pending.remove(rewardEntry); - setTimedRewards(pending); + private void completeTimedReward(String rewardEntry, long time) { + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + HashMap pending = getTimedRewards(); + if (Long.valueOf(time).equals(pending.get(rewardEntry))) { + pending.remove(rewardEntry); + setTimedRewards(pending); + } + claims.timed.remove(rewardEntry); + releaseReplayClaimsIfEmpty(claims); } - timedReplayInFlight.remove(rewardEntry); } /** @@ -700,31 +717,39 @@ private synchronized void completeTimedReward(String rewardEntry, long time) { * Timed reward keys are map keys, so migrate the exact current key while * retaining its execution time and its in-flight claim. */ - private synchronized void checkpointTimedReward(AtomicReference currentEntry, long time, + private void checkpointTimedReward(AtomicReference currentEntry, long time, Reward.ReplayCheckpoint checkpoint) { - String current = currentEntry.get(); - String updated = withAsyncReplayProgress(current, checkpoint); - if (current.equals(updated)) return; - HashMap pending = getTimedRewards(); - if (!Long.valueOf(time).equals(pending.get(current))) return; - pending.remove(current); - pending.put(updated, time); - setTimedRewardsDurably(pending); - timedReplayInFlight.remove(current); - timedReplayInFlight.add(updated); - currentEntry.set(updated); + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + String current = currentEntry.get(); + String updated = withAsyncReplayProgress(current, checkpoint); + if (current.equals(updated)) return; + HashMap pending = getTimedRewards(); + if (!Long.valueOf(time).equals(pending.get(current))) return; + pending.remove(current); + pending.put(updated, time); + setTimedRewardsDurably(pending); + claims.timed.remove(current); + claims.timed.add(updated); + currentEntry.set(updated); + } } /** Restores a due timed entry after an asynchronous replay fails. */ - private synchronized void restoreTimedReward(String rewardEntry, long time, Throwable failure) { - HashMap pending = getTimedRewards(); - int retry = Math.min(8, asyncRetryCount(rewardEntry) + 1); - long retryDelay = Math.min(TimeUnit.MINUTES.toMillis(5), TimeUnit.SECONDS.toMillis(1L << retry)); - long retryTime = System.currentTimeMillis() + retryDelay; - pending.remove(rewardEntry); - pending.put(withAsyncRetryCount(withAsyncReplayProgress(rewardEntry, failure), retry), retryTime); - setTimedRewards(pending); - timedReplayInFlight.remove(rewardEntry); + private void restoreTimedReward(String rewardEntry, long time, Throwable failure) { + long retryTime; + synchronized (plugin) { + ReplayClaims claims = replayClaims(); + HashMap pending = getTimedRewards(); + int retry = Math.min(8, asyncRetryCount(rewardEntry) + 1); + long retryDelay = Math.min(TimeUnit.MINUTES.toMillis(5), TimeUnit.SECONDS.toMillis(1L << retry)); + retryTime = System.currentTimeMillis() + retryDelay; + pending.remove(rewardEntry); + pending.put(withAsyncRetryCount(withAsyncReplayProgress(rewardEntry, failure), retry), retryTime); + setTimedRewards(pending); + claims.timed.remove(rewardEntry); + 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); @@ -732,6 +757,23 @@ private synchronized void restoreTimedReward(String rewardEntry, long time, Thro + "; it will be retried: " + failure.getMessage()); } + private ReplayClaims replayClaims() { + synchronized (REPLAY_CLAIMS_LOCK) { + return REPLAY_CLAIMS.computeIfAbsent(plugin, ignored -> new HashMap<>()) + .computeIfAbsent(getUUID(), ignored -> new ReplayClaims()); + } + } + + private void releaseReplayClaimsIfEmpty(ReplayClaims claims) { + if (!claims.offline.isEmpty() || !claims.timed.isEmpty()) return; + synchronized (REPLAY_CLAIMS_LOCK) { + HashMap byUser = REPLAY_CLAIMS.get(plugin); + if (byUser == null || byUser.get(getUUID()) != claims) return; + byUser.remove(getUUID()); + if (byUser.isEmpty()) REPLAY_CLAIMS.remove(plugin); + } + } + /** * Clears the user cache. */ @@ -1582,7 +1624,10 @@ public CompletionStage preformCommandAsync(ArrayList commands, Has final ArrayList cmds = PlaceholderUtils.replaceJavascript(getPlayer(), PlaceholderUtils.replacePlaceHolder(commands, placeholders)); final Player player = getPlayer(); - if (player == null || !plugin.isEnabled()) return CompletableFuture.completedFuture(null); + if (player == null || !plugin.isEnabled()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Player command could not run because the player or plugin is unavailable")); + } ArrayList> completions = new ArrayList<>(); for (String command : cmds) { plugin.debug("Executing player command for " + getPlayerName() + ": " + command); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 2f731cae72..65f7a0fe73 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -3,9 +3,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; @@ -369,6 +372,29 @@ public void asyncCommandsWaitForTheirDispatchStages() { } } + @Test + public void mixedAsyncCommandsDoNotScheduleConsoleWorkWhenPlayerDispatchFails() { + RewardCommands.register(handler, plugin); + MiscUtils misc = mock(MiscUtils.class); + ArrayList console = new ArrayList<>(List.of("give Ben diamond")); + ArrayList player = new ArrayList<>(List.of("spawn")); + when(user.preformCommandAsync(eq(player), eq(placeholders))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("player unavailable"))); + ConfigurationSection commands = section("Commands"); + commands.set("Console", console); + commands.set("Player", player); + + try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { + miscStatic.when(MiscUtils::getInstance).thenReturn(misc); + + CompletionStage result = ((RewardInjectConfigurationSection) injects.get(3)) + .onRewardRequestedAsync(reward, user, commands, placeholders); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + verify(misc, never()).executeConsoleCommandsAsync(anyString(), any(), any(), anyBoolean()); + } + } + @Test public void messagesActuallySendPlayerAndBroadcastForms() { RewardMessages.register(handler, plugin); 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 1bdae2f188..443f23dcb2 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 @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -437,7 +438,8 @@ void everyNestedRewardInjectorWaitsForItsSelectedChild() { when(user.getPlugin()).thenReturn(plugin); data.set("RandomReward", new ArrayList<>(List.of("child"))); CompletableFuture child = new CompletableFuture<>(); - doReturn(child).when(handler).giveRewardAsync(eq(user), eq("child"), any()); + ArgumentCaptor childOptions = ArgumentCaptor.forClass(RewardOptions.class); + doReturn(child).when(handler).giveRewardAsync(eq(user), eq("child"), childOptions.capture()); List events = new ArrayList<>(); RewardRandomReward.register(handler, plugin); handler.getInjectedRewards().add(new RewardInject("After") { @@ -451,6 +453,8 @@ void everyNestedRewardInjectorWaitsForItsSelectedChild() { CompletionStage result = reward.giveInjectedRewardsAsync(user, new HashMap<>()); assertFalse(result.toCompletableFuture().isDone()); assertTrue(events.isEmpty()); + assertNotNull(childOptions.getValue().getAsyncReplayState()); + assertTrue(childOptions.getValue().getAsyncReplayKey().endsWith("/selected:child")); child.complete(null); result.toCompletableFuture().join(); assertEquals(List.of("after-child"), events); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 9534ead464..5f9f0fa7be 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; @@ -12,15 +13,18 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Map; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; +import org.bukkit.Bukkit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.AdvancedCorePlugin; @@ -248,6 +252,23 @@ void pendingAsyncOfflineReplayRemainsDurableAndIsNotDispatchedTwice() { verify(data).setStringList(eq("offlineRewardsPath"), any()); } + @Test + void pendingOfflineReplayClaimIsSharedAcrossWrappersForTheSameUuid() { + ArrayList rewards = new ArrayList<>(List.of("VoteReward")); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenReturn(rewards); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(any(AdvancedCoreUser.class), + any(PersistedQueueReference.class), any(RewardOptions.class))).thenReturn(pending); + AdvancedCoreUser second = new AdvancedCoreUser(plugin, UUID.fromString(user.getUUID()), "Test"); + second.setData(data); + + user.checkOfflineRewards(); + second.checkOfflineRewards(); + + verify(rewardHandler, org.mockito.Mockito.times(1)).givePersistedQueueRewardAsync( + any(AdvancedCoreUser.class), any(PersistedQueueReference.class), any(RewardOptions.class)); + } + @Test void offlineCheckpointMigratesOnlyItsOwnDuplicateInFlightClaim() throws Exception { String entry = "VoteReward%placeholders%Server%pair%server-a"; @@ -332,6 +353,38 @@ void timedAsyncCheckpointIsDurableAndMigratesTheExactEntryWhileInFlight() throws any(PersistedQueueReference.class), any(RewardOptions.class)); } + @Test + void pendingTimedReplayClaimIsSharedAcrossWrappersForTheSameUuid() { + long due = System.currentTimeMillis() - 1_000; + ArrayList timed = new ArrayList<>(List.of("VoteReward%ExecutionTime/%" + due)); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)).thenReturn(timed); + CompletableFuture pending = new CompletableFuture<>(); + when(rewardHandler.givePersistedQueueRewardAsync(any(AdvancedCoreUser.class), + any(PersistedQueueReference.class), any(RewardOptions.class))).thenReturn(pending); + AdvancedCoreUser second = new AdvancedCoreUser(plugin, UUID.fromString(user.getUUID()), "Test"); + second.setData(data); + + user.checkDelayedTimedRewards(); + second.checkDelayedTimedRewards(); + + verify(rewardHandler, org.mockito.Mockito.times(1)).givePersistedQueueRewardAsync( + any(AdvancedCoreUser.class), any(PersistedQueueReference.class), any(RewardOptions.class)); + } + + @Test + void unavailablePlayerFailsAsyncCommandDispatch() { + AdvancedCorePlugin.setInstance(plugin); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(UUID.fromString(user.getUUID()))).thenReturn(null); + + assertThrows(java.util.concurrent.CompletionException.class, + () -> user.preformCommandAsync(new ArrayList<>(List.of("say hello")), new HashMap<>()) + .toCompletableFuture().join()); + } finally { + AdvancedCorePlugin.setInstance(null); + } + } + @Test void timedAsyncCheckpointSurvivesRestartBeforeTheInFlightCompletion() throws Exception { long due = System.currentTimeMillis() - 1_000; From 0d1c8a2bce071de7993907ec3e764466b1fb1090 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:58:17 -0600 Subject: [PATCH 08/78] Preserve JavaScript reward replay selection --- .../api/rewards/builtin/RewardJavascript.java | 7 ++-- .../rewards/RewardAsyncInjectionTest.java | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java index 188935d6b7..187adf6334 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java @@ -87,8 +87,11 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor String path = Reward.replaySelection(placeholders, () -> new JavascriptEngine().addPlayer(user.getOfflinePlayer()).addPlaceholders(placeholders) .getBooleanValue(section.getString("Expression")) ? "TrueRewards" : "FalseRewards"); - return new RewardBuilder(section, path).withPrefix(reward.getName() + ".Javascript").sendAsync(user) - .thenApply(ignored -> null); + RewardBuilder builder = new RewardBuilder(section, path).withPrefix(reward.getName() + ".Javascript") + .withPlaceHolder(placeholders); + Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), + Reward.currentReplayKey(), "path:" + path, Reward.currentReplayOccurrenceId()); + return builder.sendAsync(user).thenApply(ignored -> null); } @Override 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 443f23dcb2..3a70069e57 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 @@ -11,6 +11,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -35,9 +36,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; +import org.mockito.Answers; +import org.mockito.MockedConstruction; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.rewards.injected.RewardInject; @@ -45,6 +49,8 @@ import com.bencodez.advancedcore.api.rewards.injected.RewardInjectString; import com.bencodez.advancedcore.api.rewards.builtin.RewardSubRewards; import com.bencodez.advancedcore.api.rewards.builtin.RewardRandomReward; +import com.bencodez.advancedcore.api.rewards.builtin.RewardJavascript; +import com.bencodez.advancedcore.api.javascript.JavascriptEngine; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; class RewardAsyncInjectionTest { @@ -460,6 +466,36 @@ void everyNestedRewardInjectorWaitsForItsSelectedChild() { assertEquals(List.of("after-child"), events); } + @Test + void javascriptChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { + ConfigurationSection javascript = data.createSection("Javascript"); + javascript.set("Enabled", true); + javascript.set("Expression", "true"); + javascript.createSection("TrueRewards"); + RewardJavascript.register(handler, plugin); + RewardOptions nestedOptions = new RewardOptions(); + + try (MockedConstruction engines = mockConstruction(JavascriptEngine.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (engine, context) -> when(engine.getBooleanValue("true")).thenReturn(true)); + MockedConstruction builders = mockConstruction(RewardBuilder.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> { + when(builder.getRewardOptions()).thenReturn(nestedOptions); + when(builder.withPlaceHolder(any())).thenAnswer(invocation -> { + nestedOptions.withPlaceHolder(invocation.getArgument(0)); + return builder; + }); + when(builder.sendAsync(user)).thenReturn(CompletableFuture.completedFuture(null)); + })) { + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + } + + assertNotNull(nestedOptions.getAsyncReplayState()); + assertTrue(nestedOptions.getAsyncReplayKey().endsWith("/path:TrueRewards")); + assertFalse(nestedOptions.getPlaceholders().isEmpty()); + } + @Test void replayCheckpointSkipsAlreadyAppliedInjectionAfterLaterFailure() throws Exception { AtomicInteger alreadyApplied = new AtomicInteger(); From 10091388ddbe2fa513d91360f4824ffd0bc37bad Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:20:05 -0600 Subject: [PATCH 09/78] Checkpoint command replay progress --- .../advancedcore/api/misc/MiscUtils.java | 22 ++++--- .../advancedcore/api/rewards/Reward.java | 65 +++++++++++++++++++ .../api/rewards/RewardExecutor.java | 13 ++-- .../api/rewards/builtin/RewardCommands.java | 10 +-- .../api/user/AdvancedCoreUser.java | 26 ++++---- .../tests/misc/MiscUtilsTest.java | 12 ++-- .../rewards/BuiltinRewardBehaviorTest.java | 13 ++-- .../rewards/RewardAsyncInjectionTest.java | 39 +++++++++++ .../tests/rewards/RewardExecutorTest.java | 43 +++++++++++- .../tests/user/AdvancedCoreUserTest.java | 59 +++++++++++++++++ 10 files changed, 259 insertions(+), 43 deletions(-) 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 89c0a415b2..2efe1e8e9a 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 @@ -43,6 +43,7 @@ import org.bukkit.potion.PotionEffectType; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; @@ -247,6 +248,11 @@ public void executeConsoleCommands(String playerName, String command, HashMap executeConsoleCommandsAsync(String playerName, String command, HashMap placeholders) { + return executeConsoleCommandAsync(playerName, command, placeholders, false); + } + + private CompletionStage executeConsoleCommandAsync(String playerName, String command, + HashMap placeholders, boolean delayed) { if (command == null || command.isEmpty()) return CompletableFuture.completedFuture(null); try { OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); @@ -260,7 +266,7 @@ public CompletionStage executeConsoleCommandsAsync(String playerName, Stri final String cmd = stripLeadingSlash(command); plugin.debug("Executing console command: " + command); - return runConsoleCommandAsync(cmd, 0, false, false); + return runConsoleCommandAsync(cmd, delayed ? 1 : 0, delayed, delayed); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } @@ -283,14 +289,12 @@ public CompletionStage executeConsoleCommandsAsync(final String playerName commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); if (p != null) commands = PlaceholderUtils.replacePlaceHolders(p, commands); - ArrayList> completions = new ArrayList<>(); - int delay = 0; - for (String command : commands) { - plugin.debug("Executing console command: " + command); - completions.add(runConsoleCommandAsync(stripLeadingSlash(command), delay++, stagger, true) - .toCompletableFuture()); - } - return CompletableFuture.allOf(completions.toArray(new CompletableFuture[0])); + return Reward.replayCommandSequence(plugin, placeholders, "console:" + stagger, commands, + (command, index) -> { + plugin.debug("Executing console command: " + command); + return runConsoleCommandAsync(stripLeadingSlash(command), index > 0 ? 1 : 0, + stagger && index > 0, true); + }); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } 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 b2460af6d3..590b002552 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 @@ -22,6 +22,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import java.util.function.Consumer; +import java.util.function.BiFunction; +import java.util.function.Function; import java.util.logging.Level; import org.bukkit.Bukkit; @@ -454,6 +456,69 @@ public static String replaySelection(HashMap placeholders, Suppl return selected; } + /** Executes a command list sequentially and durably records each successful item. */ + public static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commands, + Function> dispatch) { + return replayCommandSequence(plugin, placeholders, lane, commands, + (command, ignoredIndex) -> dispatch.apply(command)); + } + + /** Executes an indexed command list sequentially and durably records each successful item. */ + public static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commands, + BiFunction> dispatch) { + return replayCommandSequence(plugin, placeholders, lane, commands, currentReplayState(), currentReplayKey(), dispatch); + } + + static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commands, + ReplayState replayState, String activeKey, + BiFunction> dispatch) { + if (commands == null || commands.isEmpty()) return CompletableFuture.completedFuture(null); + if (replayState == null || activeKey == null) { + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = 0; index < commands.size(); index++) { + String command = commands.get(index); + int commandIndex = index; + sequence = sequence.thenCompose(ignored -> dispatch.apply(command, commandIndex)); + } + return sequence; + } + StringBuilder identity = new StringBuilder(activeKey == null ? "root" : activeKey) + .append('\n').append(lane == null ? "commands" : lane); + for (String command : commands) identity.append('\n').append(command == null ? "" : command); + String storageKey = "__advancedcore_replay_commands_" + digest(identity.toString()); + int completed = 0; + try { + completed = Integer.parseInt(placeholders.getOrDefault(storageKey, "0")); + } catch (NumberFormatException ignored) { } + if (completed < 0 || completed > commands.size()) completed = 0; + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = completed; index < commands.size(); index++) { + String command = commands.get(index); + int completedCount = index + 1; + int commandIndex = index; + sequence = sequence.thenCompose(ignored -> dispatch.apply(command, commandIndex)).thenCompose(ignored -> { + placeholders.put(storageKey, String.valueOf(completedCount)); + return replayState == null ? CompletableFuture.completedFuture(null) + : replayState.persistCheckpointAsync(plugin, placeholders); + }); + } + return sequence; + } + + private static String digest(String value) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte item : bytes) hex.append(String.format("%02x", item & 0xff)); + return hex.toString(); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable for reward replay checkpoints", failure); + } + } + /** Attaches a captured replay state to an option object for a deferred child. */ public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState) { if (replayState != null) options.setAsyncReplayState(replayState); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index 6af01b4d83..ab8d414093 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -176,11 +176,14 @@ public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewar public CompletionStage giveRewardAsync(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null); - if (!plugin.isEnabled()) return disabledDispatch(); - if (reward.startsWith("/")) { - return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), reward, - context.getPlaceholders()); - } + if (!plugin.isEnabled()) return disabledDispatch(); + if (reward.startsWith("/")) { + RewardOptions options = context.getOptions(); + return Reward.replayCommandSequence(plugin, context.getPlaceholders(), "direct", java.util.List.of(reward), + Reward.replayStateFor(options), options.getAsyncReplayKey(), + (command, ignoredIndex) -> MiscUtils.getInstance().executeConsoleCommandsAsync( + user.getPlayerName(), command, context.getPlaceholders())); + } return giveRewardAsync(user, handler.getReward(reward), context.getOptions()); } 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 f99f5baa49..b5aebc743b 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 @@ -155,8 +155,8 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { - return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), list, placeholders, true) - .thenApply(ignored -> null); + return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), list, placeholders, true) + .thenApply(ignored -> null); } }.addEditButton(new EditGUIButton(new ItemBuilder("COMMAND_BLOCK"), new EditGUIValueList("Commands", null) { @Override @@ -216,9 +216,9 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor // Player availability is a prerequisite for the mixed section. Do not // schedule console side effects until that prerequisite has completed, // otherwise a disconnect can fail the replay after grants already ran. - return player.thenCompose(ignored -> consoleCommands.isEmpty() ? CompletableFuture.completedFuture(null) - : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, - placeholders, section.getBoolean("Stagger", true))) + return player.thenCompose(ignored -> consoleCommands.isEmpty() ? CompletableFuture.completedFuture(null) + : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, + placeholders, section.getBoolean("Stagger", true))) .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/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 528c3f5e35..8a71e79e42 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 @@ -266,13 +266,15 @@ public void addPermission(String permission, long delay) { * @param epochMilli the epoch time in milliseconds when the reward should be * given */ - public synchronized void addTimedReward(Reward reward, HashMap placeholders, long epochMilli) { - HashMap timed = new HashMap<>(getTimedRewards()); - String rewardName = queuedRewardReference(reward); - rewardName += "%extime%" + System.currentTimeMillis(); + public void addTimedReward(Reward reward, HashMap placeholders, long epochMilli) { + synchronized (plugin) { + HashMap timed = new HashMap<>(getTimedRewards()); + String rewardName = queuedRewardReference(reward); + rewardName += "%extime%" + System.currentTimeMillis(); - timed.put(rewardName + "%placeholders%" + ArrayUtils.makeString(placeholders), epochMilli); - setTimedRewards(timed); + timed.put(rewardName + "%placeholders%" + ArrayUtils.makeString(placeholders), epochMilli); + setTimedRewards(timed); + } loadTimedDelayedTimer(epochMilli); } @@ -1628,12 +1630,10 @@ public CompletionStage preformCommandAsync(ArrayList commands, Has return CompletableFuture.failedFuture( new IllegalStateException("Player command could not run because the player or plugin is unavailable")); } - ArrayList> completions = new ArrayList<>(); - for (String command : cmds) { + return Reward.replayCommandSequence(plugin, placeholders, "player", cmds, command -> { plugin.debug("Executing player command for " + getPlayerName() + ": " + command); - completions.add(runPlayerCommandAsync(player, command)); - } - return CompletableFuture.allOf(completions.toArray(new CompletableFuture[0])); + return runPlayerCommandAsync(player, command); + }); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } @@ -2036,7 +2036,9 @@ public void setRepeatAmount(Reward reward, int amount) { * @param timed the timed rewards */ public void setTimedRewards(HashMap timed) { - setTimedRewards(timed, true); + synchronized (plugin) { + setTimedRewards(timed, true); + } } /** Writes an async replay checkpoint before the next stage can execute. */ diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java index 1c548e0006..191aa3b210 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -116,7 +117,8 @@ public void asyncStaggeredCommandsCompleteOnlyAfterEveryScheduledDispatch() { Server server = mock(Server.class); ConsoleCommandSender console = mock(ConsoleCommandSender.class); ArgumentCaptor immediate = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor delayed = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor firstDelayed = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor secondDelayed = ArgumentCaptor.forClass(Runnable.class); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getServer).thenReturn(server); @@ -126,14 +128,16 @@ public void asyncStaggeredCommandsCompleteOnlyAfterEveryScheduledDispatch() { java.util.concurrent.CompletionStage completion = miscUtils.executeConsoleCommandsAsync("Ben", new ArrayList<>(List.of("say one", "say two", "say three")), new HashMap<>(), true); verify(scheduler).runTask(eq(plugin), immediate.capture()); - verify(scheduler, times(2)).runTaskLater(eq(plugin), delayed.capture(), anyLong()); + verify(scheduler, never()).runTaskLater(eq(plugin), any(Runnable.class), anyLong()); assertFalse(completion.toCompletableFuture().isDone()); immediate.getValue().run(); assertFalse(completion.toCompletableFuture().isDone()); - delayed.getAllValues().get(0).run(); + verify(scheduler).runTaskLater(eq(plugin), firstDelayed.capture(), eq(1L)); + firstDelayed.getValue().run(); assertFalse(completion.toCompletableFuture().isDone()); - delayed.getAllValues().get(1).run(); + verify(scheduler, times(2)).runTaskLater(eq(plugin), secondDelayed.capture(), eq(1L)); + secondDelayed.getAllValues().get(1).run(); completion.toCompletableFuture().join(); verify(server).dispatchCommand(console, "say one"); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 65f7a0fe73..de7b5beb78 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -310,7 +310,7 @@ public void asyncCommandsWaitForTheirDispatchStages() { MiscUtils misc = mock(MiscUtils.class); CompletableFuture numberCommand = new CompletableFuture<>(); CompletableFuture command = new CompletableFuture<>(); - CompletableFuture commandList = new CompletableFuture<>(); + CompletableFuture commandList = new CompletableFuture<>(); CompletableFuture sectionConsole = new CompletableFuture<>(); CompletableFuture sectionPlayer = new CompletableFuture<>(); CompletableFuture random = new CompletableFuture<>(); @@ -320,11 +320,11 @@ public void asyncCommandsWaitForTheirDispatchStages() { .thenReturn(numberCommand); when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("say hi"), eq(placeholders))).thenReturn(command); ArrayList list = new ArrayList<>(List.of("say one", "say two")); - when(misc.executeConsoleCommandsAsync(eq("Ben"), eq(list), eq(placeholders), eq(true))) - .thenReturn(commandList); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq(list), eq(placeholders), eq(true))) + .thenReturn(commandList); ArrayList console = new ArrayList<>(List.of("say console")); - when(misc.executeConsoleCommandsAsync(eq("Ben"), eq(console), eq(placeholders), eq(false))) - .thenReturn(sectionConsole); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq(console), eq(placeholders), eq(false))) + .thenReturn(sectionConsole); ArrayList player = new ArrayList<>(List.of("spawn")); when(user.preformCommandAsync(eq(player), eq(placeholders))).thenReturn(sectionPlayer); ArrayList randomList = new ArrayList<>(List.of("say random")); @@ -350,7 +350,7 @@ public void asyncCommandsWaitForTheirDispatchStages() { CompletionStage listStage = ((RewardInjectStringList) injects.get(2)) .onRewardRequestAsync(reward, user, list, placeholders); assertFalse(listStage.toCompletableFuture().isDone()); - commandList.complete(null); + commandList.complete(null); listStage.toCompletableFuture().join(); ConfigurationSection commands = section("Commands"); @@ -392,6 +392,7 @@ public void mixedAsyncCommandsDoNotScheduleConsoleWorkWhenPlayerDispatchFails() assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); verify(misc, never()).executeConsoleCommandsAsync(anyString(), any(), any(), anyBoolean()); + verify(misc, never()).executeConsoleCommandsAsync(anyString(), anyString(), any()); } } 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 3a70069e57..8364914e5c 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 @@ -535,6 +535,45 @@ void replayCheckpointSkipsAlreadyAppliedInjectionAfterLaterFailure() throws Exce assertEquals(1, alreadyApplied.get()); } + @Test + void commandReplayRetriesOnlyTheFailedSuffix() throws Exception { + ArrayList dispatched = new ArrayList<>(); + AtomicBoolean failSecond = new AtomicBoolean(true); + handler.getInjectedRewards().add(new RewardInject("Commands") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap commandPlaceholders) { + return Reward.replayCommandSequence(plugin, commandPlaceholders, "console", + List.of("first", "second"), command -> { + dispatched.add(command); + return command.equals("second") && failSecond.get() + ? CompletableFuture.failedFuture(new IllegalStateException("temporary")) + : CompletableFuture.completedFuture(null); + }).thenApply(nothing -> null); + } + }); + + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + failSecond.set(false); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, + user, checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + resumed.toCompletableFuture().join(); + + assertEquals(List.of("first", "second", "second"), dispatched); + } + @Test void persistedReplayRejectsAChangedInjectorRegistryBeforeAnyStageRuns() throws Exception { AtomicInteger applied = new AtomicInteger(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 9ed41d3fb4..84c4a6b2fb 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -13,12 +13,15 @@ import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -127,7 +130,7 @@ public void listRewardDispatchesEveryNamedReward() { } @Test - public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { + public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { YamlConfiguration data = new YamlConfiguration(); data.set("Rewards", new ArrayList<>(List.of("Child", "Child"))); Reward child = mock(Reward.class); @@ -143,7 +146,43 @@ public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { assertFalse(children.get(0) == children.get(1)); assertFalse(children.get(0).getAsyncReplayKey().equals(children.get(1).getAsyncReplayKey())); assertSame(children.get(0).getAsyncReplayState(), children.get(1).getAsyncReplayState()); - } + } + + @Test + public void asyncListReplayDoesNotRepeatCompletedDirectCommands() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("/first", "/second"))); + MiscUtils misc = mock(MiscUtils.class); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("/first"), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("/second"), any())) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary")), + CompletableFuture.completedFuture(null)); + AtomicReference checkpoint = new AtomicReference<>(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0).run(); + return null; + }).when(timer).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(timer); + RewardOptions first = new RewardOptions(); + first.setAsyncReplayCheckpointConsumer(checkpoint::set); + + try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { + miscStatic.when(MiscUtils::getInstance).thenReturn(misc); + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Rewards", first).toCompletableFuture().join()); + assertNotNull(checkpoint.get()); + + RewardOptions retry = new RewardOptions() + .setPlaceholders(new java.util.HashMap<>(checkpoint.get().getPlaceholders())); + retry.setAsyncReplayCheckpointConsumer(ignored -> { }); + executor.giveRewardAsync(user, data, "Rewards", retry).toCompletableFuture().join(); + } + + verify(misc).executeConsoleCommandsAsync(eq("Ben"), eq("/first"), any()); + verify(misc, org.mockito.Mockito.times(2)).executeConsoleCommandsAsync(eq("Ben"), eq("/second"), any()); + } @Test public void stringRewardDispatchesNamedReward() { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 5f9f0fa7be..ed1756a36b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -16,6 +16,9 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; @@ -371,6 +374,53 @@ void pendingTimedReplayClaimIsSharedAcrossWrappersForTheSameUuid() { any(AdvancedCoreUser.class), any(PersistedQueueReference.class), any(RewardOptions.class)); } + @Test + void timedRewardMutationsAreSharedAcrossUserWrappers() throws Exception { + java.util.concurrent.atomic.AtomicReference> stored = + new java.util.concurrent.atomic.AtomicReference<>(new ArrayList<>()); + java.util.concurrent.atomic.AtomicInteger reads = new java.util.concurrent.atomic.AtomicInteger(); + CountDownLatch firstRead = new CountDownLatch(1); + CountDownLatch releaseFirstRead = new CountDownLatch(1); + CountDownLatch secondRead = new CountDownLatch(1); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> { + if (reads.getAndIncrement() == 0) { + firstRead.countDown(); + await(releaseFirstRead); + } else { + secondRead.countDown(); + } + return new ArrayList<>(stored.get()); + }); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("TimedRewards"), any()); + AdvancedCoreUser second = new AdvancedCoreUser(plugin, UUID.fromString(user.getUUID()), "Test"); + second.setData(data); + Reward firstReward = mock(Reward.class); + Reward secondReward = mock(Reward.class); + when(firstReward.getRewardName()).thenReturn("First"); + when(secondReward.getRewardName()).thenReturn("Second"); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + java.util.concurrent.Future first = workers.submit( + () -> user.addTimedReward(firstReward, new HashMap<>(), 1)); + assertTrue(firstRead.await(5, TimeUnit.SECONDS)); + java.util.concurrent.Future secondTask = workers.submit( + () -> second.addTimedReward(secondReward, new HashMap<>(), 2)); + assertFalse(secondRead.await(100, TimeUnit.MILLISECONDS)); + releaseFirstRead.countDown(); + first.get(10, TimeUnit.SECONDS); + secondTask.get(10, TimeUnit.SECONDS); + } finally { + releaseFirstRead.countDown(); + workers.shutdownNow(); + } + + assertEquals(2, stored.get().size()); + } + @Test void unavailablePlayerFailsAsyncCommandDispatch() { AdvancedCorePlugin.setInstance(plugin); @@ -479,6 +529,15 @@ private Reward.ReplayCheckpoint replayCheckpoint(Map progress, return constructor.newInstance(progress, placeholders); } + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + } + private Reward.ReplayCheckpoint replayCheckpoint(Map progress, Map fingerprints, HashMap placeholders) throws Exception { java.lang.reflect.Constructor constructor = Reward.ReplayCheckpoint.class From 1b53e9548a1d4ac70155a13e99aec600c369446a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:27:15 -0600 Subject: [PATCH 10/78] Make reward replay durable across scheduled work --- .../advancedcore/api/misc/MiscUtils.java | 3 +- .../advancedcore/api/rewards/Reward.java | 175 ++++++- .../api/rewards/RewardExecutor.java | 23 +- .../api/user/AdvancedCoreUser.java | 261 ++++++++--- .../rewards/RewardAsyncInjectionTest.java | 432 +++++++++++++++++- .../tests/rewards/RewardExecutorTest.java | 28 ++ .../tests/user/AdvancedCoreUserTest.java | 28 +- 7 files changed, 854 insertions(+), 96 deletions(-) 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 2efe1e8e9a..b2f7738632 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 @@ -284,12 +284,13 @@ public CompletionStage executeConsoleCommandsAsync(final String playerName try { placeholders.put("player", playerName); OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); + ArrayList templates = new ArrayList<>(cmds); ArrayList commands = cmds; if (p != null) commands = PlaceholderUtils.replaceJavascriptOnly(p, commands); commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); if (p != null) commands = PlaceholderUtils.replacePlaceHolders(p, commands); - return Reward.replayCommandSequence(plugin, placeholders, "console:" + stagger, commands, + return Reward.replayCommandSequence(plugin, placeholders, "console:" + stagger, templates, commands, (command, index) -> { plugin.debug("Executing console command: " + command); return runConsoleCommandAsync(stripLeadingSlash(command), index > 0 ? 1 : 0, 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 590b002552..bb3f9f64b1 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 @@ -51,6 +51,7 @@ public class Reward { private static final ThreadLocal ACTIVE_REPLAY_KEY = new ThreadLocal<>(); private static final ThreadLocal ACTIVE_REPLAY_OCCURRENCE_ID = new ThreadLocal<>(); private static final String REPLAY_SELECTION_PREFIX = "__advancedcore_replay_selection_"; + private static final String REPLAY_COMMAND_PREFIX = "__advancedcore_replay_commands_"; @Getter @Setter @@ -389,7 +390,9 @@ private RewardReplayFailure(ReplayState replayState, HashMap rep cause); this.replayState = replayState; this.completedInjectionCount = replayState.highestCompletedCount(); - this.replayPlaceholders = new HashMap<>(replayPlaceholders); + HashMap placeholders = new HashMap<>(replayPlaceholders); + replayState.mergeReplayMetadataInto(placeholders); + this.replayPlaceholders = placeholders; } public Map getReplayProgress() { return replayState.copyProgress(); } @@ -448,11 +451,16 @@ public static String replaySelection(HashMap placeholders, Suppl .encodeToString((activeKey == null ? "root" : activeKey).getBytes(StandardCharsets.UTF_8)); if (placeholders.containsKey(storageKey)) { String stored = placeholders.get(storageKey); + ReplayState replayState = ACTIVE_REPLAY_STATE.get(); + if (replayState != null) replayState.recordReplayMetadata(storageKey, stored); return stored.isEmpty() ? null : new String(Base64.getUrlDecoder().decode(stored), StandardCharsets.UTF_8); } String selected = selector.get(); - placeholders.put(storageKey, selected == null ? "" : Base64.getUrlEncoder().withoutPadding() - .encodeToString(selected.getBytes(StandardCharsets.UTF_8))); + String encoded = selected == null ? "" : Base64.getUrlEncoder().withoutPadding() + .encodeToString(selected.getBytes(StandardCharsets.UTF_8)); + placeholders.put(storageKey, encoded); + ReplayState replayState = ACTIVE_REPLAY_STATE.get(); + if (replayState != null) replayState.recordReplayMetadata(storageKey, encoded); return selected; } @@ -468,18 +476,30 @@ public static CompletionStage replayCommandSequence(AdvancedCorePlugin plu public static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, HashMap placeholders, String lane, List commands, BiFunction> dispatch) { - return replayCommandSequence(plugin, placeholders, lane, commands, currentReplayState(), currentReplayKey(), dispatch); + return replayCommandSequence(plugin, placeholders, lane, commands, commands, currentReplayState(), currentReplayKey(), dispatch); + } + + /** Executes expanded commands while identifying the sequence by stable templates. */ + public static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commandTemplates, + List expandedCommands, BiFunction> dispatch) { + return replayCommandSequence(plugin, placeholders, lane, commandTemplates, expandedCommands, + currentReplayState(), currentReplayKey(), dispatch); } static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, - HashMap placeholders, String lane, List commands, + HashMap placeholders, String lane, List commandTemplates, + List expandedCommands, ReplayState replayState, String activeKey, BiFunction> dispatch) { - if (commands == null || commands.isEmpty()) return CompletableFuture.completedFuture(null); + if (commandTemplates == null || commandTemplates.isEmpty()) return CompletableFuture.completedFuture(null); + if (expandedCommands == null || expandedCommands.size() != commandTemplates.size()) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Command templates and expansions differ in size")); + } if (replayState == null || activeKey == null) { CompletionStage sequence = CompletableFuture.completedFuture(null); - for (int index = 0; index < commands.size(); index++) { - String command = commands.get(index); + for (int index = 0; index < expandedCommands.size(); index++) { + String command = expandedCommands.get(index); int commandIndex = index; sequence = sequence.thenCompose(ignored -> dispatch.apply(command, commandIndex)); } @@ -487,20 +507,46 @@ static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, } StringBuilder identity = new StringBuilder(activeKey == null ? "root" : activeKey) .append('\n').append(lane == null ? "commands" : lane); - for (String command : commands) identity.append('\n').append(command == null ? "" : command); - String storageKey = "__advancedcore_replay_commands_" + digest(identity.toString()); + identity.append('\n').append(encodeCommandSnapshot(commandTemplates)); + String storageKey = REPLAY_COMMAND_PREFIX + digest(identity.toString()); + String snapshotKey = storageKey + "_snapshot"; + List commands; + String storedSnapshot = placeholders.get(snapshotKey); + boolean createdSnapshot = storedSnapshot == null; + if (storedSnapshot == null) { + commands = new ArrayList<>(expandedCommands); + String encodedSnapshot = encodeCommandSnapshot(commands); + placeholders.put(snapshotKey, encodedSnapshot); + replayState.recordReplayMetadata(snapshotKey, encodedSnapshot); + } else { + replayState.recordReplayMetadata(snapshotKey, storedSnapshot); + try { + commands = decodeCommandSnapshot(storedSnapshot, commandTemplates.size()); + } catch (IllegalArgumentException failure) { + return CompletableFuture.failedFuture(new IllegalStateException("Malformed command replay snapshot", failure)); + } + } int completed = 0; try { completed = Integer.parseInt(placeholders.getOrDefault(storageKey, "0")); } catch (NumberFormatException ignored) { } - if (completed < 0 || completed > commands.size()) completed = 0; - CompletionStage sequence = CompletableFuture.completedFuture(null); + if (placeholders.containsKey(storageKey)) { + replayState.recordReplayMetadata(storageKey, placeholders.get(storageKey)); + } + if (completed < 0 || completed > commandTemplates.size()) completed = 0; + // Persist the concrete expansion before issuing its first side effect. A + // restart during an indeterminate dispatch must reuse this exact payload, + // even though no command-progress marker exists yet. + CompletionStage sequence = createdSnapshot + ? replayState.persistCheckpointAsync(plugin, placeholders) + : CompletableFuture.completedFuture(null); for (int index = completed; index < commands.size(); index++) { String command = commands.get(index); int completedCount = index + 1; int commandIndex = index; sequence = sequence.thenCompose(ignored -> dispatch.apply(command, commandIndex)).thenCompose(ignored -> { placeholders.put(storageKey, String.valueOf(completedCount)); + replayState.recordReplayMetadata(storageKey, String.valueOf(completedCount)); return replayState == null ? CompletableFuture.completedFuture(null) : replayState.persistCheckpointAsync(plugin, placeholders); }); @@ -508,6 +554,50 @@ static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, return sequence; } + static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List commands, + ReplayState replayState, String activeKey, + BiFunction> dispatch) { + return replayCommandSequence(plugin, placeholders, lane, commands, commands, replayState, activeKey, dispatch); + } + + private static String encodeCommandSnapshot(List commands) { + StringBuilder encoded = new StringBuilder("v1:"); + for (String command : commands) { + if (encoded.length() > 3) encoded.append('.'); + if (command == null) encoded.append('~'); + else if (command.isEmpty()) encoded.append('='); + else encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(command.getBytes(StandardCharsets.UTF_8))); + } + return encoded.toString(); + } + + private static List decodeCommandSnapshot(String encoded, int expectedSize) { + if (!encoded.startsWith("v1:")) throw new IllegalArgumentException("Unknown command snapshot version"); + String body = encoded.substring(3); + ArrayList commands = new ArrayList<>(); + if (!body.isEmpty()) { + for (String item : body.split("\\.", -1)) { + if (item.equals("~")) { + commands.add(null); + continue; + } + if (item.equals("=")) { + commands.add(""); + continue; + } + try { + commands.add(new String(Base64.getUrlDecoder().decode(item), StandardCharsets.UTF_8)); + } catch (IllegalArgumentException failure) { + throw new IllegalArgumentException("Invalid command snapshot encoding", failure); + } + } + } + if (commands.size() != expectedSize) throw new IllegalArgumentException("Command snapshot size changed"); + return commands; + } + private static String digest(String value) { try { byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); @@ -519,6 +609,18 @@ private static String digest(String value) { } } + /** Copies only durable replay markers; ordinary reward placeholders stay isolated. */ + public static void mergeReplayMetadata(HashMap target, Map source) { + if (target == null || source == null) return; + for (Entry entry : source.entrySet()) { + if (isReplayMetadataKey(entry.getKey())) target.put(entry.getKey(), entry.getValue()); + } + } + + private static boolean isReplayMetadataKey(String key) { + return key != null && (key.startsWith(REPLAY_SELECTION_PREFIX) || key.startsWith(REPLAY_COMMAND_PREFIX)); + } + /** Attaches a captured replay state to an option object for a deferred child. */ public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState) { if (replayState != null) options.setAsyncReplayState(replayState); @@ -566,6 +668,7 @@ public static RewardOptions withReplayState(RewardOptions options, ReplayState r public static final class ReplayState { private final HashMap completed = new HashMap<>(); private final HashMap registryFingerprints = new HashMap<>(); + private final HashMap replayMetadata = new HashMap<>(); private final boolean legacyCheckpoint; private Consumer checkpointConsumer; private ReplayState(Map initial) { this(initial, null, false); } @@ -586,6 +689,13 @@ private synchronized void setRegistryFingerprint(String rewardName, String finge private synchronized Map copyRegistryFingerprints() { return new HashMap<>(registryFingerprints); } + /** Records a reserved marker produced by a nested replay-aware injector. */ + public synchronized void recordReplayMetadata(String key, String value) { + if (isReplayMetadataKey(key)) replayMetadata.put(key, value); + } + public synchronized void mergeReplayMetadataInto(HashMap target) { + Reward.mergeReplayMetadata(target, replayMetadata); + } private synchronized boolean hasPersistedCheckpoint() { return legacyCheckpoint || !completed.isEmpty() || !registryFingerprints.isEmpty(); } @@ -739,25 +849,42 @@ 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(); + CompletionStage result = CompletableFuture.failedFuture( + new IllegalStateException("Reward injection did not produce a result")); try { - if (inject.supportsAsyncRequest()) { - return inject.onRewardRequestAsync(this, user, getConfig().getConfigData(), placeholders); - } - try { - return CompletableFuture.completedFuture( - inject.onRewardRequest(this, user, getConfig().getConfigData(), placeholders)); - } catch (Exception failure) { - // Preserve the legacy per-injection isolation contract while allowing - // opted-in asynchronous injections to propagate durable failures. - failure.printStackTrace(); - return CompletableFuture.completedFuture(null); - } + if (inject.supportsAsyncRequest()) { + result = inject.onRewardRequestAsync(this, user, getConfig().getConfigData(), placeholders); + } else { + try { + result = CompletableFuture.completedFuture( + inject.onRewardRequest(this, user, getConfig().getConfigData(), placeholders)); + } catch (Exception failure) { + // Preserve the legacy per-injection isolation contract while allowing + // opted-in asynchronous injections to propagate durable failures. + failure.printStackTrace(); + result = CompletableFuture.completedFuture(null); + } + } + if (result == null) result = CompletableFuture.failedFuture( + new IllegalStateException("Reward injection returned a null asynchronous result")); } finally { + user.restoreAsyncActionCollectionScope(actionCollection); if (previous == null) ACTIVE_REPLAY_STATE.remove(); else ACTIVE_REPLAY_STATE.set(previous); if (previousKey == null) ACTIVE_REPLAY_KEY.remove(); else ACTIVE_REPLAY_KEY.set(previousKey); if (previousOccurrenceId == null) ACTIVE_REPLAY_OCCURRENCE_ID.remove(); else ACTIVE_REPLAY_OCCURRENCE_ID.set(previousOccurrenceId); } + CompletableFuture combined = new CompletableFuture<>(); + result.whenComplete((value, failure) -> { + user.claimAsyncContinuationActions(actionCollection); + user.endAsyncActionCollection(actionCollection).whenComplete((ignored, actionFailure) -> { + if (failure != null) combined.completeExceptionally(failure); + else if (actionFailure != null) combined.completeExceptionally(actionFailure); + else combined.complete(value); + }); + }); + return combined; } private void addPlaceholder(RewardInject inject, Object obj, HashMap placeholders) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index ab8d414093..5114557bc6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -5,6 +5,7 @@ import java.util.Base64; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.CompletionException; import org.bukkit.Bukkit; import org.bukkit.configuration.Configuration; @@ -120,12 +121,26 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio String parentReplayKey = options.getAsyncReplayKey(); if (parentReplayKey == null) parentReplayKey = Reward.currentReplayKey(); if (parentReplayKey == null) parentReplayKey = "list:" + path; + final String stableParentReplayKey = parentReplayKey; int rewardIndex = 0; for (String nestedReward : new ArrayList<>(data.getStringList(path))) { - RewardOptions nestedOptions = options.copyForNestedDispatch( - parentReplayKey + "/" + nestedReward + ":" + rewardIndex++); - nestedOptions.setAsyncReplayState(replayState); - sequence = sequence.thenCompose(ignored -> giveRewardAsync(user, nestedReward, nestedOptions)); + final int nestedIndex = rewardIndex++; + sequence = sequence.thenCompose(ignored -> { + // Clone only when this child is actually reached. This lets + // metadata from earlier children be merged before a later + // child receives its isolated ordinary placeholders. + RewardOptions nestedOptions = options.copyForNestedDispatch( + stableParentReplayKey + "/" + nestedReward + ":" + nestedIndex); + nestedOptions.setAsyncReplayState(replayState); + return giveRewardAsync(user, nestedReward, nestedOptions).handle((childResult, failure) -> { + // Child options stay isolated for normal placeholders. Replay + // markers are shared lazily so an earlier child remains durable + // when a later child fails and the list is restarted. + replayState.mergeReplayMetadataInto(options.getPlaceholders()); + if (failure != null) throw new CompletionException(failure); + return childResult; + }); + }); } return sequence; } 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 8a71e79e42..67a8b485dc 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 @@ -8,11 +8,14 @@ import java.time.ZoneId; import java.util.ArrayList; import java.util.Base64; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.Map; import java.util.Map.Entry; +import java.util.Set; import java.util.UUID; import java.util.WeakHashMap; import java.util.concurrent.TimeUnit; @@ -21,6 +24,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; import org.bukkit.Bukkit; import org.bukkit.Effect; @@ -69,10 +73,96 @@ public class AdvancedCoreUser { private static final String ASYNC_OCCURRENCE_DELIMITER = "%asyncoccurrence%"; private static final Object REPLAY_CLAIMS_LOCK = new Object(); private static final WeakHashMap> REPLAY_CLAIMS = new WeakHashMap<>(); + private static final ThreadLocal ASYNC_ACTION_COLLECTION = new ThreadLocal<>(); + private static final ThreadLocal>> CONTINUATION_ASYNC_ACTIONS = + ThreadLocal.withInitial(ArrayList::new); + private final Set activeAsyncActionCollections = Collections + .newSetFromMap(new IdentityHashMap<>()); + + /** Internal completion scope used by asynchronous reward dispatch. */ + public static final class AsyncActionCollection { + private final AsyncActionCollection previous; + private final ArrayList> actions = new ArrayList<>(); + private boolean closed; + + private AsyncActionCollection(AsyncActionCollection previous) { + this.previous = previous; + } + + private synchronized boolean add(CompletionStage action) { + if (closed) return false; + actions.add(action); + return true; + } + + private synchronized CompletionStage closeAndAwait() { + closed = true; + CompletionStage result = CompletableFuture.completedFuture(null); + for (CompletionStage action : actions) { + result = result.thenCompose(ignored -> action); + } + return result; + } + } + + /** Starts collecting completion stages created by legacy reward callbacks. */ + public AsyncActionCollection beginAsyncActionCollection() { + AsyncActionCollection collection = new AsyncActionCollection(ASYNC_ACTION_COLLECTION.get()); + synchronized (activeAsyncActionCollections) { + activeAsyncActionCollections.add(collection); + } + ASYNC_ACTION_COLLECTION.set(collection); + return collection; + } + + /** Restores the calling thread's previous collection while keeping this scope active for its returned stage. */ + public void restoreAsyncActionCollectionScope(AsyncActionCollection collection) { + if (collection == null || ASYNC_ACTION_COLLECTION.get() != collection) return; + if (collection.previous == null) ASYNC_ACTION_COLLECTION.remove(); + else ASYNC_ACTION_COLLECTION.set(collection.previous); + } + + /** Closes a completed async scope and returns completion for every collected action. */ + public CompletionStage endAsyncActionCollection(AsyncActionCollection collection) { + if (collection == null) return CompletableFuture.completedFuture(null); + restoreAsyncActionCollectionScope(collection); + synchronized (activeAsyncActionCollections) { + activeAsyncActionCollections.remove(collection); + return collection.closeAndAwait(); + } + } + + private boolean collectAsyncAction(CompletionStage action) { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + if (collection != null && collection.add(action)) return true; + synchronized (activeAsyncActionCollections) { + if (activeAsyncActionCollections.isEmpty()) return false; + // A CompletionStage continuation runs before completion callbacks attached + // to its returned stage. Hold its legacy action on that same thread until + // requestInjectionAsync's callback claims it for the exact collection. + CONTINUATION_ASYNC_ACTIONS.get().add(action); + return true; + } + } + + /** Claims legacy actions created by the continuation that just completed on this thread. */ + public void claimAsyncContinuationActions(AsyncActionCollection collection) { + if (collection == null) return; + ArrayList> pending = CONTINUATION_ASYNC_ACTIONS.get(); + if (pending.isEmpty()) return; + for (CompletionStage action : pending) collection.add(action); + pending.clear(); + CONTINUATION_ASYNC_ACTIONS.remove(); + } + + private void collectAsyncFailure(Throwable failure) { + collectAsyncAction(CompletableFuture.failedFuture(failure)); + } private static final class ReplayClaims { private final HashMap offline = new HashMap<>(); private final HashSet timed = new HashSet<>(); + private CompletableFuture serialReplayTail = CompletableFuture.completedFuture(null); } /** @@ -564,10 +654,21 @@ public void checkDelayedTimedRewards() { AtomicReference currentEntry = new AtomicReference<>(entry.getKey()); replayOptions.setAsyncReplayCheckpointConsumer( checkpoint -> checkpointTimedReward(currentEntry, time, checkpoint)); - plugin.getRewardHandler().givePersistedQueueRewardAsync(this, - new PersistedQueueReference(rewardReference), replayOptions).whenComplete((ignored, failure) -> { - if (failure == null) completeTimedReward(currentEntry.get(), time); - else restoreTimedReward(currentEntry.get(), time, failure); + enqueuePersistedReplay(() -> { + CompletionStage replay; + try { + replay = plugin.getRewardHandler().givePersistedQueueRewardAsync(this, + new PersistedQueueReference(rewardReference), replayOptions); + if (replay == null) throw new IllegalStateException("Timed reward replay returned no completion stage"); + } catch (Throwable failure) { + restoreTimedReward(currentEntry.get(), time, failure); + return CompletableFuture.completedFuture(null); + } + return replay.handle((ignored, failure) -> { + if (failure == null) completeTimedReward(currentEntry.get(), time); + else restoreTimedReward(currentEntry.get(), time, failure); + return null; + }); }); String rewardName = rewardReference; plugin.debug("Giving timed/delayed reward " + rewardName + " for " + getPlayerName() @@ -616,12 +717,55 @@ private void dispatchOfflineRewards(boolean force) { AtomicReference currentEntry = new AtomicReference<>(rewardEntry); options.setAsyncReplayCheckpointConsumer(checkpoint -> checkpointOfflineReward(currentEntry, checkpoint)); - plugin.getRewardHandler().givePersistedQueueRewardAsync(this, - new PersistedQueueReference(rewardReference), options).whenComplete((ignored, failure) -> { - if (failure == null) completeOfflineReward(currentEntry.get()); - else restoreOfflineReward(currentEntry.get(), failure); - }); + enqueuePersistedReplay(() -> { + CompletionStage replay; + try { + replay = plugin.getRewardHandler().givePersistedQueueRewardAsync(this, + new PersistedQueueReference(rewardReference), options); + if (replay == null) throw new IllegalStateException("Offline reward replay returned no completion stage"); + } catch (Throwable failure) { + restoreOfflineReward(currentEntry.get(), failure); + return CompletableFuture.completedFuture(null); + } + return replay.handle((ignored, failure) -> { + if (failure == null) completeOfflineReward(currentEntry.get()); + else restoreOfflineReward(currentEntry.get(), failure); + return null; + }); + }); + } + } + + /** + * Runs persisted offline and timed occurrences one at a time for this plugin + * and user. The tail deliberately absorbs a completed occurrence's failure: + * its own restore path retains the queue entry, while the next occurrence must + * still be allowed to start without blocking a caller thread. + */ + private void enqueuePersistedReplay(Supplier> replay) { + ReplayClaims claims; + CompletableFuture previous; + CompletableFuture next = new CompletableFuture<>(); + synchronized (plugin) { + claims = replayClaims(); + previous = claims.serialReplayTail; + claims.serialReplayTail = next; } + previous.whenComplete((ignored, previousFailure) -> { + CompletionStage stage; + try { + stage = replay.get(); + if (stage == null) throw new IllegalStateException("Persisted reward replay returned no completion stage"); + } catch (Throwable failure) { + next.complete(null); + releaseReplayClaimsIfEmpty(claims); + return; + } + stage.whenComplete((result, failure) -> { + next.complete(null); + releaseReplayClaimsIfEmpty(claims); + }); + }); } private void checkpointOfflineReward(AtomicReference currentEntry, Reward.ReplayCheckpoint checkpoint) { @@ -1119,15 +1263,11 @@ public void giveItem(ItemStack item) { final Player player = getPlayer(); if (plugin.isEnabled()) { - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - if (player != null) { - plugin.getFullInventoryHandler().giveItem(player, item); - } - } - }, player); + scheduleLegacyRewardAction(() -> { + if (player != null) plugin.getFullInventoryHandler().giveItem(player, item); + }, player, true); + } else { + collectAsyncFailure(new IllegalStateException("Plugin disabled before item reward was scheduled")); } } @@ -1155,15 +1295,11 @@ public void giveItems(ItemStack... item) { final Player player = getPlayer(); if (plugin.isEnabled()) { - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - if (player != null) { - plugin.getFullInventoryHandler().giveItem(player, item); - } - } - }, player); + scheduleLegacyRewardAction(() -> { + if (player != null) plugin.getFullInventoryHandler().giveItem(player, item); + }, player, true); + } else { + collectAsyncFailure(new IllegalStateException("Plugin disabled before item reward was scheduled")); } } @@ -1175,36 +1311,28 @@ public void run() { */ public void giveMoney(double m) { if (!plugin.isEnabled()) { + collectAsyncFailure(new IllegalStateException("Plugin disabled before money reward was scheduled")); return; } if (plugin.getVaultHandler() != null && plugin.getVaultHandler().getEcon() != null) { try { if (m > 0) { final double money = m; - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - plugin.getVaultHandler().getEcon().depositPlayer(getOfflinePlayer(), money); - } - }); + scheduleLegacyRewardAction( + () -> plugin.getVaultHandler().getEcon().depositPlayer(getOfflinePlayer(), money), null, false); } else if (m < 0) { m = m * -1; final double money = m; - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - plugin.getVaultHandler().getEcon().withdrawPlayer(getOfflinePlayer(), money); - } - }); + scheduleLegacyRewardAction( + () -> plugin.getVaultHandler().getEcon().withdrawPlayer(getOfflinePlayer(), money), null, false); } } catch ( IllegalStateException e) { e.printStackTrace(); + collectAsyncFailure(e); } } } @@ -1228,17 +1356,48 @@ public void giveMoney(int money) { public void givePotionEffect(String potionName, int duration, int amplifier) { Player player = getPlayer(); if (player != null && plugin.isEnabled()) { - getPlugin().getBukkitScheduler().runTask(plugin, new Runnable() { - - @SuppressWarnings("deprecation") - @Override - public void run() { - player.addPotionEffect( - new PotionEffect(PotionEffectType.getByName(potionName), 20 * duration, amplifier)); - } - }, player); + scheduleLegacyRewardAction(() -> player.addPotionEffect( + new PotionEffect(PotionEffectType.getByName(potionName), 20 * duration, amplifier)), player, true); + } else if (player != null && ASYNC_ACTION_COLLECTION.get() != null) { + collectAsyncFailure(new IllegalStateException( + "Potion reward could not be scheduled because the plugin is unavailable")); + } + } + /** Queues a legacy Bukkit action while exposing a nonblocking completion internally. */ + private void scheduleLegacyRewardAction(Runnable action, Player player, boolean playerAware) { + CompletableFuture completion = new CompletableFuture<>(); + if (!collectAsyncAction(completion)) { + if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, action, player); + else getPlugin().getBukkitScheduler().runTask(plugin, action); + return; + } + AtomicBoolean claimed = new AtomicBoolean(); + Runnable dispatch = () -> { + if (!claimed.compareAndSet(false, true)) return; + if (!plugin.isEnabled()) { + completion.completeExceptionally(new IllegalStateException("Plugin disabled before scheduled reward action ran")); + return; + } + try { + action.run(); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }; + try { + if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); + else getPlugin().getBukkitScheduler().runTask(plugin, dispatch); + } catch (Throwable failure) { + claimed.set(true); + completion.completeExceptionally(failure); } + CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { + if (claimed.compareAndSet(false, true)) { + completion.completeExceptionally(new TimeoutException("Timed out waiting for scheduled reward action")); + } + }); } /** @@ -1630,7 +1789,7 @@ public CompletionStage preformCommandAsync(ArrayList commands, Has return CompletableFuture.failedFuture( new IllegalStateException("Player command could not run because the player or plugin is unavailable")); } - return Reward.replayCommandSequence(plugin, placeholders, "player", cmds, command -> { + return Reward.replayCommandSequence(plugin, placeholders, "player", commands, cmds, (command, ignoredIndex) -> { plugin.debug("Executing player command for " + getPlayerName() + ": " + command); return runPlayerCommandAsync(player, command); }); 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 8364914e5c..c8830e0dae 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 @@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.times; @@ -21,6 +22,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; @@ -29,8 +31,13 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -40,6 +47,9 @@ import org.mockito.MockedConstruction; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.VaultHandler; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.advancedcore.api.rewards.RewardHandler; @@ -53,6 +63,8 @@ import com.bencodez.advancedcore.api.javascript.JavascriptEngine; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import net.milkbowl.vault.economy.Economy; + class RewardAsyncInjectionTest { @TempDir @@ -102,6 +114,414 @@ void tearDown() { AdvancedCorePlugin.setInstance(null); } + @Test + void legacyScheduledRewardsWaitForQueuedBukkitActions() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + realUser.setPlayerName("Legacy"); + Player player = mock(Player.class); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(2); + realUser.giveItem(new ItemStack(Material.STONE)); + return null; + } + }); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + assertFalse(result.toCompletableFuture().isDone()); + assertEquals(2, queued.size()); + for (Runnable action : queued) action.run(); + result.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 2); + verify(inventory).giveItem(eq(player), any(ItemStack.class)); + } + + @Test + void legacySchedulerRejectionFailsAsyncRewardClosed() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Legacy"); + doThrow(new IllegalStateException("scheduler stopped")).when(scheduler) + .runTask(eq(plugin), any(Runnable.class)); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(2); + return null; + } + }); + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(realUser, new HashMap<>()).toCompletableFuture().join()); + } + } + + @Test + void legacyQueuedActionFailsWhenPluginShutsDownBeforeExecution() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Legacy"); + AtomicReference queued = new AtomicReference<>(); + doAnswer(invocation -> { + queued.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(2); + return null; + } + }); + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + assertFalse(result.toCompletableFuture().isDone()); + enabled.set(false); + queued.get().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + } + } + + @Test + void legacyActionsCreatedByAnAsyncContinuationAreAwaited() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Legacy"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + CompletableFuture continuation = new CompletableFuture<>(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return continuation.thenApply(nothing -> { + realUser.giveMoney(2); + return null; + }); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + continuation.complete(null); + assertEquals(1, queued.size()); + assertFalse(result.toCompletableFuture().isDone()); + queued.get(0).run(); + result.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 2); + } + + @Test + void serializedPersistedReplaysWaitForThePriorContinuationAndLegacyAction() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Serialized"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queuedActions = new ArrayList<>(); + doAnswer(invocation -> { + queuedActions.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + ArrayList> continuations = new ArrayList<>(); + continuations.add(new CompletableFuture<>()); + continuations.add(new CompletableFuture<>()); + AtomicInteger invocations = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + int index = invocations.getAndIncrement(); + return continuations.get(index).thenApply(nothing -> { + realUser.giveMoney(index + 1); + return null; + }); + } + }); + + java.lang.reflect.Method enqueue = AdvancedCoreUser.class.getDeclaredMethod("enqueuePersistedReplay", + java.util.function.Supplier.class); + enqueue.setAccessible(true); + java.util.function.Supplier> replay = + () -> reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + enqueue.invoke(realUser, replay); + enqueue.invoke(realUser, replay); + assertEquals(1, invocations.get(), "the second replay must remain behind the first continuation"); + + continuations.get(0).complete(null); + assertEquals(1, queuedActions.size()); + assertEquals(1, invocations.get(), "the first legacy action is part of the replay tail"); + queuedActions.get(0).run(); + + assertEquals(2, invocations.get(), "the second replay starts only after the first action completed"); + continuations.get(1).complete(null); + assertEquals(2, queuedActions.size()); + queuedActions.get(1).run(); + } + verify(economy).depositPlayer(offlinePlayer, 1); + verify(economy).depositPlayer(offlinePlayer, 2); + } + + @Test + void legacyContinuationActionIsClaimedOnlyByItsCompletingScope() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Nested"); + ArrayList queuedActions = new ArrayList<>(); + doAnswer(invocation -> { + queuedActions.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(mock(OfflinePlayer.class)); + AdvancedCoreUser.AsyncActionCollection outer = realUser.beginAsyncActionCollection(); + AdvancedCoreUser.AsyncActionCollection inner = realUser.beginAsyncActionCollection(); + realUser.restoreAsyncActionCollectionScope(inner); + realUser.restoreAsyncActionCollectionScope(outer); + realUser.giveMoney(1); + realUser.claimAsyncContinuationActions(inner); + + CompletionStage outerCompletion = realUser.endAsyncActionCollection(outer); + CompletionStage innerCompletion = realUser.endAsyncActionCollection(inner); + assertEquals(1, queuedActions.size()); + assertTrue(outerCompletion.toCompletableFuture().isDone()); + assertFalse(innerCompletion.toCompletableFuture().isDone()); + queuedActions.get(0).run(); + outerCompletion.toCompletableFuture().join(); + innerCompletion.toCompletableFuture().join(); + } + } + + @Test + void failedLegacyActionCreatedByAnAsyncContinuationFailsTheReward() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Legacy"); + AtomicReference queued = new AtomicReference<>(); + doAnswer(invocation -> { + queued.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + CompletableFuture continuation = new CompletableFuture<>(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return continuation.thenApply(nothing -> { + realUser.giveMoney(2); + return null; + }); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + continuation.complete(null); + enabled.set(false); + queued.get().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + } + } + + @Test + void concurrentContinuationActionsRemainBoundToTheirOwnReward() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Concurrent"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + AtomicReference successfulAction = new AtomicReference<>(); + doThrow(new IllegalStateException("first scheduler failure")).doAnswer(invocation -> { + successfulAction.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + ArrayList> continuations = new ArrayList<>(); + continuations.add(new CompletableFuture<>()); + continuations.add(new CompletableFuture<>()); + AtomicInteger invocation = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + int index = invocation.getAndIncrement(); + return continuations.get(index).thenApply(nothing -> { + realUser.giveMoney(index + 1); + return null; + }); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage first = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + CompletionStage second = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + + continuations.get(0).complete(null); + assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join()); + assertFalse(second.toCompletableFuture().isDone()); + + continuations.get(1).complete(null); + successfulAction.get().run(); + second.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 2); + } + + @Test + void commandSnapshotIsCheckpointedBeforeItsFirstDispatch() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(new HashMap<>(), new HashMap<>(), false); + List checkpoints = new ArrayList<>(); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, (java.util.function.Consumer) checkpoints::add); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("replayCommandSequence", + AdvancedCorePlugin.class, HashMap.class, String.class, List.class, List.class, stateType, String.class, + java.util.function.BiFunction.class); + replay.setAccessible(true); + List dispatched = new ArrayList<>(); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(null, plugin, new HashMap<>(), "console", + List.of("say %value%"), List.of("say fixed"), replayState, "AsyncReward/0", + (java.util.function.BiFunction>) (command, index) -> { + dispatched.add(command); + return CompletableFuture.completedFuture(null); + }); + + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + assertTrue(dispatched.isEmpty()); + assertFalse(result.toCompletableFuture().isDone()); + writes.getValue().run(); + assertEquals(List.of("say fixed"), dispatched); + assertTrue(checkpoints.get(0).getReplayProgress().isEmpty()); + assertTrue(checkpoints.get(0).getPlaceholders().entrySet().stream() + .anyMatch(entry -> entry.getKey().startsWith("__advancedcore_replay_commands_") + && entry.getKey().endsWith("_snapshot") && entry.getValue().contains("c2F5IGZpeGVk"))); + ArgumentCaptor progressWrite = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor, times(2)).execute(progressWrite.capture()); + progressWrite.getAllValues().get(1).run(); + result.toCompletableFuture().join(); + } + + @Test + void commandSnapshotDistinguishesAnEmptyElementFromAnEmptyList() throws Exception { + java.lang.reflect.Method encode = Reward.class.getDeclaredMethod("encodeCommandSnapshot", List.class); + encode.setAccessible(true); + String emptyList = (String) encode.invoke(null, List.of()); + String emptyElement = (String) encode.invoke(null, List.of("")); + assertNotEquals(emptyList, emptyElement); + java.lang.reflect.Method decode = Reward.class.getDeclaredMethod("decodeCommandSnapshot", String.class, int.class); + decode.setAccessible(true); + assertEquals(List.of(), decode.invoke(null, emptyList, 0)); + assertEquals(List.of(""), decode.invoke(null, emptyElement, 1)); + } + @Test void asyncInjectionsAreSequentialAndPostRewardsWaitForThem() { List events = new ArrayList<>(); @@ -539,16 +959,20 @@ void replayCheckpointSkipsAlreadyAppliedInjectionAfterLaterFailure() throws Exce void commandReplayRetriesOnlyTheFailedSuffix() throws Exception { ArrayList dispatched = new ArrayList<>(); AtomicBoolean failSecond = new AtomicBoolean(true); + AtomicInteger expansion = new AtomicInteger(); handler.getInjectedRewards().add(new RewardInject("Commands") { @Override public boolean supportsAsyncRequest() { return true; } @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, HashMap commandPlaceholders) { - return Reward.replayCommandSequence(plugin, commandPlaceholders, "console", - List.of("first", "second"), command -> { + List templates = List.of("first %value%", "second %value%"); + int currentExpansion = expansion.incrementAndGet(); + List expanded = List.of("first-" + currentExpansion, "second-" + currentExpansion); + return Reward.replayCommandSequence(plugin, commandPlaceholders, "console", templates, expanded, + (command, ignoredIndex) -> { dispatched.add(command); - return command.equals("second") && failSecond.get() + return command.startsWith("second-") && failSecond.get() ? CompletableFuture.failedFuture(new IllegalStateException("temporary")) : CompletableFuture.completedFuture(null); }).thenApply(nothing -> null); @@ -571,7 +995,7 @@ void commandReplayRetriesOnlyTheFailedSuffix() throws Exception { "AsyncReward"); resumed.toCompletableFuture().join(); - assertEquals(List.of("first", "second", "second"), dispatched); + assertEquals(List.of("first-1", "second-1", "second-1"), dispatched); } @Test diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 84c4a6b2fb..e73350e6b7 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -148,6 +148,34 @@ public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { assertSame(children.get(0).getAsyncReplayState(), children.get(1).getAsyncReplayState()); } + @Test + public void asyncListLazilyClonesAndCarriesOnlyReplayMetadata() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("First", "Second"))); + Reward first = mock(Reward.class); + Reward second = mock(Reward.class); + when(handler.getReward("First")).thenReturn(first); + when(handler.getReward("Second")).thenReturn(second); + RewardOptions parent = new RewardOptions().addPlaceholder("ordinary", "parent"); + when(first.giveRewardAsync(eq(user), any(RewardOptions.class))).thenAnswer(invocation -> { + RewardOptions child = invocation.getArgument(1); + child.getPlaceholders().put("ordinary", "child"); + child.getAsyncReplayState().recordReplayMetadata("__advancedcore_replay_commands_test_snapshot", "v1:c2"); + return CompletableFuture.completedFuture(null); + }); + when(second.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("later child failed"))); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Rewards", parent).toCompletableFuture().join()); + + ArgumentCaptor captured = ArgumentCaptor.forClass(RewardOptions.class); + verify(second).giveRewardAsync(eq(user), captured.capture()); + assertEquals("parent", captured.getValue().getPlaceholders().get("ordinary")); + assertEquals("v1:c2", captured.getValue().getPlaceholders() + .get("__advancedcore_replay_commands_test_snapshot")); + } + @Test public void asyncListReplayDoesNotRepeatCompletedDirectCommands() { YamlConfiguration data = new YamlConfiguration(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index ed1756a36b..8bf954e896 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -289,21 +289,25 @@ void offlineCheckpointMigratesOnlyItsOwnDuplicateInFlightClaim() throws Exceptio when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), any(RewardOptions.class))).thenReturn(pending); - ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); - user.checkOfflineRewards(); - verify(rewardHandler, org.mockito.Mockito.times(2)).givePersistedQueueRewardAsync(eq(user), - any(PersistedQueueReference.class), options.capture()); - options.getAllValues().get(0).getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("VoteReward", 1), - new HashMap<>(Map.of("Server", "server-a")))); + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkOfflineRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), options.capture()); + options.getValue().getAsyncReplayCheckpointConsumer().accept(replayCheckpoint(Map.of("VoteReward", 1), + new HashMap<>(Map.of("Server", "server-a")))); assertEquals(2, persisted.size()); assertTrue(persisted.stream().anyMatch(value -> value.contains("%asyncprogress%v2-"))); - assertTrue(persisted.stream().anyMatch(value -> value.equals(entry))); - user.checkOfflineRewards(); - verify(rewardHandler, org.mockito.Mockito.times(2)).givePersistedQueueRewardAsync(eq(user), - any(PersistedQueueReference.class), any(RewardOptions.class)); - verify(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); - } + assertTrue(persisted.stream().anyMatch(value -> value.equals(entry))); + user.checkOfflineRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + verify(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + + pending.complete(null); + verify(rewardHandler, org.mockito.Mockito.times(2)).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + } @Test void timedAsyncReplayPreservesItsStoredCheckpointAfterExecutionMarker() { From 400bf780208b7b3bef913ff4107202905372368c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:57:18 -0600 Subject: [PATCH 11/78] Preserve queued reward replay state when deferred --- .../advancedcore/api/rewards/Reward.java | 33 +++++++-- .../api/user/AdvancedCoreUser.java | 59 ++++++++++++--- .../rewards/RewardAsyncInjectionTest.java | 24 +++++++ .../tests/user/AdvancedCoreUserTest.java | 72 +++++++++++++++++++ 4 files changed, 174 insertions(+), 14 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 bb3f9f64b1..45869c62a6 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 @@ -439,6 +439,18 @@ public static ReplayState replayStateFor(RewardOptions options) { return replayState; } + /** + * Copies the durable portion of an in-flight replay into options before the + * reward is deferred. Pause/vanish handling happens before the async + * injector creates its next checkpoint, so the queue must carry the state + * already accumulated by a nested replay rather than starting a new + * occurrence with placeholders only. + */ + public static void preserveReplayState(RewardOptions options) { + if (options == null || options.getAsyncReplayState() == null) return; + options.getAsyncReplayState().copyTo(options); + } + /** * Records a nondeterministic nested-reward decision in the placeholder * snapshot carried by {@link RewardReplayFailure}. A retry consequently @@ -699,6 +711,13 @@ public synchronized void mergeReplayMetadataInto(HashMap target) private synchronized boolean hasPersistedCheckpoint() { return legacyCheckpoint || !completed.isEmpty() || !registryFingerprints.isEmpty(); } + private synchronized void copyTo(RewardOptions options) { + options.setCompletedAsyncInjections(Math.max(options.getCompletedAsyncInjections(), highestCompletedCount())); + options.setAsyncReplayProgress(copyProgress()); + options.setAsyncReplayRegistryFingerprints(copyRegistryFingerprints()); + options.setLegacyAsyncReplayCheckpoint(legacyCheckpoint); + mergeReplayMetadataInto(options.getPlaceholders()); + } private synchronized boolean matchesRegistryFingerprint(String currentFingerprint) { if (legacyCheckpoint) return false; for (Entry entry : completed.entrySet()) { @@ -995,7 +1014,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { if (plugin.getOptions().isPauseRewards()) { checkRewardFile(); - user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + preserveReplayState(rewardOptions); + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); plugin.getLogger() .info("Rewards are paused, saving offline reward " + getRewardName() + ": " + user.getPlayerName()); return; @@ -1003,7 +1023,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { if ((plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) { checkRewardFile(); - user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + preserveReplayState(rewardOptions); + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); plugin.getLogger() .info(getRewardName() + ": " + user.getPlayerName() + " is vanished, saving reward offline"); return; @@ -1095,7 +1116,8 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption } if (plugin.getOptions().isPauseRewards() || (plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) { checkRewardFile(); - user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + preserveReplayState(rewardOptions); + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); return CompletableFuture.completedFuture(null); } if (((((!rewardOptions.isOnline() || rewardOptions.getServer() != null) && !user.isOnline()) || allowOffline) @@ -1130,8 +1152,9 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption * @param rewardOptions rewardOptions */ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { - if (hasAsyncRewardInjection() || hasPersistedReplayCheckpoint(rewardOptions)) { - giveRewardUserAsync(user, phs, rewardOptions).exceptionally(failure -> { + RewardOptions effectiveOptions = rewardOptions == null ? new RewardOptions() : rewardOptions; + if (hasAsyncRewardInjection() || hasPersistedReplayCheckpoint(effectiveOptions)) { + giveRewardUserAsync(user, phs, effectiveOptions).exceptionally(failure -> { logRewardUserFailure(failure); return 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 67a8b485dc..7c4cd4ea3f 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 @@ -322,9 +322,27 @@ public AdvancedCoreUser(AdvancedCorePlugin plugin, UUID uuid, String playerName) * @param placeholders the placeholders */ public void addOfflineRewards(Reward reward, HashMap placeholders) { + addOfflineRewards(reward, placeholders, null); + } + + /** + * Adds an offline reward while retaining an in-flight asynchronous replay's + * occurrence and durable checkpoint. This overload is used when pause or + * vanish handling defers a reward before its async chain can complete. + * + * @param reward the reward + * @param placeholders the placeholders + * @param options replay options to preserve, if any + */ + public void addOfflineRewards(Reward reward, HashMap placeholders, RewardOptions options) { synchronized (plugin) { ArrayList offlineRewards = getOfflineRewards(); - offlineRewards.add(queuedRewardReference(reward) + "%placeholders%" + ArrayUtils.makeString(placeholders)); + Reward.preserveReplayState(options); + HashMap savedPlaceholders = placeholders == null ? new HashMap<>() + : new HashMap<>(placeholders); + if (options != null) savedPlaceholders.putAll(options.getPlaceholders()); + offlineRewards.add(queuedRewardReference(reward, options) + "%placeholders%" + + ArrayUtils.makeString(savedPlaceholders)); setOfflineRewards(offlineRewards); } } @@ -369,10 +387,26 @@ public void addTimedReward(Reward reward, HashMap placeholders, } private String queuedRewardReference(Reward reward) { + return queuedRewardReference(reward, null); + } + + private String queuedRewardReference(Reward reward, RewardOptions options) { String encodedName = Base64.getUrlEncoder().withoutPadding() .encodeToString(reward.getRewardName().getBytes(StandardCharsets.UTF_8)); - return QUEUED_REFERENCE_PREFIX + (reward.isGeneratedSnapshotCreated() ? "snapshot/" : "normal/") - + encodedName + ASYNC_OCCURRENCE_DELIMITER + UUID.randomUUID(); + String reference = QUEUED_REFERENCE_PREFIX + (reward.isGeneratedSnapshotCreated() ? "snapshot/" : "normal/") + + encodedName + ASYNC_OCCURRENCE_DELIMITER + + (options == null || options.getAsyncReplayOccurrenceId() == null + || options.getAsyncReplayOccurrenceId().isEmpty() ? UUID.randomUUID() + : options.getAsyncReplayOccurrenceId()); + if (options != null) { + String serialized = encodeAsyncReplayProgress(options.getAsyncReplayProgress(), + options.getAsyncReplayRegistryFingerprints()); + if (!serialized.isEmpty()) reference += ASYNC_PROGRESS_DELIMITER + serialized; + else if (options.getCompletedAsyncInjections() > 0) { + reference += ASYNC_PROGRESS_DELIMITER + options.getCompletedAsyncInjections(); + } + } + return reference; } private static QueuedReplay parseQueuedReplay(String storedReference) { @@ -911,12 +945,19 @@ private ReplayClaims replayClaims() { } private void releaseReplayClaimsIfEmpty(ReplayClaims claims) { - if (!claims.offline.isEmpty() || !claims.timed.isEmpty()) return; - synchronized (REPLAY_CLAIMS_LOCK) { - HashMap byUser = REPLAY_CLAIMS.get(plugin); - if (byUser == null || byUser.get(getUUID()) != claims) return; - byUser.remove(getUUID()); - if (byUser.isEmpty()) REPLAY_CLAIMS.remove(plugin); + synchronized (plugin) { + synchronized (REPLAY_CLAIMS_LOCK) { + // Claim mutations use the same lock ordering (plugin, then this + // lock). Recheck only while holding both locks; checking before + // entering them permits a concurrent wrapper to add a claim that + // cleanup then drops. + if (!claims.offline.isEmpty() || !claims.timed.isEmpty() + || !claims.serialReplayTail.isDone()) return; + HashMap byUser = REPLAY_CLAIMS.get(plugin); + if (byUser == null || byUser.get(getUUID()) != claims) return; + byUser.remove(getUUID()); + if (byUser.isEmpty()) REPLAY_CLAIMS.remove(plugin); + } } } 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 c8830e0dae..8fc7ccc49f 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 @@ -1094,6 +1094,30 @@ void persistedReplayDoesNotFallBackToSynchronousDispatchWhenAsyncInjectorsAreGon assertEquals(0, invoked.get()); } + @Test + void publicRewardUserApiRetainsNullOptionsCompatibilityWithAsyncInjectors() { + AtomicReference receivedOptions = new AtomicReference<>(); + handler.getInjectedRewards().add(new RewardInject("Async") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return false; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return CompletableFuture.completedFuture(null); + } + }); + Reward spyReward = org.mockito.Mockito.spy(reward); + doAnswer(invocation -> { + receivedOptions.set(invocation.getArgument(2)); + return CompletableFuture.completedFuture(null); + }).when(spyReward).giveRewardUserAsync(eq(user), any(HashMap.class), any(RewardOptions.class)); + + spyReward.giveRewardUser(user, new HashMap<>(), null); + + assertNotNull(receivedOptions.get()); + } + @Test void sharedNestedReplayStatePreventsSynchronousFallbackWhenOptionMapsAreEmpty() throws Exception { AtomicInteger invoked = new AtomicInteger(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 8bf954e896..0990f275b7 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; @@ -21,6 +22,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; import org.bukkit.Bukkit; @@ -152,6 +154,76 @@ void queuedOccurrenceIsForwardedBeforeAnyAsyncInjectorRuns() { assertEquals(occurrence, options.getValue().getAsyncReplayOccurrenceId()); } + @Test + void deferredReplayRetainsOccurrenceAndCheckpoint() { + ArrayList persisted = new ArrayList<>(); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); + when(reward.getRewardName()).thenReturn("VoteReward"); + RewardOptions options = new RewardOptions().addPlaceholder("Server", "server-a"); + String occurrence = UUID.randomUUID().toString(); + options.setAsyncReplayOccurrenceId(occurrence); + options.setAsyncReplayProgress(Map.of("root/Grant:0", 1)); + options.setAsyncReplayRegistryFingerprints(Map.of("root/Grant:0", "fingerprint")); + + user.addOfflineRewards(reward, options.getPlaceholders(), options); + + assertEquals(1, persisted.size()); + assertTrue(persisted.get(0).contains("%asyncoccurrence%" + occurrence)); + assertTrue(persisted.get(0).contains("%asyncprogress%v3-")); + assertTrue(persisted.get(0).contains("Server%pair%server-a")); + } + + @Test + void replayClaimCleanupRechecksSharedStateUnderItsLock() throws Exception { + java.lang.reflect.Method replayClaims = AdvancedCoreUser.class.getDeclaredMethod("replayClaims"); + replayClaims.setAccessible(true); + Object claims = replayClaims.invoke(user); + java.lang.reflect.Field lockField = AdvancedCoreUser.class.getDeclaredField("REPLAY_CLAIMS_LOCK"); + lockField.setAccessible(true); + Object lock = lockField.get(null); + java.lang.reflect.Field offlineField = claims.getClass().getDeclaredField("offline"); + offlineField.setAccessible(true); + @SuppressWarnings("unchecked") + Map offline = (Map) offlineField.get(claims); + AtomicReference failure = new AtomicReference<>(); + Thread cleanup; + synchronized (lock) { + cleanup = new Thread(() -> { + try { + java.lang.reflect.Method release = AdvancedCoreUser.class + .getDeclaredMethod("releaseReplayClaimsIfEmpty", claims.getClass()); + release.setAccessible(true); + release.invoke(user, claims); + } catch (Throwable error) { + failure.set(error); + } + }, "replay-claim-cleanup-test"); + cleanup.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (cleanup.getState() != Thread.State.BLOCKED && System.nanoTime() < deadline) { + Thread.yield(); + } + assertEquals(Thread.State.BLOCKED, cleanup.getState()); + offline.put("new-claim", 1); + } + cleanup.join(TimeUnit.SECONDS.toMillis(5)); + assertFalse(cleanup.isAlive()); + assertTrue(failure.get() == null, () -> "cleanup failed: " + failure.get()); + assertSame(claims, replayClaims.invoke(user)); + offline.clear(); + java.lang.reflect.Method release = AdvancedCoreUser.class + .getDeclaredMethod("releaseReplayClaimsIfEmpty", claims.getClass()); + release.setAccessible(true); + release.invoke(user, claims); + } + @Test void setOfflineRewards_emptyList() { ArrayList rewards = new ArrayList<>(); From 85ca4e43141451857efb11b5ecfa51e20767dd66 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:32:17 -0600 Subject: [PATCH 12/78] Make legacy reward actions replay-safe --- .../advancedcore/api/rewards/Reward.java | 31 ++- .../api/user/AdvancedCoreUser.java | 219 ++++++++++++------ .../rewards/RewardAsyncInjectionTest.java | 139 +++++++++-- 3 files changed, 306 insertions(+), 83 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 45869c62a6..f9d8b289de 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 @@ -52,6 +52,7 @@ public class Reward { private static final ThreadLocal ACTIVE_REPLAY_OCCURRENCE_ID = new ThreadLocal<>(); private static final String REPLAY_SELECTION_PREFIX = "__advancedcore_replay_selection_"; private static final String REPLAY_COMMAND_PREFIX = "__advancedcore_replay_commands_"; + private static final String REPLAY_LEGACY_ACTION_PREFIX = "__advancedcore_replay_legacy_actions_"; @Getter @Setter @@ -630,7 +631,27 @@ public static void mergeReplayMetadata(HashMap target, Map placeholders, + String checkpointKey) { + String value = placeholders == null ? null : placeholders.get(checkpointKey); + if (value == null && replayState != null) value = replayState.replayMetadata(checkpointKey); + try { + return Math.max(0, value == null ? 0 : Integer.parseInt(value)); + } catch (NumberFormatException ignored) { + return 0; + } } /** Attaches a captured replay state to an option object for a deferred child. */ @@ -708,6 +729,7 @@ public synchronized void recordReplayMetadata(String key, String value) { public synchronized void mergeReplayMetadataInto(HashMap target) { Reward.mergeReplayMetadata(target, replayMetadata); } + public synchronized String replayMetadata(String key) { return replayMetadata.get(key); } private synchronized boolean hasPersistedCheckpoint() { return legacyCheckpoint || !completed.isEmpty() || !registryFingerprints.isEmpty(); } @@ -737,7 +759,7 @@ private synchronized int highestCompletedCount() { private synchronized void setCheckpointConsumer(Consumer consumer) { checkpointConsumer = consumer; } - private CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, + public CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, HashMap placeholders) { Consumer consumer; synchronized (this) { consumer = checkpointConsumer; } @@ -761,6 +783,7 @@ private CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, // instead of waiting forever without advancing its checkpoint. return persisted.orTimeout(30, TimeUnit.SECONDS); } + } /** Immutable durable replay data emitted after every completed injection. */ @@ -868,7 +891,8 @@ 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(); + AdvancedCoreUser.AsyncActionCollection actionCollection = user.beginAsyncActionCollection(replayState, placeholders, + injectionKey); CompletionStage result = CompletableFuture.failedFuture( new IllegalStateException("Reward injection did not produce a result")); try { @@ -896,7 +920,6 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan } CompletableFuture combined = new CompletableFuture<>(); result.whenComplete((value, failure) -> { - user.claimAsyncContinuationActions(actionCollection); user.endAsyncActionCollection(actionCollection).whenComplete((ignored, actionFailure) -> { if (failure != null) combined.completeExceptionally(failure); else if (actionFailure != null) combined.completeExceptionally(actionFailure); 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 7c4cd4ea3f..f406f91766 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 @@ -8,14 +8,11 @@ import java.time.ZoneId; import java.util.ArrayList; import java.util.Base64; -import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; -import java.util.IdentityHashMap; import java.util.Map; import java.util.Map.Entry; -import java.util.Set; import java.util.UUID; import java.util.WeakHashMap; import java.util.concurrent.TimeUnit; @@ -24,6 +21,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.TimeoutException; +import java.util.function.Function; import java.util.function.Supplier; import org.bukkit.Bukkit; @@ -74,47 +72,141 @@ public class AdvancedCoreUser { private static final Object REPLAY_CLAIMS_LOCK = new Object(); private static final WeakHashMap> REPLAY_CLAIMS = new WeakHashMap<>(); private static final ThreadLocal ASYNC_ACTION_COLLECTION = new ThreadLocal<>(); - private static final ThreadLocal>> CONTINUATION_ASYNC_ACTIONS = - ThreadLocal.withInitial(ArrayList::new); - private final Set activeAsyncActionCollections = Collections - .newSetFromMap(new IdentityHashMap<>()); /** Internal completion scope used by asynchronous reward dispatch. */ public static final class AsyncActionCollection { private final AsyncActionCollection previous; - private final ArrayList> actions = new ArrayList<>(); + private final AdvancedCoreUser owner; + private final ArrayList>> actions = new ArrayList<>(); + private final Reward.ReplayState replayState; + private final HashMap placeholders; + private final String checkpointKey; + private final int completedActions; + private final AdvancedCorePlugin plugin; private boolean closed; - private AsyncActionCollection(AsyncActionCollection previous) { + private AsyncActionCollection(AsyncActionCollection previous, AdvancedCoreUser owner, Reward.ReplayState replayState, + HashMap placeholders, String injectionKey, AdvancedCorePlugin plugin) { this.previous = previous; + this.owner = owner; + this.replayState = replayState; + this.placeholders = placeholders; + this.checkpointKey = Reward.legacyActionReplayKey(injectionKey); + this.completedActions = Reward.completedLegacyActions(replayState, placeholders, checkpointKey); + this.plugin = plugin; } - private synchronized boolean add(CompletionStage action) { + private synchronized boolean add(Supplier> action) { if (closed) return false; actions.add(action); return true; } + private boolean belongsTo(AdvancedCoreUser user) { + return owner == user; + } + private synchronized CompletionStage closeAndAwait() { closed = true; CompletionStage result = CompletableFuture.completedFuture(null); - for (CompletionStage action : actions) { - result = result.thenCompose(ignored -> action); + for (int index = 0; index < actions.size(); index++) { + if (index < completedActions) continue; + Supplier> action = actions.get(index); + int checkpoint = index + 1; + result = result.thenCompose(ignored -> { + try { + CompletionStage stage = action.get(); + return stage == null ? CompletableFuture.failedFuture( + new IllegalStateException("Scheduled reward action returned null")) : stage; + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + }).thenCompose(ignored -> checkpoint(checkpoint)); } return result; } + + private CompletionStage checkpoint(int completed) { + if (replayState == null || placeholders == null) return CompletableFuture.completedFuture(null); + String value = String.valueOf(completed); + placeholders.put(checkpointKey, value); + replayState.recordReplayMetadata(checkpointKey, value); + return replayState.persistCheckpointAsync(plugin, placeholders); + } + } + + /** + * Explicitly carries an async injector's originating action scope into a + * completion callback. Injectors capture this while their request method is + * invoked, then wrap only callbacks whose legacy user actions belong to that + * request. It deliberately does not infer ownership from other pending work + * on the callback thread. + */ + public static final class AsyncActionContext { + private final AsyncActionCollection collection; + + private AsyncActionContext(AsyncActionCollection collection) { + this.collection = collection; + } + + /** Wraps a CompletionStage callback that has no return value. */ + public Runnable wrap(Runnable callback) { + if (callback == null) throw new IllegalArgumentException("callback cannot be null"); + return () -> runInScope(() -> { + callback.run(); + return null; + }); + } + + /** Wraps a CompletionStage mapping callback while preserving its result. */ + public Function wrap(Function callback) { + if (callback == null) throw new IllegalArgumentException("callback cannot be null"); + return value -> runInScope(() -> callback.apply(value)); + } + + /** Wraps a deferred supplier used by an asynchronous injector. */ + public Supplier wrap(Supplier callback) { + if (callback == null) throw new IllegalArgumentException("callback cannot be null"); + return () -> runInScope(callback); + } + + private T runInScope(Supplier callback) { + if (collection == null) return callback.get(); + AsyncActionCollection previous = ASYNC_ACTION_COLLECTION.get(); + ASYNC_ACTION_COLLECTION.set(collection); + try { + return callback.get(); + } finally { + if (previous == null) ASYNC_ACTION_COLLECTION.remove(); + else ASYNC_ACTION_COLLECTION.set(previous); + } + } } /** Starts collecting completion stages created by legacy reward callbacks. */ public AsyncActionCollection beginAsyncActionCollection() { - AsyncActionCollection collection = new AsyncActionCollection(ASYNC_ACTION_COLLECTION.get()); - synchronized (activeAsyncActionCollections) { - activeAsyncActionCollections.add(collection); - } + return beginAsyncActionCollection(null, null, null); + } + + /** Starts a collection whose legacy actions advance the current replay checkpoint individually. */ + public AsyncActionCollection beginAsyncActionCollection(Reward.ReplayState replayState, + HashMap placeholders, String injectionKey) { + AsyncActionCollection collection = new AsyncActionCollection(ASYNC_ACTION_COLLECTION.get(), this, replayState, + placeholders, injectionKey, plugin); ASYNC_ACTION_COLLECTION.set(collection); return collection; } + /** + * Captures this user's active injector scope for an explicitly wrapped async + * continuation. Calling this outside the originating injector returns an + * inert context, so unrelated work keeps its ordinary scheduling semantics. + */ + public AsyncActionContext captureAsyncActionContext() { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + return new AsyncActionContext(collection != null && collection.belongsTo(this) ? collection : null); + } + /** Restores the calling thread's previous collection while keeping this scope active for its returned stage. */ public void restoreAsyncActionCollectionScope(AsyncActionCollection collection) { if (collection == null || ASYNC_ACTION_COLLECTION.get() != collection) return; @@ -126,35 +218,31 @@ public void restoreAsyncActionCollectionScope(AsyncActionCollection collection) public CompletionStage endAsyncActionCollection(AsyncActionCollection collection) { if (collection == null) return CompletableFuture.completedFuture(null); restoreAsyncActionCollectionScope(collection); - synchronized (activeAsyncActionCollections) { - activeAsyncActionCollections.remove(collection); - return collection.closeAndAwait(); - } + return collection.closeAndAwait(); } private boolean collectAsyncAction(CompletionStage action) { - AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); - if (collection != null && collection.add(action)) return true; - synchronized (activeAsyncActionCollections) { - if (activeAsyncActionCollections.isEmpty()) return false; - // A CompletionStage continuation runs before completion callbacks attached - // to its returned stage. Hold its legacy action on that same thread until - // requestInjectionAsync's callback claims it for the exact collection. - CONTINUATION_ASYNC_ACTIONS.get().add(action); - return true; - } + return collectAsyncAction(() -> action); } - /** Claims legacy actions created by the continuation that just completed on this thread. */ - public void claimAsyncContinuationActions(AsyncActionCollection collection) { - if (collection == null) return; - ArrayList> pending = CONTINUATION_ASYNC_ACTIONS.get(); - if (pending.isEmpty()) return; - for (CompletionStage action : pending) collection.add(action); - pending.clear(); - CONTINUATION_ASYNC_ACTIONS.remove(); + private boolean collectAsyncAction(Supplier> action) { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + if (collection != null && collection.belongsTo(this) && collection.add(action)) return true; + // Do not infer ownership from another pending collection. A normal synchronous + // reward can run while an unrelated async injection is waiting; it must retain + // its established fire-and-forget scheduling semantics instead of being made a + // dependency of whichever injection completes next on this thread. + return false; } + /** + * Retained for binary compatibility with integrations that previously called + * this internal hand-off hook. Unscoped actions are intentionally not claimed: + * only actions created while their originating collection is active may become + * part of that collection. + */ + public void claimAsyncContinuationActions(AsyncActionCollection collection) { } + private void collectAsyncFailure(Throwable failure) { collectAsyncAction(CompletableFuture.failedFuture(failure)); } @@ -1407,38 +1495,39 @@ public void givePotionEffect(String potionName, int duration, int amplifier) { /** Queues a legacy Bukkit action while exposing a nonblocking completion internally. */ private void scheduleLegacyRewardAction(Runnable action, Player player, boolean playerAware) { - CompletableFuture completion = new CompletableFuture<>(); - if (!collectAsyncAction(completion)) { - if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, action, player); - else getPlugin().getBukkitScheduler().runTask(plugin, action); - return; - } - AtomicBoolean claimed = new AtomicBoolean(); - Runnable dispatch = () -> { - if (!claimed.compareAndSet(false, true)) return; - if (!plugin.isEnabled()) { - completion.completeExceptionally(new IllegalStateException("Plugin disabled before scheduled reward action ran")); - return; - } + if (!collectAsyncAction(() -> { + CompletableFuture completion = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); + Runnable dispatch = () -> { + if (!claimed.compareAndSet(false, true)) return; + if (!plugin.isEnabled()) { + completion.completeExceptionally(new IllegalStateException("Plugin disabled before scheduled reward action ran")); + return; + } + try { + action.run(); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }; try { - action.run(); - completion.complete(null); + if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); + else getPlugin().getBukkitScheduler().runTask(plugin, dispatch); } catch (Throwable failure) { + claimed.set(true); completion.completeExceptionally(failure); } - }; - try { - if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); - else getPlugin().getBukkitScheduler().runTask(plugin, dispatch); - } catch (Throwable failure) { - claimed.set(true); - completion.completeExceptionally(failure); + CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { + if (claimed.compareAndSet(false, true)) { + completion.completeExceptionally(new TimeoutException("Timed out waiting for scheduled reward action")); + } + }); + return completion; + })) { + if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, action, player); + else getPlugin().getBukkitScheduler().runTask(plugin, action); } - CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { - if (claimed.compareAndSet(false, true)) { - completion.completeExceptionally(new TimeoutException("Timed out waiting for scheduled reward action")); - } - }); } /** 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 8fc7ccc49f..265891426c 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 @@ -160,8 +160,10 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); assertFalse(result.toCompletableFuture().isDone()); - assertEquals(2, queued.size()); - for (Runnable action : queued) action.run(); + assertEquals(1, queued.size()); + queued.remove(0).run(); + assertEquals(1, queued.size()); + queued.remove(0).run(); result.toCompletableFuture().join(); } verify(economy).depositPlayer(offlinePlayer, 2); @@ -196,6 +198,79 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, } } + @Test + void capturedAsyncContinuationActionCheckpointsRetryOnlyTheUnfinishedSuffix() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Checkpointed"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + CompletableFuture continuation = new CompletableFuture<>(); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(1); + realUser.giveMoney(2); + return null; + } + @Override public boolean supportsAsyncRequest() { return true; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + AdvancedCoreUser.AsyncActionContext context = ignoredUser.captureAsyncActionContext(); + return continuation.thenApply(context.wrap(nothing -> { + realUser.giveMoney(1); + realUser.giveMoney(2); + return null; + })); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage first = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + continuation.complete(null); + assertEquals(1, queued.size()); + queued.remove(0).run(); + assertEquals(1, queued.size()); + enabled.set(false); + queued.remove(0).run(); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> first.toCompletableFuture().join()); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertTrue(checkpoint.getReplayPlaceholders().entrySet().stream().anyMatch(entry -> + entry.getKey().startsWith("__advancedcore_replay_legacy_actions_") && entry.getValue().equals("1"))); + + enabled.set(true); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertEquals(1, queued.size(), "the completed first action is never scheduled again"); + queued.remove(0).run(); + resumed.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 1); + verify(economy).depositPlayer(offlinePlayer, 2); + } + @Test void legacyQueuedActionFailsWhenPluginShutsDownBeforeExecution() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); @@ -231,7 +306,7 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, } @Test - void legacyActionsCreatedByAnAsyncContinuationAreAwaited() { + void legacyActionsCapturedByAsyncContextAreAwaited() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); when(config.isOnlineMode()).thenReturn(true); when(plugin.getOptions()).thenReturn(config); @@ -254,10 +329,11 @@ void legacyActionsCreatedByAnAsyncContinuationAreAwaited() { ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { - return continuation.thenApply(nothing -> { + AdvancedCoreUser.AsyncActionContext context = ignoredUser.captureAsyncActionContext(); + return continuation.thenApply(context.wrap(nothing -> { realUser.giveMoney(2); return null; - }); + })); } }); @@ -276,7 +352,7 @@ void legacyActionsCreatedByAnAsyncContinuationAreAwaited() { } @Test - void serializedPersistedReplaysWaitForThePriorContinuationAndLegacyAction() throws Exception { + void serializedPersistedReplaysDoNotClaimUnscopedContinuationActions() throws Exception { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); when(config.isOnlineMode()).thenReturn(true); when(plugin.getOptions()).thenReturn(config); @@ -325,10 +401,9 @@ void serializedPersistedReplaysWaitForThePriorContinuationAndLegacyAction() thro continuations.get(0).complete(null); assertEquals(1, queuedActions.size()); - assertEquals(1, invocations.get(), "the first legacy action is part of the replay tail"); + assertEquals(2, invocations.get(), "the second replay starts after the first async stage completes"); queuedActions.get(0).run(); - assertEquals(2, invocations.get(), "the second replay starts only after the first action completed"); continuations.get(1).complete(null); assertEquals(2, queuedActions.size()); queuedActions.get(1).run(); @@ -338,7 +413,7 @@ void serializedPersistedReplaysWaitForThePriorContinuationAndLegacyAction() thro } @Test - void legacyContinuationActionIsClaimedOnlyByItsCompletingScope() { + void unscopedActionsAreNotClaimedByAnArbitraryCollection() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); when(config.isOnlineMode()).thenReturn(true); when(plugin.getOptions()).thenReturn(config); @@ -367,7 +442,7 @@ void legacyContinuationActionIsClaimedOnlyByItsCompletingScope() { CompletionStage innerCompletion = realUser.endAsyncActionCollection(inner); assertEquals(1, queuedActions.size()); assertTrue(outerCompletion.toCompletableFuture().isDone()); - assertFalse(innerCompletion.toCompletableFuture().isDone()); + assertTrue(innerCompletion.toCompletableFuture().isDone()); queuedActions.get(0).run(); outerCompletion.toCompletableFuture().join(); innerCompletion.toCompletableFuture().join(); @@ -375,7 +450,43 @@ void legacyContinuationActionIsClaimedOnlyByItsCompletingScope() { } @Test - void failedLegacyActionCreatedByAnAsyncContinuationFailsTheReward() { + void synchronousRewardActionIsNotClaimedByPendingAsyncInjection() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + when(vault.getEcon()).thenReturn(mock(Economy.class)); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Independent"); + ArrayList queuedActions = new ArrayList<>(); + doAnswer(invocation -> { + queuedActions.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + CompletableFuture pending = new CompletableFuture<>(); + handler.getInjectedRewards().add(new RewardInject("Async") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return pending; } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + CompletionStage result = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + realUser.giveMoney(4); + assertEquals(1, queuedActions.size()); + pending.complete(null); + assertTrue(result.toCompletableFuture().isDone(), + "an unrelated queued action must not become this async reward's dependency"); + } + } + + @Test + void failedLegacyActionCreatedByAnAsyncContinuationDoesNotFailAnUnrelatedReward() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); when(config.isOnlineMode()).thenReturn(true); when(plugin.getOptions()).thenReturn(config); @@ -410,12 +521,12 @@ void failedLegacyActionCreatedByAnAsyncContinuationFailsTheReward() { continuation.complete(null); enabled.set(false); queued.get().run(); - assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + result.toCompletableFuture().join(); } } @Test - void concurrentContinuationActionsRemainBoundToTheirOwnReward() { + void concurrentContinuationsDoNotClaimOneAnotherActions() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); when(config.isOnlineMode()).thenReturn(true); when(plugin.getOptions()).thenReturn(config); @@ -457,7 +568,7 @@ void concurrentContinuationActionsRemainBoundToTheirOwnReward() { CompletionStage second = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); continuations.get(0).complete(null); - assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join()); + first.toCompletableFuture().join(); assertFalse(second.toCompletableFuture().isDone()); continuations.get(1).complete(null); From 3b5786155e311088fea3a6a74b430bdc7dbfd188 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:13:01 -0600 Subject: [PATCH 13/78] Make legacy item rewards replay-safe --- .../api/item/FullInventoryHandler.java | 57 +++- .../advancedcore/api/rewards/Reward.java | 5 + .../api/user/AdvancedCoreUser.java | 297 ++++++++++++++++-- .../tests/item/FullInventoryHandlerTest.java | 54 +++- .../rewards/RewardAsyncInjectionTest.java | 162 +++++++++- 5 files changed, 535 insertions(+), 40 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java index 448e0b14f7..afc0567197 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java @@ -6,10 +6,14 @@ import java.util.Map.Entry; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.logging.Level; @@ -82,11 +86,62 @@ public void check(Player player) { } public void giveItem(Player player, ItemStack... item) { + scheduleItemDelivery(player, item, null); + } + + /** + * Gives items on the owning player scheduler and completes after the inventory + * mutation, including full-inventory handling, has finished. The established + * void API remains fire-and-forget; replay-aware callers use this boundary so + * they never checkpoint a queued delivery as completed. + */ + public CompletionStage giveItemAsync(Player player, ItemStack... item) { + CompletableFuture completion = new CompletableFuture<>(); + scheduleItemDelivery(player, item, completion); + return completion; + } + + private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFuture completion) { if (player == null || item == null || item.length == 0) { + if (completion != null) completion.complete(null); return; } ItemStack[] itemsToGive = item.clone(); - plugin.getBukkitScheduler().runTask(plugin, () -> giveItemOwnedPlayer(player, itemsToGive), player); + AtomicBoolean deliveryClaimed = new AtomicBoolean(); + Runnable delivery = () -> { + if (!deliveryClaimed.compareAndSet(false, true)) return; + try { + giveItemOwnedPlayer(player, itemsToGive); + if (completion != null) completion.complete(null); + } catch (Throwable failure) { + if (completion != null) completion.completeExceptionally(failure); + else rethrowDeliveryFailure(failure); + } + }; + try { + plugin.getBukkitScheduler().runTask(plugin, delivery, player); + } catch (Throwable failure) { + deliveryClaimed.set(true); + if (completion != null) completion.completeExceptionally(failure); + else rethrowDeliveryFailure(failure); + } + if (completion == null) return; + CompletableFuture.delayedExecutor(getItemDeliveryTimeoutMillis(), TimeUnit.MILLISECONDS).execute(() -> { + if (deliveryClaimed.compareAndSet(false, true)) { + completion.completeExceptionally(new TimeoutException("Timed out waiting for item delivery")); + } + }); + } + + /** Bounds a replay-aware delivery; timeout claims the delivery to prevent a duplicate retry. */ + protected long getItemDeliveryTimeoutMillis() { + return TimeUnit.SECONDS.toMillis(30); + } + + private static void rethrowDeliveryFailure(Throwable failure) { + if (failure instanceof RuntimeException) throw (RuntimeException) failure; + if (failure instanceof Error) throw (Error) failure; + throw new IllegalStateException("Failed to schedule item delivery", failure); } public synchronized void loadTimer() { 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 f9d8b289de..a19363481e 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 @@ -642,6 +642,11 @@ public static String legacyActionReplayKey(String injectionKey) { return REPLAY_LEGACY_ACTION_PREFIX + encoded; } + /** Stable fingerprint for an already-resolved legacy reward action payload. */ + public static String legacyActionFingerprint(String descriptor) { + return digest(descriptor == null ? "" : descriptor); + } + /** Reads the number of legacy actions that have a durable per-injection checkpoint. */ public static int completedLegacyActions(ReplayState replayState, HashMap placeholders, String checkpointKey) { 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 f406f91766..c75c9beece 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java @@ -1,5 +1,6 @@ package com.bencodez.advancedcore.api.user; +import java.lang.reflect.Array; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.time.Duration; @@ -32,6 +33,7 @@ import org.bukkit.Registry; import org.bukkit.Sound; import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.serialization.ConfigurationSerializable; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.metadata.MetadataValue; @@ -75,13 +77,17 @@ public class AdvancedCoreUser { /** Internal completion scope used by asynchronous reward dispatch. */ public static final class AsyncActionCollection { + private static final String ACTION_SNAPSHOT_SUFFIX = "_snapshot"; + private static final String ACTION_COMPLETION_VERSION = "v2:"; + private static final String ACTION_SNAPSHOT_VERSION = "v1:"; private final AsyncActionCollection previous; private final AdvancedCoreUser owner; - private final ArrayList>> actions = new ArrayList<>(); + private final ArrayList actions = new ArrayList<>(); + private final HashMap actionOccurrences = new HashMap<>(); private final Reward.ReplayState replayState; private final HashMap placeholders; private final String checkpointKey; - private final int completedActions; + private final String snapshotKey; private final AdvancedCorePlugin plugin; private boolean closed; @@ -92,13 +98,16 @@ private AsyncActionCollection(AsyncActionCollection previous, AdvancedCoreUser o this.replayState = replayState; this.placeholders = placeholders; this.checkpointKey = Reward.legacyActionReplayKey(injectionKey); - this.completedActions = Reward.completedLegacyActions(replayState, placeholders, checkpointKey); + this.snapshotKey = checkpointKey + ACTION_SNAPSHOT_SUFFIX; this.plugin = plugin; } - private synchronized boolean add(Supplier> action) { + private synchronized boolean add(Supplier> action, String descriptor) { if (closed) return false; - actions.add(action); + String fingerprint = Reward.legacyActionFingerprint(descriptor); + int occurrence = actionOccurrences.getOrDefault(fingerprint, 0); + actionOccurrences.put(fingerprint, occurrence + 1); + actions.add(new ReplayAction(fingerprint + "/" + occurrence, fingerprint, action)); return true; } @@ -108,31 +117,139 @@ private boolean belongsTo(AdvancedCoreUser user) { private synchronized CompletionStage closeAndAwait() { closed = true; + HashMap currentSnapshot = new HashMap<>(); + for (ReplayAction action : actions) currentSnapshot.put(action.identity, action.fingerprint); + HashMap persistedSnapshot; + HashSet completed; + try { + persistedSnapshot = snapshot(readReplayValue(snapshotKey)); + completed = completed(readReplayValue(checkpointKey)); + for (String identity : completed) { + if (!persistedSnapshot.containsKey(identity)) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Legacy action checkpoint does not match its persisted action snapshot")); + } + } + } catch (IllegalArgumentException failure) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Cannot safely resume legacy reward actions from an ordinal-only or malformed checkpoint", failure)); + } + boolean snapshotChanged = persistedSnapshot.isEmpty(); + if (snapshotChanged) persistedSnapshot = new HashMap<>(currentSnapshot); + else if (!persistedSnapshot.equals(currentSnapshot)) { + // The actions are deliberately matched individually below. Retaining the + // original snapshot lets a reordered, shortened, or extended config skip + // only the exact effects known to have completed. New actions are added + // before they can receive a completion marker. + persistedSnapshot.putAll(currentSnapshot); + snapshotChanged = true; + } CompletionStage result = CompletableFuture.completedFuture(null); - for (int index = 0; index < actions.size(); index++) { - if (index < completedActions) continue; - Supplier> action = actions.get(index); - int checkpoint = index + 1; + if (snapshotChanged) { + recordReplayValue(snapshotKey, encodeSnapshot(persistedSnapshot)); + result = checkpoint(); + } + for (ReplayAction action : actions) { + if (completed.contains(action.identity)) continue; result = result.thenCompose(ignored -> { try { - CompletionStage stage = action.get(); + CompletionStage stage = action.action.get(); return stage == null ? CompletableFuture.failedFuture( new IllegalStateException("Scheduled reward action returned null")) : stage; } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } - }).thenCompose(ignored -> checkpoint(checkpoint)); + }).thenCompose(ignored -> { + completed.add(action.identity); + recordReplayValue(checkpointKey, encodeCompleted(completed)); + return checkpoint(); + }); } return result; } - private CompletionStage checkpoint(int completed) { + private String readReplayValue(String key) { + String value = placeholders == null ? null : placeholders.get(key); + return value == null && replayState != null ? replayState.replayMetadata(key) : value; + } + + private void recordReplayValue(String key, String value) { + if (placeholders != null) placeholders.put(key, value); + if (replayState != null) replayState.recordReplayMetadata(key, value); + } + + private CompletionStage checkpoint() { if (replayState == null || placeholders == null) return CompletableFuture.completedFuture(null); - String value = String.valueOf(completed); - placeholders.put(checkpointKey, value); - replayState.recordReplayMetadata(checkpointKey, value); return replayState.persistCheckpointAsync(plugin, placeholders); } + + private static HashSet completed(String encoded) { + HashSet values = new HashSet<>(); + if (encoded == null || encoded.isEmpty()) return values; + if (!encoded.startsWith(ACTION_COMPLETION_VERSION)) { + throw new IllegalArgumentException("Unknown legacy action checkpoint version"); + } + for (String value : encoded.substring(ACTION_COMPLETION_VERSION.length()).split("\\.", -1)) { + if (value.isEmpty()) continue; + values.add(new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8)); + } + return values; + } + + private static String encodeCompleted(HashSet completed) { + ArrayList values = new ArrayList<>(completed); + java.util.Collections.sort(values); + StringBuilder encoded = new StringBuilder(ACTION_COMPLETION_VERSION); + for (String value : values) { + if (encoded.length() > ACTION_COMPLETION_VERSION.length()) encoded.append('.'); + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8))); + } + return encoded.toString(); + } + + private static HashMap snapshot(String encoded) { + HashMap values = new HashMap<>(); + if (encoded == null || encoded.isEmpty()) return values; + if (!encoded.startsWith(ACTION_SNAPSHOT_VERSION)) { + throw new IllegalArgumentException("Unknown legacy action snapshot version"); + } + for (String entry : encoded.substring(ACTION_SNAPSHOT_VERSION.length()).split("\\.", -1)) { + if (entry.isEmpty()) continue; + String[] pair = entry.split("~", 2); + if (pair.length != 2 || values.put(new String(Base64.getUrlDecoder().decode(pair[0]), StandardCharsets.UTF_8), + new String(Base64.getUrlDecoder().decode(pair[1]), StandardCharsets.UTF_8)) != null) { + throw new IllegalArgumentException("Malformed legacy action snapshot"); + } + } + return values; + } + + private static String encodeSnapshot(HashMap snapshot) { + ArrayList identities = new ArrayList<>(snapshot.keySet()); + java.util.Collections.sort(identities); + StringBuilder encoded = new StringBuilder(ACTION_SNAPSHOT_VERSION); + for (String identity : identities) { + if (encoded.length() > ACTION_SNAPSHOT_VERSION.length()) encoded.append('.'); + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(identity.getBytes(StandardCharsets.UTF_8))).append('~') + .append(Base64.getUrlEncoder().withoutPadding().encodeToString( + snapshot.get(identity).getBytes(StandardCharsets.UTF_8))); + } + return encoded.toString(); + } + + private static final class ReplayAction { + private final String identity; + private final String fingerprint; + private final Supplier> action; + + private ReplayAction(String identity, String fingerprint, Supplier> action) { + this.identity = identity; + this.fingerprint = fingerprint; + this.action = action; + } + } } /** @@ -222,12 +339,12 @@ public CompletionStage endAsyncActionCollection(AsyncActionCollection coll } private boolean collectAsyncAction(CompletionStage action) { - return collectAsyncAction(() -> action); + return collectAsyncAction(() -> action, "failure:" + action.getClass().getName()); } - private boolean collectAsyncAction(Supplier> action) { + private boolean collectAsyncAction(Supplier> action, String descriptor) { AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); - if (collection != null && collection.belongsTo(this) && collection.add(action)) return true; + if (collection != null && collection.belongsTo(this) && collection.add(action, descriptor)) return true; // Do not infer ownership from another pending collection. A normal synchronous // reward can run while an unrelated async injection is waiting; it must retain // its established fire-and-forget scheduling semantics instead of being made a @@ -1392,9 +1509,7 @@ public void giveItem(ItemStack item) { final Player player = getPlayer(); if (plugin.isEnabled()) { - scheduleLegacyRewardAction(() -> { - if (player != null) plugin.getFullInventoryHandler().giveItem(player, item); - }, player, true); + scheduleLegacyItemAction(player, item); } else { collectAsyncFailure(new IllegalStateException("Plugin disabled before item reward was scheduled")); } @@ -1424,9 +1539,7 @@ public void giveItems(ItemStack... item) { final Player player = getPlayer(); if (plugin.isEnabled()) { - scheduleLegacyRewardAction(() -> { - if (player != null) plugin.getFullInventoryHandler().giveItem(player, item); - }, player, true); + scheduleLegacyItemAction(player, item); } else { collectAsyncFailure(new IllegalStateException("Plugin disabled before item reward was scheduled")); } @@ -1448,13 +1561,15 @@ public void giveMoney(double m) { if (m > 0) { final double money = m; scheduleLegacyRewardAction( - () -> plugin.getVaultHandler().getEcon().depositPlayer(getOfflinePlayer(), money), null, false); + () -> plugin.getVaultHandler().getEcon().depositPlayer(getOfflinePlayer(), money), null, false, + "money:deposit:" + Double.toString(money)); } else if (m < 0) { m = m * -1; final double money = m; scheduleLegacyRewardAction( - () -> plugin.getVaultHandler().getEcon().withdrawPlayer(getOfflinePlayer(), money), null, false); + () -> plugin.getVaultHandler().getEcon().withdrawPlayer(getOfflinePlayer(), money), null, false, + "money:withdraw:" + Double.toString(money)); } } catch ( @@ -1486,7 +1601,8 @@ public void givePotionEffect(String potionName, int duration, int amplifier) { Player player = getPlayer(); if (player != null && plugin.isEnabled()) { scheduleLegacyRewardAction(() -> player.addPotionEffect( - new PotionEffect(PotionEffectType.getByName(potionName), 20 * duration, amplifier)), player, true); + new PotionEffect(PotionEffectType.getByName(potionName), 20 * duration, amplifier)), player, true, + "potion:" + potionName + ":" + duration + ":" + amplifier); } else if (player != null && ASYNC_ACTION_COLLECTION.get() != null) { collectAsyncFailure(new IllegalStateException( "Potion reward could not be scheduled because the plugin is unavailable")); @@ -1494,7 +1610,105 @@ public void givePotionEffect(String potionName, int duration, int amplifier) { } /** Queues a legacy Bukkit action while exposing a nonblocking completion internally. */ - private void scheduleLegacyRewardAction(Runnable action, Player player, boolean playerAware) { + private void scheduleLegacyItemAction(Player player, ItemStack... item) { + StringBuilder descriptor = new StringBuilder("item"); + for (ItemStack current : item) { + descriptor.append('\n').append(itemDescriptor(current)); + } + if (collectAsyncAction(() -> player == null ? CompletableFuture.completedFuture(null) + : plugin.getFullInventoryHandler().giveItemAsync(player, item), descriptor.toString())) { + return; + } + // Preserve ordinary fire-and-forget behavior without adding an ignored async + // timeout or a second scheduler boundary. Replay scopes above await the + // inventory handler's actual owner-task completion directly. + plugin.getFullInventoryHandler().giveItem(player, item); + } + + private static String itemDescriptor(ItemStack item) { + if (item == null) return "null"; + try { + return canonicalActionDescriptor(item.serialize()); + } catch (Throwable ignored) { + // Unit-test and early-bootstrap environments can lack Bukkit's unsafe + // serializer. Retain every independently accessible item property instead + // of collapsing different metadata to an unstable display string. + HashMap fallback = new HashMap<>(); + try { + fallback.put("type", item.getType()); + } catch (Throwable ignoredAgain) { } + try { + fallback.put("amount", item.getAmount()); + } catch (Throwable ignoredAgain) { } + try { + fallback.put("durability", item.getDurability()); + } catch (Throwable ignoredAgain) { } + try { + fallback.put("meta", item.getItemMeta()); + } catch (Throwable ignoredAgain) { } + fallback.put("class", item.getClass().getName()); + return canonicalActionDescriptor(fallback); + } + } + + /** Deterministic, delimiter-safe encoding for replayed action payloads. */ + private static String canonicalActionDescriptor(Object value) { + if (value == null) return encodedActionPart("null", ""); + if (value instanceof CharSequence || value instanceof Character || value instanceof Boolean + || value instanceof Number || value instanceof Enum) { + return encodedActionPart(value.getClass().getName(), value instanceof Enum + ? ((Enum) value).name() : String.valueOf(value)); + } + if (value instanceof ConfigurationSerializable) { + ConfigurationSerializable serializable = (ConfigurationSerializable) value; + return encodedActionPart("serializable-class", value.getClass().getName()) + + canonicalActionDescriptor(serializable.serialize()); + } + if (value instanceof Map) { + ArrayList entries = new ArrayList<>(); + for (Entry entry : ((Map) value).entrySet()) { + entries.add(canonicalActionDescriptor(entry.getKey()) + canonicalActionDescriptor(entry.getValue())); + } + java.util.Collections.sort(entries); + return encodedActionParts("map", entries); + } + if (value instanceof Iterable) { + ArrayList entries = new ArrayList<>(); + for (Object entry : (Iterable) value) entries.add(canonicalActionDescriptor(entry)); + return encodedActionParts("list", entries); + } + if (value.getClass().isArray()) { + ArrayList entries = new ArrayList<>(); + for (int index = 0; index < Array.getLength(value); index++) { + entries.add(canonicalActionDescriptor(Array.get(value, index))); + } + return encodedActionParts("array:" + value.getClass().getComponentType().getName(), entries); + } + return encodedActionPart("object-class", value.getClass().getName()); + } + + private static String encodedActionParts(String type, Iterable values) { + StringBuilder encoded = new StringBuilder(encodedActionPart("collection", type)); + for (String value : values) encoded.append(encodedActionPart("entry", value)); + return encoded.toString(); + } + + private static String encodedActionPart(String type, String value) { + String encodedType = Base64.getUrlEncoder().withoutPadding().encodeToString(type.getBytes(StandardCharsets.UTF_8)); + String encodedValue = Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + return encodedType.length() + ":" + encodedType + encodedValue.length() + ":" + encodedValue; + } + + /** Queues a legacy Bukkit action while exposing a nonblocking completion internally. */ + private void scheduleLegacyRewardAction(Runnable action, Player player, boolean playerAware, String descriptor) { + scheduleLegacyRewardActionAsync(() -> { + action.run(); + return CompletableFuture.completedFuture(null); + }, player, playerAware, descriptor); + } + + private void scheduleLegacyRewardActionAsync(Supplier> action, Player player, + boolean playerAware, String descriptor) { if (!collectAsyncAction(() -> { CompletableFuture completion = new CompletableFuture<>(); AtomicBoolean claimed = new AtomicBoolean(); @@ -1505,8 +1719,15 @@ private void scheduleLegacyRewardAction(Runnable action, Player player, boolean return; } try { - action.run(); - completion.complete(null); + CompletionStage stage = action.get(); + if (stage == null) { + completion.completeExceptionally(new IllegalStateException("Scheduled reward action returned null")); + return; + } + stage.whenComplete((ignored, failure) -> { + if (failure == null) completion.complete(null); + else completion.completeExceptionally(failure); + }); } catch (Throwable failure) { completion.completeExceptionally(failure); } @@ -1524,9 +1745,19 @@ private void scheduleLegacyRewardAction(Runnable action, Player player, boolean } }); return completion; - })) { - if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, action, player); - else getPlugin().getBukkitScheduler().runTask(plugin, action); + }, descriptor)) { + Runnable dispatch = () -> { + try { + CompletionStage stage = action.get(); + if (stage == null) throw new IllegalStateException("Scheduled reward action returned null"); + } catch (Throwable failure) { + if (failure instanceof RuntimeException) throw (RuntimeException) failure; + if (failure instanceof Error) throw (Error) failure; + throw new IllegalStateException("Failed to schedule legacy reward action", failure); + } + }; + if (playerAware) getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); + else getPlugin().getBukkitScheduler().runTask(plugin, dispatch); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java index 36a345335e..f3b069d9f1 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -19,6 +20,7 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -117,6 +119,47 @@ public void giveItemAlwaysUsesEntityScheduler() { verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(player)); } + @Test + public void giveItemAsyncCompletesOnlyAfterOwnedInventoryDelivery() { + Fixture fixture = createFixture(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>()); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + assertFalse(delivery.toCompletableFuture().isDone()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + + task.getValue().run(); + + assertTrue(delivery.toCompletableFuture().isDone()); + verify(inventory).addItem(item); + } + + @Test + public void timedOutGiveItemAsyncSuppressesTheQueuedDeliveryToPreventReplayDuplicates() throws Exception { + Fixture fixture = createFixture(0L); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>()); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + + task.getValue().run(); + task.getValue().run(); + + verify(inventory, never()).addItem(item); + } + @Test public void handlerTimerIsIsolatedFromPluginInventoryTimer() { Fixture fixture = createFixture(); @@ -214,6 +257,10 @@ public void saveWaitsForInflightDeliveryBeforeSnapshotting() throws Exception { } private Fixture createFixture() { + return createFixture(TimeUnit.SECONDS.toMillis(30)); + } + + private Fixture createFixture(long itemDeliveryTimeoutMillis) { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); ScheduledExecutorService sharedInventoryTimer = mock(ScheduledExecutorService.class); BukkitScheduler bukkitScheduler = mock(BukkitScheduler.class); @@ -225,7 +272,12 @@ private Fixture createFixture() { when(plugin.getServerDataFile()).thenReturn(serverData); when(serverData.getData()).thenReturn(data); - FullInventoryHandler handler = new FullInventoryHandler(plugin); + FullInventoryHandler handler = new FullInventoryHandler(plugin) { + @Override + protected long getItemDeliveryTimeoutMillis() { + return itemDeliveryTimeoutMillis; + } + }; handlers.add(handler); return new Fixture(plugin, sharedInventoryTimer, bukkitScheduler, serverData, data, handler); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index 265891426c..7d73539ec2 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 @@ -13,6 +13,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -21,6 +22,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -125,10 +127,12 @@ void legacyScheduledRewardsWaitForQueuedBukkitActions() { when(plugin.getVaultHandler()).thenReturn(vault); FullInventoryHandler inventory = mock(FullInventoryHandler.class); when(plugin.getFullInventoryHandler()).thenReturn(inventory); + CompletableFuture itemDelivery = new CompletableFuture<>(); UUID uuid = UUID.randomUUID(); AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); realUser.setPlayerName("Legacy"); Player player = mock(Player.class); + when(inventory.giveItemAsync(eq(player), any(ItemStack.class))).thenReturn(itemDelivery); OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); ArrayList queued = new ArrayList<>(); doAnswer(invocation -> { @@ -162,12 +166,34 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, assertFalse(result.toCompletableFuture().isDone()); assertEquals(1, queued.size()); queued.remove(0).run(); - assertEquals(1, queued.size()); - queued.remove(0).run(); + assertTrue(queued.isEmpty(), "replay hands the item directly to the awaited inventory task"); + assertFalse(result.toCompletableFuture().isDone(), "the inner item delivery still owns completion"); + itemDelivery.complete(null); result.toCompletableFuture().join(); } verify(economy).depositPlayer(offlinePlayer, 2); - verify(inventory).giveItem(eq(player), any(ItemStack.class)); + verify(inventory).giveItemAsync(eq(player), any(ItemStack.class)); + } + + @Test + void ordinaryItemGrantUsesTheVoidInventoryApiWithoutAnAsyncTimeout() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + Player player = mock(Player.class); + ItemStack item = new ItemStack(Material.STONE); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + realUser.giveItem(item); + } + + verify(inventory).giveItem(player, item); + verify(inventory, never()).giveItemAsync(any(Player.class), any(ItemStack.class)); } @Test @@ -250,7 +276,9 @@ void capturedAsyncContinuationActionCheckpointsRetryOnlyTheUnfinishedSuffix() th () -> first.toCompletableFuture().join()); Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); assertTrue(checkpoint.getReplayPlaceholders().entrySet().stream().anyMatch(entry -> - entry.getKey().startsWith("__advancedcore_replay_legacy_actions_") && entry.getValue().equals("1"))); + entry.getKey().startsWith("__advancedcore_replay_legacy_actions_") && entry.getValue().startsWith("v2:"))); + assertTrue(checkpoint.getReplayPlaceholders().keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_legacy_actions_") && key.endsWith("_snapshot"))); enabled.set(true); Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); @@ -271,6 +299,92 @@ void capturedAsyncContinuationActionCheckpointsRetryOnlyTheUnfinishedSuffix() th verify(economy).depositPlayer(offlinePlayer, 2); } + @Test + void legacyActionSnapshotsSurviveReorderRemovalAndExtension() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("ActionSnapshot"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + AtomicReference> amounts = new AtomicReference<>(new ArrayList<>(List.of(1D, 2D))); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + for (double amount : amounts.get()) realUser.giveMoney(amount); + return CompletableFuture.completedFuture(null); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage first = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + queued.remove(0).run(); + enabled.set(false); + queued.remove(0).run(); + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows( + java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join())); + + enabled.set(true); + amounts.set(new ArrayList<>(List.of(2D, 3D))); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertEquals(1, queued.size(), "the completed, removed action is not rescheduled"); + queued.remove(0).run(); + assertEquals(1, queued.size(), "the extended action runs after the unfinished stable action"); + queued.remove(0).run(); + resumed.toCompletableFuture().join(); + } + verify(economy).depositPlayer(offlinePlayer, 1D); + verify(economy).depositPlayer(offlinePlayer, 2D); + verify(economy).depositPlayer(offlinePlayer, 3D); + } + + @Test + void itemActionFingerprintIsCanonicalAndIncludesMetadata() throws Exception { + LinkedHashMap first = new LinkedHashMap<>(); + first.put("type", "DIAMOND"); + first.put("amount", 1); + first.put("meta", Map.of("display-name", "Original", "lore", List.of("one", "two"))); + LinkedHashMap reordered = new LinkedHashMap<>(); + reordered.put("meta", Map.of("lore", List.of("one", "two"), "display-name", "Original")); + reordered.put("amount", 1); + reordered.put("type", "DIAMOND"); + LinkedHashMap changedMetadata = new LinkedHashMap<>(reordered); + changedMetadata.put("meta", Map.of("display-name", "Changed", "lore", List.of("one", "two"))); + java.lang.reflect.Method descriptor = AdvancedCoreUser.class.getDeclaredMethod("canonicalActionDescriptor", + Object.class); + descriptor.setAccessible(true); + String firstDescriptor = (String) descriptor.invoke(null, first); + String reorderedDescriptor = (String) descriptor.invoke(null, reordered); + String changedDescriptor = (String) descriptor.invoke(null, changedMetadata); + + assertEquals(Reward.legacyActionFingerprint(firstDescriptor), Reward.legacyActionFingerprint(reorderedDescriptor)); + assertNotEquals(Reward.legacyActionFingerprint(firstDescriptor), Reward.legacyActionFingerprint(changedDescriptor)); + } + @Test void legacyQueuedActionFailsWhenPluginShutsDownBeforeExecution() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); @@ -582,11 +696,11 @@ void concurrentContinuationsDoNotClaimOneAnotherActions() { void commandSnapshotIsCheckpointedBeforeItsFirstDispatch() throws Exception { ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); when(plugin.getTimer()).thenReturn(storageExecutor); + List checkpoints = new ArrayList<>(); Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); constructor.setAccessible(true); Object replayState = constructor.newInstance(new HashMap<>(), new HashMap<>(), false); - List checkpoints = new ArrayList<>(); java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", java.util.function.Consumer.class); setConsumer.setAccessible(true); @@ -1163,6 +1277,44 @@ void persistedReplayRejectsAChangedInjectorRegistryBeforeAnyStageRuns() throws E assertEquals(0, inserted.get()); } + @Test + void completedInjectorIsNotReplayedWhenItsConfigurationChangesDuringRecovery() throws Exception { + AtomicInteger applied = new AtomicInteger(); + data.set("Applied", "before"); + handler.getInjectedRewards().add(new RewardInject("Applied") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + applied.incrementAndGet(); + return null; + } + }); + handler.getInjectedRewards().add(new RewardInject("Fails") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + return CompletableFuture.failedFuture(new IllegalStateException("temporary")); + } + }); + + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows( + java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join())); + data.set("Applied", "after"); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + assertEquals(1, applied.get()); + } + @Test void legacyCountOnlyReplayCheckpointDoesNotResumeAgainstAnUnknownRegistry() throws Exception { AtomicInteger invoked = new AtomicInteger(); From 75aa4ede021d3b8b33911c20919da83516a805ce Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:32:53 -0600 Subject: [PATCH 14/78] Retain item rewards across disconnects --- .../api/item/FullInventoryHandler.java | 31 +++++++ .../api/user/AdvancedCoreUser.java | 17 +++- .../tests/item/FullInventoryHandlerTest.java | 35 +++++++- .../rewards/RewardAsyncInjectionTest.java | 80 +++++++++++++++++++ 4 files changed, 159 insertions(+), 4 deletions(-) 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 afc0567197..f7b0980dcf 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 @@ -106,11 +106,26 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu if (completion != null) completion.complete(null); return; } + UUID playerId = null; + if (completion != null) { + try { + playerId = player.getUniqueId(); + if (playerId == null) { + completion.completeExceptionally(new IllegalStateException("Item delivery player has no UUID")); + return; + } + } catch (Throwable failure) { + completion.completeExceptionally(failure); + return; + } + } + final UUID deliveryPlayerId = playerId; ItemStack[] itemsToGive = item.clone(); AtomicBoolean deliveryClaimed = new AtomicBoolean(); Runnable delivery = () -> { if (!deliveryClaimed.compareAndSet(false, true)) return; try { + if (completion != null) validateReplayDeliveryTarget(player, deliveryPlayerId); giveItemOwnedPlayer(player, itemsToGive); if (completion != null) completion.complete(null); } catch (Throwable failure) { @@ -138,6 +153,22 @@ protected long getItemDeliveryTimeoutMillis() { return TimeUnit.SECONDS.toMillis(30); } + /** + * Runs on the owning player scheduler immediately before a replay-aware inventory + * mutation. A reconnect creates a different player entity, so require both the + * captured UUID and entity identity to remain current; a retry can then safely + * schedule delivery for the live entity. + */ + private void validateReplayDeliveryTarget(Player player, UUID playerId) { + if (!plugin.isEnabled()) { + throw new IllegalStateException("Plugin disabled before item delivery"); + } + Player current = Bukkit.getPlayer(playerId); + if (current != player || !current.isOnline()) { + throw new IllegalStateException("Player became unavailable before item delivery"); + } + } + private static void rethrowDeliveryFailure(Throwable failure) { if (failure instanceof RuntimeException) throw (RuntimeException) failure; if (failure instanceof Error) throw (Error) failure; 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 c75c9beece..452ffdc1ef 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 @@ -352,6 +352,11 @@ private boolean collectAsyncAction(Supplier> action, Strin return false; } + private boolean hasOwnedAsyncActionCollection() { + AsyncActionCollection collection = ASYNC_ACTION_COLLECTION.get(); + return collection != null && collection.belongsTo(this); + } + /** * Retained for binary compatibility with integrations that previously called * this internal hand-off hook. Unscoped actions are intentionally not claimed: @@ -1493,7 +1498,12 @@ public void giveExpLevels(int num) { * @param builder the item builder */ public void giveItem(ItemBuilder builder) { - giveItem(builder.toItemStack(getPlayer())); + Player player = getPlayer(); + if (player == null && hasOwnedAsyncActionCollection()) { + collectAsyncFailure(new IllegalStateException("Player became unavailable before item reward delivery")); + return; + } + giveItem(builder.toItemStack(player)); } /** @@ -1523,7 +1533,7 @@ public void giveItem(ItemStack item) { * @param placeholders the placeholders */ public void giveItem(ItemStack itemStack, HashMap placeholders) { - giveItem(new ItemBuilder(itemStack).setPlaceholders(placeholders).toItemStack(getPlayer())); + giveItem(new ItemBuilder(itemStack).setPlaceholders(placeholders)); } /** @@ -1615,7 +1625,8 @@ private void scheduleLegacyItemAction(Player player, ItemStack... item) { for (ItemStack current : item) { descriptor.append('\n').append(itemDescriptor(current)); } - if (collectAsyncAction(() -> player == null ? CompletableFuture.completedFuture(null) + if (collectAsyncAction(() -> player == null ? CompletableFuture.failedFuture( + new IllegalStateException("Player became unavailable before item reward delivery")) : plugin.getFullInventoryHandler().giveItemAsync(player, item), descriptor.toString())) { return; } 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 f3b069d9f1..5cdb24252b 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 @@ -122,9 +122,12 @@ public void giveItemAlwaysUsesEntityScheduler() { @Test public void giveItemAsyncCompletesOnlyAfterOwnedInventoryDelivery() { Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); Player player = mock(Player.class); PlayerInventory inventory = mock(PlayerInventory.class); ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); when(player.getInventory()).thenReturn(inventory); when(inventory.addItem(item)).thenReturn(new HashMap<>()); @@ -133,18 +136,47 @@ public void giveItemAsyncCompletesOnlyAfterOwnedInventoryDelivery() { ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); - task.getValue().run(); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } assertTrue(delivery.toCompletableFuture().isDone()); verify(inventory).addItem(item); } + @Test + public void giveItemAsyncFailsWithoutMutationWhenPlayerDisconnectsAfterEnqueue() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.getInventory()).thenReturn(inventory); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + task.getValue().run(); + } + + assertThrows(java.util.concurrent.ExecutionException.class, + () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + verify(inventory, never()).addItem(item); + } + @Test public void timedOutGiveItemAsyncSuppressesTheQueuedDeliveryToPreventReplayDuplicates() throws Exception { Fixture fixture = createFixture(0L); + UUID uuid = UUID.randomUUID(); Player player = mock(Player.class); PlayerInventory inventory = mock(PlayerInventory.class); ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); when(player.getInventory()).thenReturn(inventory); when(inventory.addItem(item)).thenReturn(new HashMap<>()); @@ -268,6 +300,7 @@ private Fixture createFixture(long itemDeliveryTimeoutMillis) { YamlConfiguration data = new YamlConfiguration(); when(plugin.getInventoryTimer()).thenReturn(sharedInventoryTimer); + when(plugin.isEnabled()).thenReturn(true); when(plugin.getBukkitScheduler()).thenReturn(bukkitScheduler); when(plugin.getServerDataFile()).thenReturn(serverData); when(serverData.getData()).thenReturn(data); 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 7d73539ec2..d319eaf25b 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 @@ -52,10 +52,12 @@ import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.VaultHandler; import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.rewards.builtin.RewardItems; import com.bencodez.advancedcore.api.rewards.injected.RewardInject; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectString; @@ -196,6 +198,84 @@ void ordinaryItemGrantUsesTheVoidInventoryApiWithoutAnAsyncTimeout() { verify(inventory, never()).giveItemAsync(any(Player.class), any(ItemStack.class)); } + @Test + void replayItemsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + realUser.setPlayerName("Dispatch"); + Player player = mock(Player.class); + when(player.getDisplayName()).thenReturn("Dispatch"); + AtomicReference availablePlayer = new AtomicReference<>(player); + AtomicReference queuedInjection = new AtomicReference<>(); + doAnswer(invocation -> { + queuedInjection.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + data.createSection("Items").createSection("Stone").set("Material", "STONE"); + RewardItems.registerItems(handler, plugin); + + try (MockedConstruction builders = mockConstruction(ItemBuilder.class, (builder, context) -> { + when(builder.setPlaceholders(any(HashMap.class))).thenReturn(builder); + when(builder.toItemStack(any(Player.class))).thenReturn(new ItemStack(Material.STONE)); + }); org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenAnswer(ignored -> availablePlayer.get()); + CompletionStage delivery = reward.giveRewardUserAsync(realUser, new HashMap<>(), new RewardOptions()); + assertFalse(delivery.toCompletableFuture().isDone(), "the initial online check only queues injection dispatch"); + assertNotNull(queuedInjection.get()); + + availablePlayer.set(null); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> { + queuedInjection.get().run(); + delivery.toCompletableFuture().join(); + }); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertEquals(0, checkpoint.getCompletedInjectionCount()); + assertEquals(0, checkpoint.getReplayProgress().getOrDefault("AsyncReward", 0)); + assertTrue(checkpoint.getReplayPlaceholders().entrySet().stream().noneMatch(entry -> + entry.getKey().startsWith("__advancedcore_replay_legacy_actions_") + && !entry.getKey().endsWith("_snapshot")), + "an undelivered item must not receive an action-completion checkpoint"); + verify(inventory, never()).giveItemAsync(any(Player.class), any(ItemStack.class)); + + availablePlayer.set(player); + when(inventory.giveItemAsync(eq(player), any(ItemStack.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + resumed.toCompletableFuture().join(); + + availablePlayer.set(null); + AdvancedCoreUser.AsyncActionCollection placeholderContext = realUser.beginAsyncActionCollection(); + realUser.giveItem(new ItemStack(Material.STONE), new HashMap<>()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> realUser.endAsyncActionCollection(placeholderContext).toCompletableFuture().join(), + "placeholder-expanded items use the same replay-only unavailable-player failure"); + } + verify(inventory).giveItemAsync(eq(player), any(ItemStack.class)); + } + @Test void legacySchedulerRejectionFailsAsyncRewardClosed() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From 9c1537e99a473ccd4c5461d5e67aa3f73302ccb5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:59:28 -0600 Subject: [PATCH 15/78] Preserve replay state across player loss --- .../advancedcore/api/rewards/Reward.java | 14 +-- .../api/user/AdvancedCoreUser.java | 29 +++-- .../rewards/RewardAsyncInjectionTest.java | 101 ++++++++++++++++++ 3 files changed, 132 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 a19363481e..37176516a3 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 @@ -966,8 +966,8 @@ private boolean isRewardInjectionApplicable(RewardInject inject) { } public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { - if (!AdvancedCorePlugin.getInstance().getOptions().isProcessRewards()) { - AdvancedCorePlugin.getInstance().debug("Processing rewards is disabled"); + if (!plugin.getOptions().isProcessRewards()) { + plugin.debug("Processing rewards is disabled"); return; } @@ -1063,7 +1063,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { && (!isForceOffline() && !rewardOptions.isForceOffline()))) { if (rewardOptions.isGiveOffline()) { checkRewardFile(); - user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + preserveReplayState(rewardOptions); + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); plugin.debug("Saving offline reward " + getRewardName() + " for " + user.getPlayerName()); } return; @@ -1090,8 +1091,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { * @return completion stage for the complete reward injection chain */ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { - if (!AdvancedCorePlugin.getInstance().getOptions().isProcessRewards()) { - AdvancedCorePlugin.getInstance().debug("Processing rewards is disabled"); + if (!plugin.getOptions().isProcessRewards()) { + plugin.debug("Processing rewards is disabled"); return CompletableFuture.completedFuture(null); } @@ -1152,7 +1153,8 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption && (!isForceOffline() && !rewardOptions.isForceOffline()))) { if (rewardOptions.isGiveOffline()) { checkRewardFile(); - user.addOfflineRewards(this, rewardOptions.getPlaceholders()); + preserveReplayState(rewardOptions); + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); } return CompletableFuture.completedFuture(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 452ffdc1ef..b3f7e1d42f 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 @@ -1609,14 +1609,19 @@ public void giveMoney(int money) { */ public void givePotionEffect(String potionName, int duration, int amplifier) { Player player = getPlayer(); - if (player != null && plugin.isEnabled()) { - scheduleLegacyRewardAction(() -> player.addPotionEffect( - new PotionEffect(PotionEffectType.getByName(potionName), 20 * duration, amplifier)), player, true, - "potion:" + potionName + ":" + duration + ":" + amplifier); - } else if (player != null && ASYNC_ACTION_COLLECTION.get() != null) { - collectAsyncFailure(new IllegalStateException( + if (player == null) { + if (hasOwnedAsyncActionCollection()) collectAsyncFailure(new IllegalStateException( + "Player became unavailable before potion reward delivery")); + return; + } + if (!plugin.isEnabled()) { + if (hasOwnedAsyncActionCollection()) collectAsyncFailure(new IllegalStateException( "Potion reward could not be scheduled because the plugin is unavailable")); + return; } + scheduleLegacyRewardAction(() -> player.addPotionEffect( + new PotionEffect(PotionEffectType.getByName(potionName), 20 * duration, amplifier)), player, true, + "potion:" + potionName + ":" + duration + ":" + amplifier); } /** Queues a legacy Bukkit action while exposing a nonblocking completion internally. */ @@ -1730,6 +1735,7 @@ private void scheduleLegacyRewardActionAsync(Supplier> act return; } try { + if (playerAware) validateLiveScheduledPlayer(player); CompletionStage stage = action.get(); if (stage == null) { completion.completeExceptionally(new IllegalStateException("Scheduled reward action returned null")); @@ -1772,6 +1778,17 @@ private void scheduleLegacyRewardActionAsync(Supplier> act } } + /** Runs on the player-owned scheduler before a replay-aware player mutation. */ + private void validateLiveScheduledPlayer(Player player) { + if (player == null || player.getUniqueId() == null) { + throw new IllegalStateException("Scheduled player reward has no live player identity"); + } + Player current = Bukkit.getPlayer(player.getUniqueId()); + if (current != player || !current.isOnline()) { + throw new IllegalStateException("Player became unavailable before scheduled reward delivery"); + } + } + /** * Gives a reward from a configuration file. * 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 d319eaf25b..1b08deb0f0 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 @@ -58,6 +58,7 @@ import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.rewards.builtin.RewardItems; +import com.bencodez.advancedcore.api.rewards.builtin.RewardPotions; import com.bencodez.advancedcore.api.rewards.injected.RewardInject; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectString; @@ -276,6 +277,106 @@ void replayItemsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() verify(inventory).giveItemAsync(eq(player), any(ItemStack.class)); } + @Test + void replayPotionsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + realUser.setPlayerName("PotionDispatch"); + Player player = mock(Player.class); + when(player.getDisplayName()).thenReturn("PotionDispatch"); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + AtomicReference availablePlayer = new AtomicReference<>(player); + AtomicReference queuedInjection = new AtomicReference<>(); + doAnswer(invocation -> { + queuedInjection.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + data.createSection("Potions").createSection("SPEED").set("Duration", 1); + RewardPotions.register(handler, plugin); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenAnswer(ignored -> availablePlayer.get()); + CompletionStage delivery = reward.giveRewardUserAsync(realUser, new HashMap<>(), new RewardOptions()); + assertFalse(delivery.toCompletableFuture().isDone()); + availablePlayer.set(null); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> { + queuedInjection.get().run(); + delivery.toCompletableFuture().join(); + }); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertEquals(0, checkpoint.getCompletedInjectionCount()); + assertEquals(0, checkpoint.getReplayProgress().getOrDefault("AsyncReward", 0)); + verify(player, never()).addPotionEffect(any()); + + availablePlayer.set(player); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + AtomicReference queuedPotionDelivery = new AtomicReference<>(); + doAnswer(invocation -> { + queuedPotionDelivery.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertFalse(resumed.toCompletableFuture().isDone(), "recovery must requeue the unfinished potion action"); + assertNotNull(queuedPotionDelivery.get()); + availablePlayer.set(null); + queuedPotionDelivery.get().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + } + verify(player, never()).addPotionEffect(any()); + } + + @Test + void offlineRequeueRetainsReplayStateAndLegacyActionMarkers() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(false); + when(config.isTreatVanishAsOffline()).thenReturn(false); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.isOnline()).thenReturn(false); + when(user.getPlayerName()).thenReturn("Offline"); + RewardOptions options = new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true).setGiveOffline(true); + options.setOnline(false); + options.setAsyncReplayProgress(Map.of("AsyncReward", 1)); + options.setAsyncReplayRegistryFingerprints(Map.of("AsyncReward", "registry-fingerprint")); + options.getPlaceholders().put("__advancedcore_replay_legacy_actions_marker", "v2:completed"); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + reward.giveReward(user, options); + } + + ArgumentCaptor queuedOptions = ArgumentCaptor.forClass(RewardOptions.class); + verify(user).addOfflineRewards(eq(reward), eq(options.getPlaceholders()), queuedOptions.capture()); + assertEquals(options, queuedOptions.getValue()); + assertEquals(1, queuedOptions.getValue().getAsyncReplayProgress().get("AsyncReward")); + assertEquals("registry-fingerprint", queuedOptions.getValue().getAsyncReplayRegistryFingerprints().get("AsyncReward")); + assertEquals("v2:completed", queuedOptions.getValue().getPlaceholders() + .get("__advancedcore_replay_legacy_actions_marker")); + } + @Test void legacySchedulerRejectionFailsAsyncRewardClosed() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From 3d4069d58970131b4d41e1fd099e819565306c55 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:11:04 -0600 Subject: [PATCH 16/78] Keep player-bound reward stages retryable --- .../api/user/AdvancedCoreUser.java | 23 ++++++- .../rewards/RewardAsyncInjectionTest.java | 69 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) 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 b3f7e1d42f..216abeecc4 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 @@ -1475,6 +1475,7 @@ public String getUUID() { */ public void giveExp(int exp) { Player player = getPlayer(); + if (scheduleOwnedPlayerAction(player, () -> player.giveExp(exp), "exp:" + exp)) return; if (player != null) { player.giveExp(exp); } @@ -1487,6 +1488,7 @@ public void giveExp(int exp) { */ public void giveExpLevels(int num) { Player p = getPlayer(); + if (scheduleOwnedPlayerAction(p, () -> p.setLevel(p.getLevel() + num), "exp-levels:" + num)) return; if (p != null) { p.setLevel(p.getLevel() + num); } @@ -1723,6 +1725,24 @@ private void scheduleLegacyRewardAction(Runnable action, Player player, boolean }, player, playerAware, descriptor); } + /** + * Makes a direct player mutation durable only while this user's replay scope is + * active. Outside that scope callers retain the legacy immediate behavior. + */ + private boolean scheduleOwnedPlayerAction(Player player, Runnable action, String descriptor) { + if (!hasOwnedAsyncActionCollection()) return false; + if (player == null) { + collectAsyncFailure(new IllegalStateException("Player became unavailable before reward delivery")); + return true; + } + if (!plugin.isEnabled()) { + collectAsyncFailure(new IllegalStateException("Plugin disabled before player reward delivery")); + return true; + } + scheduleLegacyRewardAction(action, player, true, descriptor); + return true; + } + private void scheduleLegacyRewardActionAsync(Supplier> action, Player player, boolean playerAware, String descriptor) { if (!collectAsyncAction(() -> { @@ -2193,6 +2213,7 @@ private CompletableFuture runPlayerCommandAsync(Player player, String comm Runnable dispatch = () -> { if (!claimed.compareAndSet(false, true)) return; try { + validateLiveScheduledPlayer(player); player.chat("/" + command); completion.complete(null); } catch (Throwable failure) { @@ -2200,7 +2221,7 @@ private CompletableFuture runPlayerCommandAsync(Player player, String comm } }; try { - getPlugin().getBukkitScheduler().runTask(plugin, dispatch); + getPlugin().getBukkitScheduler().runTask(plugin, dispatch, player); } catch (Throwable failure) { claimed.set(true); completion.completeExceptionally(failure); 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 1b08deb0f0..11fd26054c 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 @@ -57,6 +57,7 @@ import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.rewards.builtin.RewardExp; import com.bencodez.advancedcore.api.rewards.builtin.RewardItems; import com.bencodez.advancedcore.api.rewards.builtin.RewardPotions; import com.bencodez.advancedcore.api.rewards.injected.RewardInject; @@ -346,6 +347,74 @@ void replayPotionsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns( verify(player, never()).addPotionEffect(any()); } + @Test + void replayExperienceRemainsPendingForNullAndDisconnectedPlayers() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + realUser.setPlayerName("ExperienceDispatch"); + Player player = mock(Player.class); + when(player.getDisplayName()).thenReturn("ExperienceDispatch"); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + AtomicReference availablePlayer = new AtomicReference<>(player); + AtomicReference queuedInjection = new AtomicReference<>(); + doAnswer(invocation -> { + queuedInjection.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + data.set("EXP", 5); + RewardExp.register(handler, plugin); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenAnswer(ignored -> availablePlayer.get()); + CompletionStage delivery = reward.giveRewardUserAsync(realUser, new HashMap<>(), new RewardOptions()); + assertFalse(delivery.toCompletableFuture().isDone()); + availablePlayer.set(null); + Throwable failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> { + queuedInjection.get().run(); + delivery.toCompletableFuture().join(); + }); + Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); + assertEquals(0, checkpoint.getCompletedInjectionCount()); + verify(player, never()).giveExp(5); + + availablePlayer.set(player); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + AtomicReference queuedExperience = new AtomicReference<>(); + doAnswer(invocation -> { + queuedExperience.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertFalse(resumed.toCompletableFuture().isDone()); + assertNotNull(queuedExperience.get()); + availablePlayer.set(null); + queuedExperience.get().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + } + verify(player, never()).giveExp(5); + } + @Test void offlineRequeueRetainsReplayStateAndLegacyActionMarkers() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From 2533230d62fa84550614debd32b0c40eabdcbbf5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 05:42:24 -0600 Subject: [PATCH 17/78] Add durable replay checkpoint callbacks --- .../advancedcore/api/rewards/Reward.java | 24 ++++- .../api/rewards/injected/RewardInject.java | 18 ++++ .../rewards/RewardAsyncInjectionTest.java | 93 +++++++++++++++++++ 3 files changed, 133 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 37176516a3..13e70024b2 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 @@ -309,9 +309,13 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, AtomicInteger completed = new AtomicInteger(resumeAfter); CompletionStage sequence = CompletableFuture.completedFuture(null); for (int index = 0; index < orderedRewards.size(); index++) { - if (index < resumeAfter) continue; final RewardInject inject = orderedRewards.get(index); final String injectionKey = replayKey + "/" + index; + if (index < resumeAfter) { + sequence = sequence.thenCompose(ignored -> notifyReplayCheckpointPersisted(inject, user, occurrenceId, + injectionKey)); + continue; + } sequence = sequence.thenCompose(ignored -> { // A nested injector can checkpoint a child before this parent injector // completes. Bind the parent's registry first so that child checkpoint @@ -324,7 +328,10 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, int checkpoint = completed.incrementAndGet(); replayState.setCompleted(replayKey, checkpoint); replayState.setRegistryFingerprint(replayKey, registryFingerprint); - return replayState.persistCheckpointAsync(plugin, placeholders).thenApply(ignored -> result); + return replayState.persistCheckpointAsync(plugin, placeholders) + .thenCompose(ignored -> notifyReplayCheckpointPersisted(inject, user, occurrenceId, + injectionKey)) + .thenApply(ignored -> result); }).thenCompose(ignored -> resumeOnServerThread(user)); } return sequence.handle((ignored, failure) -> { @@ -336,6 +343,19 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, }); } + private CompletionStage notifyReplayCheckpointPersisted(RewardInject inject, AdvancedCoreUser user, + String occurrenceId, + String injectionKey) { + try { + CompletionStage notification = inject.onReplayCheckpointPersisted(this, user, occurrenceId, + injectionKey); + return notification == null ? CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null replay-checkpoint result: " + inject.getPath())) : notification; + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + private List orderedInjectedRewards() { List postRewards = new ArrayList<>(); List orderedRewards = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java index b96c055c1e..e01ee19856 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java @@ -148,6 +148,24 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU } } + /** + * Notifies this injection after its replay progress has been durably persisted. + * Implementations may use the stable occurrence and injection identities to + * retire their own idempotency records. A failed callback keeps the replay + * pending, but the persisted checkpoint prevents the injection itself from + * running again; recovery retries only this callback. Implementations must be + * idempotent because recovery can repeat a notification that already succeeded. + * + * @param reward reward being given + * @param user receiving user + * @param occurrenceId stable identity of this logical reward occurrence + * @param injectionKey stable replay path for this injection + */ + public CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, String occurrenceId, + String injectionKey) { + return CompletableFuture.completedFuture(null); + } + /** Serializes a synchronized asynchronous injection through completion, not just invocation. */ public synchronized CompletionStage runSynchronizedAsync(Supplier> request) { CompletableFuture result = new CompletableFuture<>(); 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 11fd26054c..dadfcc40de 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 @@ -1116,6 +1116,99 @@ void checkpointWriteRunsOffServerThreadAndGatesTheNextInjection() throws Excepti assertEquals(List.of("first", "checkpoint:1", "second", "checkpoint:2"), events); } + @Test + void injectionIsNotifiedOnlyAfterItsReplayCheckpointIsPersisted() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + List events = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("Points") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + events.add("injection"); + return null; + } + @Override public CompletionStage onReplayCheckpointPersisted(Reward ignored, AdvancedCoreUser ignoredUser, + String occurrenceId, String injectionKey) { + events.add("ack:" + occurrenceId + ":" + injectionKey); + return CompletableFuture.completedFuture(null); + } + }); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(null, null, false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, (java.util.function.Consumer) checkpoint -> + events.add("checkpoint")); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence-1"); + + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + assertEquals(List.of("injection"), events); + assertFalse(result.toCompletableFuture().isDone()); + + writes.getValue().run(); + result.toCompletableFuture().join(); + assertEquals(List.of("injection", "checkpoint", "ack:occurrence-1:AsyncReward/0"), events); + } + + @Test + void replayRetriesFailedCheckpointNotificationWithoutRepeatingInjection() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + AtomicInteger injections = new AtomicInteger(); + AtomicInteger notifications = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Points") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + injections.incrementAndGet(); + return null; + } + @Override public CompletionStage onReplayCheckpointPersisted(Reward ignored, + AdvancedCoreUser ignoredUser, String occurrenceId, String injectionKey) { + return notifications.incrementAndGet() == 1 + ? CompletableFuture.failedFuture(new IllegalStateException("ack unavailable")) + : CompletableFuture.completedFuture(null); + } + }); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(null, null, false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, (java.util.function.Consumer) checkpoint -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage first = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence-1"); + assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join()); + @SuppressWarnings("unchecked") + CompletionStage retry = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence-1"); + retry.toCompletableFuture().join(); + + assertEquals(1, injections.get()); + assertEquals(2, notifications.get()); + } + @Test void typedIntegerAsyncHookUsesParsedValue() { data.set("Amount", 7); From 1167618dd3519cb224f0bde809c7267a83173181 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:15:35 -0600 Subject: [PATCH 18/78] Preserve mixed command order and checkpoint safety --- .../advancedcore/api/rewards/Reward.java | 21 +++++++++-- .../api/rewards/builtin/RewardCommands.java | 11 +++--- .../api/user/AdvancedCoreUser.java | 8 ++++ .../rewards/BuiltinRewardBehaviorTest.java | 7 +++- .../rewards/RewardAsyncInjectionTest.java | 37 +++++++++++++++++++ 5 files changed, 75 insertions(+), 9 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 13e70024b2..17f86e016d 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 @@ -19,6 +19,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import java.util.function.Consumer; @@ -786,13 +788,20 @@ private synchronized void setCheckpointConsumer(Consumer consu } public CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, HashMap placeholders) { + return persistCheckpointAsync(plugin, placeholders, 30, TimeUnit.SECONDS); + } + + private CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, + HashMap placeholders, long timeout, TimeUnit timeoutUnit) { Consumer consumer; synchronized (this) { consumer = checkpointConsumer; } if (consumer == null) return CompletableFuture.completedFuture(null); ReplayCheckpoint checkpoint = new ReplayCheckpoint(copyProgress(), copyRegistryFingerprints(), placeholders); CompletableFuture persisted = new CompletableFuture<>(); + AtomicBoolean claimed = new AtomicBoolean(); try { plugin.getTimer().execute(() -> { + if (!claimed.compareAndSet(false, true)) return; try { consumer.accept(checkpoint); persisted.complete(null); @@ -801,12 +810,18 @@ public CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, } }); } catch (Throwable failure) { - persisted.completeExceptionally(failure); + if (claimed.compareAndSet(false, true)) persisted.completeExceptionally(failure); } // shutdownNow() may remove a task that was accepted just before runtime // teardown. Bound that indeterminate state so the replay fails closed - // instead of waiting forever without advancing its checkpoint. - return persisted.orTimeout(30, TimeUnit.SECONDS); + // instead of waiting forever without advancing its checkpoint. The same + // claim prevents a backlogged task from persisting after timeout recovery. + CompletableFuture.delayedExecutor(timeout, timeoutUnit).execute(() -> { + if (claimed.compareAndSet(false, true)) { + persisted.completeExceptionally(new TimeoutException("Timed out waiting to persist reward checkpoint")); + } + }); + return persisted; } } 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 b5aebc743b..69c1318dda 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 @@ -212,13 +212,14 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor ConfigurationSection section, HashMap placeholders) { ArrayList consoleCommands = (ArrayList) section.getList("Console", new ArrayList<>()); ArrayList userCommands = (ArrayList) section.getList("Player", new ArrayList<>()); - CompletionStage player = user.preformCommandAsync(userCommands, placeholders); - // Player availability is a prerequisite for the mixed section. Do not - // schedule console side effects until that prerequisite has completed, - // otherwise a disconnect can fail the replay after grants already ran. - return player.thenCompose(ignored -> consoleCommands.isEmpty() ? CompletableFuture.completedFuture(null) + CompletionStage availability = userCommands.isEmpty() ? CompletableFuture.completedFuture(null) + : user.validatePlayerCommandAvailabilityAsync(); + // Validate the player before any mixed-section side effect, then preserve + // the established console-before-player command order. + return availability.thenCompose(ignored -> consoleCommands.isEmpty() ? CompletableFuture.completedFuture(null) : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, placeholders, section.getBoolean("Stagger", true))) + .thenCompose(ignored -> user.preformCommandAsync(userCommands, placeholders)) .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/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 216abeecc4..9cb37f7feb 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 @@ -2207,6 +2207,14 @@ 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() + ? CompletableFuture.failedFuture( + new IllegalStateException("Player command could not run because the player or plugin is unavailable")) + : CompletableFuture.completedFuture(null); + } + private CompletableFuture runPlayerCommandAsync(Player player, String command) { CompletableFuture completion = new CompletableFuture<>(); AtomicBoolean claimed = new AtomicBoolean(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index de7b5beb78..5f3ac2c656 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -326,6 +326,7 @@ public void asyncCommandsWaitForTheirDispatchStages() { when(misc.executeConsoleCommandsAsync(eq("Ben"), eq(console), eq(placeholders), eq(false))) .thenReturn(sectionConsole); ArrayList player = new ArrayList<>(List.of("spawn")); + when(user.validatePlayerCommandAvailabilityAsync()).thenReturn(CompletableFuture.completedFuture(null)); when(user.preformCommandAsync(eq(player), eq(placeholders))).thenReturn(sectionPlayer); ArrayList randomList = new ArrayList<>(List.of("say random")); HashMap randomPlaceholders = new HashMap<>(); @@ -359,7 +360,10 @@ public void asyncCommandsWaitForTheirDispatchStages() { commands.set("Stagger", false); CompletionStage sectionStage = ((RewardInjectConfigurationSection) injects.get(3)) .onRewardRequestedAsync(reward, user, commands, placeholders); + verify(misc).executeConsoleCommandsAsync(eq("Ben"), eq(console), eq(placeholders), eq(false)); + verify(user, never()).preformCommandAsync(eq(player), eq(placeholders)); sectionConsole.complete(null); + verify(user).preformCommandAsync(eq(player), eq(placeholders)); assertFalse(sectionStage.toCompletableFuture().isDone()); sectionPlayer.complete(null); sectionStage.toCompletableFuture().join(); @@ -378,7 +382,7 @@ public void mixedAsyncCommandsDoNotScheduleConsoleWorkWhenPlayerDispatchFails() MiscUtils misc = mock(MiscUtils.class); ArrayList console = new ArrayList<>(List.of("give Ben diamond")); ArrayList player = new ArrayList<>(List.of("spawn")); - when(user.preformCommandAsync(eq(player), eq(placeholders))) + when(user.validatePlayerCommandAvailabilityAsync()) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("player unavailable"))); ConfigurationSection commands = section("Commands"); commands.set("Console", console); @@ -393,6 +397,7 @@ public void mixedAsyncCommandsDoNotScheduleConsoleWorkWhenPlayerDispatchFails() assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); verify(misc, never()).executeConsoleCommandsAsync(anyString(), any(), any(), anyBoolean()); verify(misc, never()).executeConsoleCommandsAsync(anyString(), anyString(), any()); + verify(user, never()).preformCommandAsync(any(), any()); } } 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 dadfcc40de..bc4c7d3c2b 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 @@ -28,6 +28,7 @@ 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.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -68,6 +69,7 @@ import com.bencodez.advancedcore.api.rewards.builtin.RewardJavascript; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.thread.FileThread; import net.milkbowl.vault.economy.Economy; @@ -98,6 +100,13 @@ void setUp() { }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); when(plugin.getBukkitScheduler()).thenReturn(scheduler); AdvancedCorePlugin.setInstance(plugin); + try { + java.lang.reflect.Field pluginField = FileThread.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(FileThread.getInstance(), plugin); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } handler = new RewardHandler(plugin); when(plugin.getRewardHandler()).thenReturn(handler); @@ -1160,6 +1169,34 @@ void injectionIsNotifiedOnlyAfterItsReplayCheckpointIsPersisted() throws Excepti assertEquals(List.of("injection", "checkpoint", "ack:occurrence-1:AsyncReward/0"), events); } + @Test + void timedOutQueuedCheckpointCannotPersistLater() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + AtomicInteger writes = new AtomicInteger(); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(null, null, false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) checkpoint -> writes.incrementAndGet()); + java.lang.reflect.Method persist = stateType.getDeclaredMethod("persistCheckpointAsync", + AdvancedCorePlugin.class, HashMap.class, long.class, TimeUnit.class); + persist.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) persist.invoke(replayState, plugin, new HashMap<>(), 0L, + TimeUnit.MILLISECONDS); + + ArgumentCaptor queued = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(queued.capture()); + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + queued.getValue().run(); + assertEquals(0, writes.get()); + } + @Test void replayRetriesFailedCheckpointNotificationWithoutRepeatingInjection() throws Exception { ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); From 167952a0feec97017a550477e54694a944648fac Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:07:48 -0600 Subject: [PATCH 19/78] Harden durable reward replay across reloads --- .../advancedcore/api/misc/MiscUtils.java | 39 +++--- .../advancedcore/api/rewards/Reward.java | 118 +++++++++++++++--- .../api/rewards/RewardExecutor.java | 66 ++++++---- .../builtin/RewardAdvancedRewards.java | 44 +++++-- .../rewards/builtin/RewardAdvancedWorld.java | 37 ++++-- .../api/rewards/builtin/RewardCommands.java | 56 ++++++++- .../api/rewards/injected/RewardInject.java | 8 ++ .../RewardInjectConfigurationSection.java | 11 +- .../rewards/injected/RewardInjectString.java | 2 +- .../injected/RewardInjectStringList.java | 4 +- .../api/user/AdvancedCoreUser.java | 55 +++++--- .../rewards/RewardAsyncInjectionTest.java | 27 +++- .../tests/rewards/RewardExecutorTest.java | 14 +++ .../tests/user/AdvancedCoreUserTest.java | 14 +++ 14 files changed, 378 insertions(+), 117 deletions(-) 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 b2f7738632..b5f86060b0 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 @@ -248,25 +248,23 @@ public void executeConsoleCommands(String playerName, String command, HashMap executeConsoleCommandsAsync(String playerName, String command, HashMap placeholders) { - return executeConsoleCommandAsync(playerName, command, placeholders, false); - } - - private CompletionStage executeConsoleCommandAsync(String playerName, String command, - HashMap placeholders, boolean delayed) { - if (command == null || command.isEmpty()) return CompletableFuture.completedFuture(null); 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 (p != null) { - command = PlaceholderUtils.replaceJavascriptOnly(p, command); - } - command = PlaceholderUtils.replacePlaceHolder(command, placeholders); - if (p != null) { - command = PlaceholderUtils.replacePlaceHolders(p, command); - } - final String cmd = stripLeadingSlash(command); - - plugin.debug("Executing console command: " + command); - return runConsoleCommandAsync(cmd, delayed ? 1 : 0, delayed, delayed); + 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 = expanded == null || expanded.isEmpty() + ? new ArrayList<>() : new ArrayList<>(java.util.List.of(expanded)); + return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, + (cmd, ignoredIndex) -> { + plugin.debug("Executing console command: " + cmd); + return runConsoleCommandAsync(stripLeadingSlash(cmd), 0, false, false); + }); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } @@ -280,17 +278,16 @@ private CompletionStage executeConsoleCommandAsync(String playerName, Stri @SuppressWarnings("deprecation") public CompletionStage executeConsoleCommandsAsync(final String playerName, final ArrayList cmds, final HashMap placeholders, final boolean stagger) { - if (cmds == null || cmds.isEmpty()) return CompletableFuture.completedFuture(null); try { placeholders.put("player", playerName); OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); - ArrayList templates = new ArrayList<>(cmds); - ArrayList commands = cmds; + ArrayList templates = cmds == null ? new ArrayList<>() : new ArrayList<>(cmds); + ArrayList commands = new ArrayList<>(templates); if (p != null) commands = PlaceholderUtils.replaceJavascriptOnly(p, commands); commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); if (p != null) commands = PlaceholderUtils.replacePlaceHolders(p, commands); - return Reward.replayCommandSequence(plugin, placeholders, "console:" + stagger, templates, commands, + return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, (command, index) -> { plugin.debug("Executing console command: " + command); return runConsoleCommandAsync(stripLeadingSlash(command), index > 0 ? 1 : 0, 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 17f86e016d..514b1f32fc 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 @@ -54,6 +54,7 @@ public class Reward { private static final ThreadLocal ACTIVE_REPLAY_OCCURRENCE_ID = new ThreadLocal<>(); private static final String REPLAY_SELECTION_PREFIX = "__advancedcore_replay_selection_"; private static final String REPLAY_COMMAND_PREFIX = "__advancedcore_replay_commands_"; + private static final String REPLAY_NESTED_LIST_PREFIX = "__advancedcore_replay_nested_list_"; private static final String REPLAY_LEGACY_ACTION_PREFIX = "__advancedcore_replay_legacy_actions_"; @Getter @@ -303,7 +304,11 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, HashMap placeholders, int completedStages, ReplayState replayState, String replayKey, String occurrenceId) { List orderedRewards = orderedInjectedRewards(); - int resumeAfter = Math.max(0, replayState.getCompleted(replayKey, completedStages)); + int resumeAfter = replayState.getCompleted(replayKey, completedStages); + if (resumeAfter < 0 || resumeAfter > orderedRewards.size()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Reward replay progress exceeds the injection registry")); + } String registryFingerprint = injectionRegistryFingerprint(orderedRewards); if (!replayState.matchesRegistryFingerprint(registryFingerprint)) { return CompletableFuture.failedFuture(new IncompatibleReplayCheckpointException(replayKey)); @@ -527,11 +532,12 @@ static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, List expandedCommands, ReplayState replayState, String activeKey, BiFunction> dispatch) { - if (commandTemplates == null || commandTemplates.isEmpty()) return CompletableFuture.completedFuture(null); - if (expandedCommands == null || expandedCommands.size() != commandTemplates.size()) { - return CompletableFuture.failedFuture(new IllegalArgumentException("Command templates and expansions differ in size")); - } if (replayState == null || activeKey == null) { + if (commandTemplates == null || commandTemplates.isEmpty()) return CompletableFuture.completedFuture(null); + if (expandedCommands == null || expandedCommands.size() != commandTemplates.size()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Command templates and expansions differ in size")); + } CompletionStage sequence = CompletableFuture.completedFuture(null); for (int index = 0; index < expandedCommands.size(); index++) { String command = expandedCommands.get(index); @@ -540,35 +546,43 @@ static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, } return sequence; } - StringBuilder identity = new StringBuilder(activeKey == null ? "root" : activeKey) - .append('\n').append(lane == null ? "commands" : lane); - identity.append('\n').append(encodeCommandSnapshot(commandTemplates)); - String storageKey = REPLAY_COMMAND_PREFIX + digest(identity.toString()); + String storageKey = replaySequenceKey(REPLAY_COMMAND_PREFIX, activeKey, + lane == null ? "commands" : lane); String snapshotKey = storageKey + "_snapshot"; List commands; - String storedSnapshot = placeholders.get(snapshotKey); + String storedSnapshot = replayMetadata(placeholders, replayState, snapshotKey); boolean createdSnapshot = storedSnapshot == null; if (storedSnapshot == null) { - commands = new ArrayList<>(expandedCommands); + List templates = commandTemplates == null ? List.of() : commandTemplates; + List expansions = expandedCommands == null ? List.of() : expandedCommands; + if (expansions.size() != templates.size()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Command templates and expansions differ in size")); + } + commands = new ArrayList<>(expansions); String encodedSnapshot = encodeCommandSnapshot(commands); placeholders.put(snapshotKey, encodedSnapshot); replayState.recordReplayMetadata(snapshotKey, encodedSnapshot); } else { replayState.recordReplayMetadata(snapshotKey, storedSnapshot); try { - commands = decodeCommandSnapshot(storedSnapshot, commandTemplates.size()); + commands = decodeCommandSnapshot(storedSnapshot); } catch (IllegalArgumentException failure) { return CompletableFuture.failedFuture(new IllegalStateException("Malformed command replay snapshot", failure)); } } - int completed = 0; + String storedProgress = replayMetadata(placeholders, replayState, storageKey); + int completed; try { - completed = Integer.parseInt(placeholders.getOrDefault(storageKey, "0")); - } catch (NumberFormatException ignored) { } - if (placeholders.containsKey(storageKey)) { - replayState.recordReplayMetadata(storageKey, placeholders.get(storageKey)); + completed = storedProgress == null ? 0 : Integer.parseInt(storedProgress); + } catch (NumberFormatException failure) { + return CompletableFuture.failedFuture( + new IllegalStateException("Malformed command replay progress", failure)); + } + if (storedProgress != null) replayState.recordReplayMetadata(storageKey, storedProgress); + if (completed < 0 || completed > commands.size()) { + return CompletableFuture.failedFuture(new IllegalStateException("Command replay progress exceeds snapshot")); } - if (completed < 0 || completed > commandTemplates.size()) completed = 0; // Persist the concrete expansion before issuing its first side effect. A // restart during an indeterminate dispatch must reuse this exact payload, // even though no command-progress marker exists yet. @@ -589,6 +603,66 @@ static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, return sequence; } + /** Freezes one nested reward list before any child side effect is dispatched. */ + public static CompletionStage> replayNestedRewardSnapshot(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List configuredRewards, + ReplayState replayState, String activeKey) { + if (replayState == null || activeKey == null) { + return CompletableFuture.completedFuture(configuredRewards == null ? List.of() + : new ArrayList<>(configuredRewards)); + } + String snapshotKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, + lane == null ? "nested" : lane) + "_snapshot"; + String storedSnapshot = replayMetadata(placeholders, replayState, snapshotKey); + if (storedSnapshot != null) { + replayState.recordReplayMetadata(snapshotKey, storedSnapshot); + try { + return CompletableFuture.completedFuture(decodeCommandSnapshot(storedSnapshot)); + } catch (IllegalArgumentException failure) { + return CompletableFuture.failedFuture( + new IllegalStateException("Malformed nested reward replay snapshot", failure)); + } + } + List snapshot = configuredRewards == null ? List.of() : new ArrayList<>(configuredRewards); + String encodedSnapshot = encodeCommandSnapshot(snapshot); + placeholders.put(snapshotKey, encodedSnapshot); + replayState.recordReplayMetadata(snapshotKey, encodedSnapshot); + return replayState.persistCheckpointAsync(plugin, placeholders).thenApply(ignored -> snapshot); + } + + private static String replaySequenceKey(String prefix, String activeKey, String lane) { + return prefix + digest(activeKey.length() + ":" + activeKey + lane.length() + ":" + lane); + } + + /** Returns whether the active replay already froze this logical command lane. */ + public static boolean hasReplayCommandSnapshot(HashMap placeholders, String lane) { + ReplayState replayState = currentReplayState(); + String activeKey = currentReplayKey(); + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_COMMAND_PREFIX, activeKey, + lane == null ? "commands" : lane); + return replayMetadata(placeholders, replayState, storageKey + "_snapshot") != null; + } + + /** Returns whether the active replay already froze this nested reward lane. */ + public static boolean hasReplayNestedRewardSnapshot(HashMap placeholders, String lane) { + return hasReplayNestedRewardSnapshot(placeholders, lane, currentReplayState(), currentReplayKey()); + } + + /** Returns whether the supplied replay already froze this nested reward lane. */ + public static boolean hasReplayNestedRewardSnapshot(HashMap placeholders, String lane, + ReplayState replayState, String activeKey) { + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, + lane == null ? "nested" : lane); + return replayMetadata(placeholders, replayState, storageKey + "_snapshot") != null; + } + + private static String replayMetadata(HashMap placeholders, ReplayState replayState, String key) { + String value = placeholders == null ? null : placeholders.get(key); + return value == null && replayState != null ? replayState.replayMetadata(key) : value; + } + static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, HashMap placeholders, String lane, List commands, ReplayState replayState, String activeKey, @@ -609,6 +683,12 @@ private static String encodeCommandSnapshot(List commands) { } private static List decodeCommandSnapshot(String encoded, int expectedSize) { + List commands = decodeCommandSnapshot(encoded); + if (commands.size() != expectedSize) throw new IllegalArgumentException("Command snapshot size changed"); + return commands; + } + + private static List decodeCommandSnapshot(String encoded) { if (!encoded.startsWith("v1:")) throw new IllegalArgumentException("Unknown command snapshot version"); String body = encoded.substring(3); ArrayList commands = new ArrayList<>(); @@ -629,7 +709,6 @@ private static List decodeCommandSnapshot(String encoded, int expectedSi } } } - if (commands.size() != expectedSize) throw new IllegalArgumentException("Command snapshot size changed"); return commands; } @@ -654,6 +733,7 @@ public static void mergeReplayMetadata(HashMap target, Map giveRewardAsync(AdvancedCoreUser user, Configuratio if (path == null || data == null) return CompletableFuture.completedFuture(null); if (!plugin.isEnabled()) return disabledDispatch(); - if (data.isList(path)) { - CompletionStage sequence = CompletableFuture.completedFuture(null); - Reward.ReplayState replayState = Reward.replayStateFor(options); - String parentReplayKey = options.getAsyncReplayKey(); - if (parentReplayKey == null) parentReplayKey = Reward.currentReplayKey(); - if (parentReplayKey == null) parentReplayKey = "list:" + path; + Reward.ReplayState replayState = Reward.replayStateFor(options); + String parentReplayKey = options.getAsyncReplayKey(); + if (parentReplayKey == null) parentReplayKey = Reward.currentReplayKey(); + if (parentReplayKey == null) parentReplayKey = "list:" + path; + String nestedLane = "nested-list:" + path; + if (data.isList(path) + || Reward.hasReplayNestedRewardSnapshot(options.getPlaceholders(), nestedLane, replayState, + parentReplayKey)) { final String stableParentReplayKey = parentReplayKey; - int rewardIndex = 0; - for (String nestedReward : new ArrayList<>(data.getStringList(path))) { - final int nestedIndex = rewardIndex++; - sequence = sequence.thenCompose(ignored -> { - // Clone only when this child is actually reached. This lets - // metadata from earlier children be merged before a later - // child receives its isolated ordinary placeholders. - RewardOptions nestedOptions = options.copyForNestedDispatch( - stableParentReplayKey + "/" + nestedReward + ":" + nestedIndex); - nestedOptions.setAsyncReplayState(replayState); - return giveRewardAsync(user, nestedReward, nestedOptions).handle((childResult, failure) -> { - // Child options stay isolated for normal placeholders. Replay - // markers are shared lazily so an earlier child remains durable - // when a later child fails and the list is restarted. - replayState.mergeReplayMetadataInto(options.getPlaceholders()); - if (failure != null) throw new CompletionException(failure); - return childResult; - }); - }); - } - return sequence; + return Reward.replayNestedRewardSnapshot(plugin, options.getPlaceholders(), nestedLane, + data.isList(path) ? new ArrayList<>(data.getStringList(path)) : java.util.List.of(), replayState, + stableParentReplayKey) + .thenCompose(rewards -> { + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = 0; index < rewards.size(); index++) { + String nestedReward = rewards.get(index); + int nestedIndex = index; + sequence = sequence.thenCompose(ignored -> { + // Clone only when this child is actually reached. This lets + // metadata from earlier children be merged before a later + // child receives its isolated ordinary placeholders. + RewardOptions nestedOptions = options.copyForNestedDispatch( + stableParentReplayKey + "/" + nestedReward + ":" + nestedIndex); + nestedOptions.setAsyncReplayState(replayState); + return giveRewardAsync(user, nestedReward, nestedOptions).handle((childResult, failure) -> { + // Child options stay isolated for normal placeholders. Replay + // markers are shared lazily so an earlier child remains durable + // when a later child fails and the list is restarted. + replayState.mergeReplayMetadataInto(options.getPlaceholders()); + if (failure != null) throw new CompletionException(failure); + return childResult; + }); + }); + } + return sequence; + }); } if (data.isConfigurationSection(path)) { return giveSectionRewardAsync(user, data, path, context); @@ -284,6 +292,10 @@ public CompletionStage givePersistedQueueRewardAsync(AdvancedCoreUser user resolved = handler.getQueuedGeneratedReward(rewardName, user.getUUID()); if (resolved == null) resolved = handler.getReward(rewardName); } + if (resolved == null) { + return CompletableFuture.failedFuture( + new IllegalStateException("Persisted queued reward could not be resolved: " + rewardName)); + } return giveRewardAsync(user, resolved, context.getOptions()); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java index 87e876bd52..c0dbdce093 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java @@ -42,6 +42,11 @@ public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayNestedRewardSnapshot(placeholders, "advanced-rewards:" + getPath()); + } + @Override public boolean supportsAsyncSynchronization() { // This injector awaits nested rewards, which can invoke this same @@ -66,23 +71,36 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU ConfigurationSection data, HashMap placeholders) { if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) && !isAlwaysForceNoData()) { - return CompletableFuture.completedFuture(null); + return hasPendingReplayWork(placeholders) + ? CompletableFuture.failedFuture(new IllegalStateException( + "Pending reward replay configuration is missing: " + getPath())) + : CompletableFuture.completedFuture(null); } - CompletionStage sequence = CompletableFuture.completedFuture(null); com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); String parentReplayKey = Reward.currentReplayKey(); String parentOccurrenceId = Reward.currentReplayOccurrenceId(); - int rewardIndex = 0; - for (String rewardName : ArrayUtils.convert(data.getConfigurationSection(getPath()).getKeys(false))) { - final int childIndex = rewardIndex++; - sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, - data.getConfigurationSection(getPath()), rewardName, - Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), replayState, - parentReplayKey, - rewardName + ":" + childIndex, parentOccurrenceId) - .setPrefix(reward.getRewardName() + "_AdvancedRewards"))); - } - return sequence.thenApply(ignored -> null); + ConfigurationSection section = data.getConfigurationSection(getPath()); + ArrayList configured = ArrayUtils.convert(section.getKeys(false)); + return Reward.replayNestedRewardSnapshot(plugin, placeholders, "advanced-rewards:" + getPath(), + configured, replayState, parentReplayKey).thenCompose(rewards -> { + for (String rewardName : rewards) { + if (!section.contains(rewardName, true)) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Pending nested reward configuration is missing: " + rewardName)); + } + } + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = 0; index < rewards.size(); index++) { + String rewardName = rewards.get(index); + int childIndex = index; + sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, + section, rewardName, + Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), replayState, + parentReplayKey, rewardName + ":" + childIndex, parentOccurrenceId) + .setPrefix(reward.getRewardName() + "_AdvancedRewards"))); + } + return sequence.thenApply(ignored -> (Object) null); + }); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java index d6c6d1418f..b46d5cdfd1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java @@ -37,6 +37,11 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayNestedRewardSnapshot(placeholders, "advanced-world:" + getPath()); + } + @Override public boolean supportsAsyncSynchronization() { return false; } @@ -55,21 +60,29 @@ public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, Conf @Override public CompletionStage onRewardRequestedAsync(Reward sourceReward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { - CompletionStage sequence = CompletableFuture.completedFuture(null); com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); String parentReplayKey = Reward.currentReplayKey(); String parentOccurrenceId = Reward.currentReplayOccurrenceId(); - int worldIndex = 0; - for (String key : section.getKeys(false)) { - final int childIndex = worldIndex++; - section.set(key + ".Worlds", ArrayUtils.convert(new String[] { key })); - sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, section, key, - Reward.withReplayState(new RewardOptions().withPlaceHolder(placeholders), replayState, - parentReplayKey, - key + ":" + childIndex, parentOccurrenceId) - .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld"))); - } - return sequence.thenApply(ignored -> null); + return Reward.replayNestedRewardSnapshot(plugin, placeholders, "advanced-world:" + getPath(), + new ArrayList<>(section.getKeys(false)), replayState, parentReplayKey).thenCompose(worlds -> { + for (String key : worlds) { + if (!section.contains(key, true)) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Pending nested reward configuration is missing: " + key)); + } + } + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = 0; index < worlds.size(); index++) { + String key = worlds.get(index); + int childIndex = index; + section.set(key + ".Worlds", ArrayUtils.convert(new String[] { key })); + sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, section, key, + Reward.withReplayState(new RewardOptions().withPlaceHolder(placeholders), replayState, + parentReplayKey, key + ":" + childIndex, parentOccurrenceId) + .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld"))); + } + return sequence.thenApply(ignored -> (String) null); + }); } @Override 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 69c1318dda..322272d208 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 @@ -9,6 +9,7 @@ import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import com.bencodez.advancedcore.AdvancedCorePlugin; @@ -52,6 +53,18 @@ public static void registerNumberCommand(RewardHandler handler, AdvancedCorePlug @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console"); + } + + @Override + protected CompletionStage onMissingConfiguredDataAsync(Reward reward, AdvancedCoreUser user, + HashMap placeholders) { + return onRewardRequestedAsync(reward, user, new YamlConfiguration(), placeholders) + .thenApply(result -> result); + } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -103,6 +116,11 @@ public static void registerCommand(RewardHandler handler, AdvancedCorePlugin plu @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console"); + } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, String value, HashMap placeholders) { @@ -143,6 +161,11 @@ public static void registerCommands(RewardHandler handler, AdvancedCorePlugin pl @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console"); + } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -190,6 +213,19 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console") + || Reward.hasReplayCommandSnapshot(placeholders, "player"); + } + + @Override + protected CompletionStage onMissingConfiguredDataAsync(Reward reward, AdvancedCoreUser user, + HashMap placeholders) { + return onRewardRequestedAsync(reward, user, new YamlConfiguration(), placeholders) + .thenApply(result -> result); + } + @SuppressWarnings("unchecked") @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, @@ -212,13 +248,18 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor ConfigurationSection section, HashMap placeholders) { ArrayList consoleCommands = (ArrayList) section.getList("Console", new ArrayList<>()); ArrayList userCommands = (ArrayList) section.getList("Player", new ArrayList<>()); - CompletionStage availability = userCommands.isEmpty() ? CompletableFuture.completedFuture(null) + boolean stagger = section.getBoolean("Stagger", true); + CompletionStage availability = userCommands.isEmpty() + && !Reward.hasReplayCommandSnapshot(placeholders, "player") + ? CompletableFuture.completedFuture(null) : user.validatePlayerCommandAvailabilityAsync(); // Validate the player before any mixed-section side effect, then preserve // the established console-before-player command order. - return availability.thenCompose(ignored -> consoleCommands.isEmpty() ? CompletableFuture.completedFuture(null) + return availability.thenCompose(ignored -> consoleCommands.isEmpty() + && !Reward.hasReplayCommandSnapshot(placeholders, "console") + ? CompletableFuture.completedFuture(null) : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, - placeholders, section.getBoolean("Stagger", true))) + placeholders, stagger)) .thenCompose(ignored -> user.preformCommandAsync(userCommands, placeholders)) .thenApply(ignored -> null); } @@ -250,6 +291,11 @@ public static void registerRandomCommand(RewardHandler handler, AdvancedCorePlug @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayCommandSnapshot(placeholders, "console"); + } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { @@ -263,7 +309,9 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { - if (list.isEmpty()) return CompletableFuture.completedFuture(null); + if (list.isEmpty() && !hasPendingReplayWork(placeholders)) { + return CompletableFuture.completedFuture(null); + } String command = Reward.replaySelection(placeholders, () -> list.get(ThreadLocalRandom.current().nextInt(list.size()))); return MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), command, placeholders) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java index e01ee19856..e8e15f70c5 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java @@ -113,6 +113,14 @@ public boolean requiresConfiguredDataForAsync() { return false; } + /** + * Whether durable replay metadata requires this injection to run even when its + * configured path was removed or changed while the reward was queued. + */ + public boolean hasPendingReplayWork(HashMap placeholders) { + return false; + } + /** * Whether asynchronous dispatch may serialize this injection across reward * chains. Nested reward injectors opt out because awaiting a child that uses diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java index b6e11843c7..21dd617979 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java @@ -40,12 +40,21 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor } } + /** Fails closed when a durable section replay can no longer read its payload. */ + protected CompletionStage onMissingConfiguredDataAsync(Reward reward, AdvancedCoreUser user, + HashMap placeholders) { + return CompletableFuture.failedFuture( + new IllegalStateException("Pending reward replay configuration is missing: " + getPath())); + } + @Override public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, ConfigurationSection data, HashMap placeholders) { if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) && !isAlwaysForceNoData()) { - return CompletableFuture.completedFuture(null); + return hasPendingReplayWork(placeholders) + ? onMissingConfiguredDataAsync(reward, user, placeholders) + : CompletableFuture.completedFuture(null); } return onRewardRequestedAsync(reward, user, data.getConfigurationSection(getPath()), placeholders) .thenApply(result -> result); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java index ab5eb80f1c..cfb954b911 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectString.java @@ -63,7 +63,7 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU ConfigurationSection data, HashMap placeholders) { if (!((data.isString(getPath()) && !data.getString(getPath(), "").isEmpty()) || (isAlwaysForce() && data.contains(getPath(), true)) || isAlwaysForceNoData())) { - return CompletableFuture.completedFuture(null); + if (!hasPendingReplayWork(placeholders)) return CompletableFuture.completedFuture(null); } String value = data.getString(getPath(), getDefaultValue()); CompletionStage result = onRewardRequestAsync(reward, user, value, placeholders); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java index 7bc405ad0c..995efabd9a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectStringList.java @@ -67,7 +67,9 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, ConfigurationSection data, HashMap placeholders) { if (!data.isList(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) && !isAlwaysForceNoData()) { - return CompletableFuture.completedFuture(null); + return hasPendingReplayWork(placeholders) + ? onRewardRequestAsync(reward, user, new ArrayList<>(), placeholders).thenApply(result -> result) + : CompletableFuture.completedFuture(null); } List stored = data.getList(getPath(), getDefaultValue()); ArrayList value = new ArrayList<>(); 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 9cb37f7feb..525caceda0 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 @@ -1021,6 +1021,7 @@ private void checkpointOfflineReward(AtomicReference currentEntry, Rewar int index = pending.indexOf(current); if (index >= 0) { pending.set(index, updated); + setOfflineRewardsDurably(pending, updated); int claimed = claims.offline.getOrDefault(current, 0); // Migrate this occurrence only. Identical legacy queue entries share a // serialized key, but may each already be executing; moving the whole @@ -1029,7 +1030,6 @@ private void checkpointOfflineReward(AtomicReference currentEntry, Rewar else claims.offline.put(current, claimed - 1); claims.offline.put(updated, claims.offline.getOrDefault(updated, 0) + 1); currentEntry.set(updated); - setOfflineRewardsDurably(pending); } } } @@ -1062,9 +1062,17 @@ private void restoreOfflineReward(String rewardEntry, Throwable failure) { synchronized (plugin) { ArrayList pending = getOfflineRewards(); int index = pending.indexOf(rewardEntry); - if (index >= 0) pending.set(index, withAsyncReplayProgress(rewardEntry, failure)); - releaseOfflineReward(rewardEntry); - setOfflineRewards(pending); + try { + if (index >= 0) { + String restored = withAsyncReplayProgress(rewardEntry, failure); + pending.set(index, restored); + setOfflineRewards(pending, true, restored); + } else { + setOfflineRewards(pending); + } + } finally { + releaseOfflineReward(rewardEntry); + } } plugin.getLogger().warning("Could not deliver queued offline reward for " + getPlayerName() + "; it will be retried: " + failure.getMessage()); @@ -2189,16 +2197,16 @@ public void run() { * replay. It completes only after each queued player command has run. */ public CompletionStage preformCommandAsync(ArrayList commands, HashMap placeholders) { - if (commands == null || commands.isEmpty()) return CompletableFuture.completedFuture(null); try { + ArrayList templates = commands == null ? new ArrayList<>() : new ArrayList<>(commands); final ArrayList cmds = PlaceholderUtils.replaceJavascript(getPlayer(), - PlaceholderUtils.replacePlaceHolder(commands, placeholders)); - final Player player = getPlayer(); - if (player == null || !plugin.isEnabled()) { - return CompletableFuture.failedFuture( - new IllegalStateException("Player command could not run because the player or plugin is unavailable")); - } - return Reward.replayCommandSequence(plugin, placeholders, "player", commands, cmds, (command, ignoredIndex) -> { + PlaceholderUtils.replacePlaceHolder(templates, placeholders)); + return Reward.replayCommandSequence(plugin, placeholders, "player", templates, cmds, (command, ignoredIndex) -> { + final Player player = getPlayer(); + if (player == null || !plugin.isEnabled()) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Player command could not run because the player or plugin is unavailable")); + } plugin.debug("Executing player command for " + getPlayerName() + ": " + command); return runPlayerCommandAsync(player, command); }); @@ -2568,19 +2576,34 @@ public void setOfflineRewards(ArrayList offlineRewards) { setOfflineRewards(offlineRewards, true); } - /** Writes an async replay checkpoint before the next stage can execute. */ - private void setOfflineRewardsDurably(ArrayList offlineRewards) { - persistReplayCheckpoint(() -> setOfflineRewards(offlineRewards, false)); + /** Writes a checkpoint without allowing size trimming to discard its active entry. */ + private void setOfflineRewardsDurably(ArrayList offlineRewards, String protectedEntry) { + persistReplayCheckpoint(() -> setOfflineRewards(offlineRewards, false, protectedEntry)); } private void setOfflineRewards(ArrayList offlineRewards, boolean queue) { + setOfflineRewards(offlineRewards, queue, null); + } + + private void setOfflineRewards(ArrayList offlineRewards, boolean queue, String protectedEntry) { // MySQL TEXT max length is 65535 bytes int maxLength = 65535; String str = String.join("%line%", offlineRewards); // Remove oldest rewards until within limit while (str.getBytes().length > maxLength && !offlineRewards.isEmpty()) { - offlineRewards.remove(0); + int removalIndex = 0; + while (removalIndex < offlineRewards.size() + && protectedEntry != null && protectedEntry.equals(offlineRewards.get(removalIndex))) { + removalIndex++; + } + if (removalIndex >= offlineRewards.size()) { + // Never trade a durable in-flight checkpoint for queue-size compliance. + // The storage write must retain the only record that prevents already + // completed non-idempotent stages from running again. + break; + } + offlineRewards.remove(removalIndex); str = String.join("%line%", offlineRewards); } 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 bc4c7d3c2b..66b4ecf392 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 @@ -1565,15 +1565,18 @@ void commandReplayRetriesOnlyTheFailedSuffix() throws Exception { ArrayList dispatched = new ArrayList<>(); AtomicBoolean failSecond = new AtomicBoolean(true); AtomicInteger expansion = new AtomicInteger(); + AtomicReference> configuredTemplates = new AtomicReference<>( + List.of("first %value%", "second %value%")); handler.getInjectedRewards().add(new RewardInject("Commands") { @Override public boolean supportsAsyncRequest() { return true; } @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, ConfigurationSection ignoredData, HashMap commandPlaceholders) { - List templates = List.of("first %value%", "second %value%"); + List templates = configuredTemplates.get(); int currentExpansion = expansion.incrementAndGet(); - List expanded = List.of("first-" + currentExpansion, "second-" + currentExpansion); + List expanded = templates.stream() + .map(template -> template.substring(0, template.indexOf(' ')) + "-" + currentExpansion).toList(); return Reward.replayCommandSequence(plugin, commandPlaceholders, "console", templates, expanded, (command, ignoredIndex) -> { dispatched.add(command); @@ -1588,6 +1591,7 @@ void commandReplayRetriesOnlyTheFailedSuffix() throws Exception { () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join()); Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); failSecond.set(false); + configuredTemplates.set(List.of("inserted %value%", "second %value%", "first %value%")); Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); state.setAccessible(true); @@ -1603,6 +1607,25 @@ void commandReplayRetriesOnlyTheFailedSuffix() throws Exception { assertEquals(List.of("first-1", "second-1", "second-1"), dispatched); } + @Test + void replayProgressBeyondTheInjectorRegistryFailsClosed() throws Exception { + AtomicInteger invoked = new AtomicInteger(); + handler.getInjectedRewards().add(new RewardInject("Only") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invoked.incrementAndGet(); + return null; + } + }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class); + replay.setAccessible(true); + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 2); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertEquals(0, invoked.get()); + } + @Test void persistedReplayRejectsAChangedInjectorRegistryBeforeAnyStageRuns() throws Exception { AtomicInteger applied = new AtomicInteger(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index e73350e6b7..c3688d1fea 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -201,6 +201,7 @@ public void asyncListReplayDoesNotRepeatCompletedDirectCommands() { assertThrows(CompletionException.class, () -> executor.giveRewardAsync(user, data, "Rewards", first).toCompletableFuture().join()); assertNotNull(checkpoint.get()); + data.set("Rewards", "/inserted"); RewardOptions retry = new RewardOptions() .setPlaceholders(new java.util.HashMap<>(checkpoint.get().getPlaceholders())); @@ -210,6 +211,7 @@ public void asyncListReplayDoesNotRepeatCompletedDirectCommands() { verify(misc).executeConsoleCommandsAsync(eq("Ben"), eq("/first"), any()); verify(misc, org.mockito.Mockito.times(2)).executeConsoleCommandsAsync(eq("Ben"), eq("/second"), any()); + verify(misc, never()).executeConsoleCommandsAsync(eq("Ben"), eq("/inserted"), any()); } @Test @@ -350,6 +352,18 @@ public void disabledPluginFailsEveryConfiguredAsyncDispatchWithoutExecuting() { verify(reward, never()).giveRewardAsync(eq(user), any(RewardOptions.class)); } + @Test + public void unresolvedPersistedRewardFailsSoTheQueueCanRetainIt() { + when(handler.rewardExist("Missing")).thenReturn(false); + when(handler.hasDirectRewardHandle("Missing")).thenReturn(false); + when(handler.getQueuedGeneratedReward("Missing", user.getUUID())).thenReturn(null); + when(handler.getReward("Missing")).thenReturn(null); + + assertThrows(CompletionException.class, + () -> executor.givePersistedQueueRewardAsync(user, "Missing", new RewardOptions()) + .toCompletableFuture().join()); + } + @Test public void choicesDispatchThroughRewardBuilder() { Reward mainReward = mock(Reward.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 0990f275b7..4f781cf950 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -270,6 +270,20 @@ void setOfflineRewards_withinLimit() { verify(data).setStringList("offlineRewardsPath", rewards); } + @Test + void durableCheckpointTrimmingPreservesTheActiveEntry() throws Exception { + String active = "active-" + "a".repeat(70_000); + ArrayList rewards = new ArrayList<>(List.of(active, "old-reward")); + java.lang.reflect.Method write = AdvancedCoreUser.class.getDeclaredMethod( + "setOfflineRewards", ArrayList.class, boolean.class, String.class); + write.setAccessible(true); + + write.invoke(user, rewards, false, active); + + assertEquals(List.of(active), rewards); + verify(data).setStringList("offlineRewardsPath", rewards, false); + } + @Test void failedAsyncOfflineReplayIsRestoredForRetry() { ArrayList initial = new ArrayList<>(); From 3465fd4c611e872d9afb1d7834342b46f36ac2b3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:47:15 -0600 Subject: [PATCH 20/78] Retain unresolved nested reward replays --- .../api/rewards/QueuedGeneratedReward.java | 21 ++++-- .../advancedcore/api/rewards/Reward.java | 17 +++++ .../api/rewards/RewardExecutor.java | 16 +++-- .../builtin/RewardAdvancedPriority.java | 12 +++- .../builtin/RewardAdvancedRandomReward.java | 17 +++-- .../api/rewards/builtin/RewardJavascript.java | 8 ++- .../api/rewards/builtin/RewardLucky.java | 7 +- .../api/rewards/builtin/RewardPriority.java | 12 +++- .../api/rewards/builtin/RewardRandom.java | 14 +++- .../rewards/builtin/RewardRandomReward.java | 20 ++++-- .../rewards/builtin/RewardSpecialChance.java | 20 ++++-- .../api/rewards/builtin/RewardSubRewards.java | 10 ++- .../QueuedGeneratedRewardDispatchTest.java | 25 +++++++ .../rewards/RewardAsyncInjectionTest.java | 68 +++++++++++++++++++ .../tests/rewards/RewardExecutorTest.java | 43 ++++++++++++ 15 files changed, 275 insertions(+), 35 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/QueuedGeneratedReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/QueuedGeneratedReward.java index f4396aa8b2..ac4443af24 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/QueuedGeneratedReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/QueuedGeneratedReward.java @@ -23,14 +23,25 @@ public final class QueuedGeneratedReward extends Reward { @Override public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { - if (user == null || user.getUUID() == null || !allowedUserUuids.contains(user.getUUID())) { - plugin.getLogger().warning("Blocked generated queued reward " + getRewardName() - + " for a user without a matching persisted queue entry"); - return; - } + if (!isAllowed(user)) return; super.giveReward(user, rewardOptions); } + @Override + public java.util.concurrent.CompletionStage giveRewardAsync(AdvancedCoreUser user, + RewardOptions rewardOptions) { + if (!isAllowed(user)) return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("Generated queued reward is not authorized for this user")); + return super.giveRewardAsync(user, rewardOptions); + } + + private boolean isAllowed(AdvancedCoreUser user) { + if (user != null && user.getUUID() != null && allowedUserUuids.contains(user.getUUID())) return true; + plugin.getLogger().warning("Blocked generated queued reward " + getRewardName() + + " for a user without a matching persisted queue entry"); + return false; + } + @Override public boolean isGeneratedSnapshotCreated() { // A loaded generated snapshot must retain snapshot provenance if execution is diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java index 514b1f32fc..4351520b87 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 @@ -504,6 +504,23 @@ public static String replaySelection(HashMap placeholders, Suppl return selected; } + /** Returns whether the active injection already persisted a selected branch. */ + public static boolean hasReplaySelection(HashMap placeholders) { + String activeKey = ACTIVE_REPLAY_KEY.get(); + if (activeKey == null) return false; + String storageKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding() + .encodeToString(activeKey.getBytes(StandardCharsets.UTF_8)); + return replayMetadata(placeholders, ACTIVE_REPLAY_STATE.get(), storageKey) != null; + } + + /** Durably records replay metadata before dispatching its selected side effect. */ + public static CompletionStage persistReplayMetadataAsync(AdvancedCorePlugin plugin, + HashMap placeholders) { + ReplayState replayState = ACTIVE_REPLAY_STATE.get(); + return replayState == null ? CompletableFuture.completedFuture(null) + : replayState.persistCheckpointAsync(plugin, placeholders); + } + /** Executes a command list sequentially and durably records each successful item. */ public static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, HashMap placeholders, String lane, List commands, diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index 2c2c5bd068..d34030696a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -155,9 +155,12 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio if (data.isConfigurationSection(path)) { return giveSectionRewardAsync(user, data, path, context); } - String nestedReward = data.getString(path, ""); - return nestedReward.isEmpty() ? CompletableFuture.completedFuture(null) - : giveRewardAsync(user, nestedReward, options); + String nestedReward = data.getString(path, ""); + if (nestedReward.isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Nested replay configuration could not be resolved: " + path)); + } + return giveRewardAsync(user, nestedReward, options); } public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) { @@ -207,7 +210,12 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, String rewar (command, ignoredIndex) -> MiscUtils.getInstance().executeConsoleCommandsAsync( user.getPlayerName(), command, context.getPlaceholders())); } - return giveRewardAsync(user, handler.getReward(reward), context.getOptions()); + Reward resolved = handler.getReward(reward); + if (resolved == null && context.getOptions().getAsyncReplayState() != null) { + return CompletableFuture.failedFuture( + new IllegalStateException("Nested replay reward could not be resolved: " + reward)); + } + return giveRewardAsync(user, resolved, context.getOptions()); } public void givePersistedQueueReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java index 40ff9a985c..6cbd7e9eb9 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java @@ -36,6 +36,11 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public boolean supportsAsyncSynchronization() { return false; } @@ -79,8 +84,11 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan .setIgnoreRequirements(true).setPrefix(sourceReward.getName() + "_AdvancedPriority") .withPlaceHolder(placeholders), Reward.currentReplayState(), Reward.currentReplayKey(), "selected:" + selectedKey, Reward.currentReplayOccurrenceId()); - return selected == null ? CompletableFuture.completedFuture(null) - : handler.giveRewardAsync(user, selected, childOptions).thenApply(ignored -> selected.getName()); + if (selected == null) return CompletableFuture.failedFuture( + new IllegalStateException("Selected advanced priority reward could not be resolved: " + selectedKey)); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> handler.giveRewardAsync(user, selected, childOptions)) + .thenApply(ignored -> selected.getName()); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java index acaf145bb6..35e30ebf3a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java @@ -39,6 +39,11 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public boolean supportsAsyncSynchronization() { return false; } @@ -58,16 +63,20 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat @Override public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, - ConfigurationSection section, HashMap placeholders) { - ArrayList rewards = ArrayUtils.convert(section.getKeys(false)); - if (rewards.isEmpty()) return CompletableFuture.completedFuture(null); + ConfigurationSection section, HashMap placeholders) { + ArrayList rewards = ArrayUtils.convert(section.getKeys(false)); + if (rewards.isEmpty() && !hasPendingReplayWork(placeholders)) { + return CompletableFuture.completedFuture(null); + } String selected = Reward.replaySelection(placeholders, () -> rewards.get(ThreadLocalRandom.current().nextInt(rewards.size()))); RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), Reward.currentReplayState(), Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()) .setPrefix(reward.getRewardName() + "_AdvancedRandomReward"); - return handler.giveRewardAsync(user, section, selected, childOptions).thenApply(ignored -> selected); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> handler.giveRewardAsync(user, section, selected, childOptions)) + .thenApply(ignored -> selected); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java index 187adf6334..dd6ab66136 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java @@ -62,6 +62,11 @@ public void setValue(Player player, ArrayList value) { @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public boolean supportsAsyncSynchronization() { return false; } @@ -91,7 +96,8 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor .withPlaceHolder(placeholders); Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), Reward.currentReplayKey(), "path:" + path, Reward.currentReplayOccurrenceId()); - return builder.sendAsync(user).thenApply(ignored -> null); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> builder.sendAsync(user)).thenApply(ignored -> null); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java index 341541a6d5..cbba924175 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java @@ -43,6 +43,11 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public boolean supportsAsyncSynchronization() { return false; } @@ -105,7 +110,7 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor return paths.isEmpty() ? null : String.join("\n", paths); }); if (choices == null) return CompletableFuture.completedFuture(null); - CompletionStage sequence = CompletableFuture.completedFuture(null); + CompletionStage sequence = Reward.persistReplayMetadataAsync(plugin, placeholders); com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); String parentReplayKey = Reward.currentReplayKey(); String parentOccurrenceId = Reward.currentReplayOccurrenceId(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java index 5d31d1d14e..d2b00f8a3a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java @@ -33,6 +33,11 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public boolean supportsAsyncSynchronization() { return false; } @@ -70,8 +75,11 @@ public CompletionStage onRewardRequestAsync(Reward source, AdvancedCoreU .setIgnoreRequirements(true), Reward.currentReplayState(), Reward.currentReplayKey(), "selected:" + selectedName, Reward.currentReplayOccurrenceId()); - return selected == null ? CompletableFuture.completedFuture(null) - : handler.giveRewardAsync(user, selected, childOptions).thenApply(ignored -> selected.getName()); + if (selected == null) return CompletableFuture.failedFuture( + new IllegalStateException("Selected priority reward could not be resolved: " + selectedName)); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> handler.giveRewardAsync(user, selected, childOptions)) + .thenApply(ignored -> selected.getName()); } }.asPlaceholder("Priority").addEditButton( new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("Priority", null) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java index 739701eb78..f548b3f339 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java @@ -32,6 +32,11 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public boolean supportsAsyncSynchronization() { return false; } @@ -76,15 +81,18 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor RewardOptions childOptions = Reward.withReplayState( new RewardOptions().setPlaceholders(placeholders), Reward.currentReplayState(), Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()); - return selected.isEmpty() ? CompletableFuture.completedFuture(null) - : handler.giveRewardAsync(user, selected, childOptions).thenApply(ignored -> null); + return selected.isEmpty() ? CompletableFuture.completedFuture(null) + : Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> handler.giveRewardAsync(user, selected, childOptions)) + .thenApply(ignored -> null); } String path = selection.equals("rewards") ? "Random.Rewards" : "Random.FallBack"; RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) .withPrefix(reward.getName()).withPlaceHolder(placeholders); Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), Reward.currentReplayKey(), "path:" + path, Reward.currentReplayOccurrenceId()); - return builder.sendAsync(user).thenApply(ignored -> null); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> builder.sendAsync(user)).thenApply(ignored -> null); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java index 803880a5cb..3b2bfabfe1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java @@ -32,6 +32,11 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public boolean supportsAsyncSynchronization() { return false; } @@ -49,14 +54,17 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList onRewardRequestAsync(Reward reward, AdvancedCoreUser user, ArrayList list, HashMap placeholders) { - if (list.isEmpty()) return java.util.concurrent.CompletableFuture.completedFuture(null); + if (list.isEmpty() && !hasPendingReplayWork(placeholders)) { + return java.util.concurrent.CompletableFuture.completedFuture(null); + } String selected = Reward.replaySelection(placeholders, () -> list.get(ThreadLocalRandom.current().nextInt(list.size()))); - RewardOptions childOptions = Reward.withReplayState( - new RewardOptions().setPlaceholders(placeholders), Reward.currentReplayState(), - Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()); - return handler.giveRewardAsync(user, selected, childOptions) - .thenApply(ignored -> selected); + RewardOptions childOptions = Reward.withReplayState( + new RewardOptions().setPlaceholders(placeholders), Reward.currentReplayState(), + Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> handler.giveRewardAsync(user, selected, childOptions)) + .thenApply(ignored -> selected); } }.asPlaceholder("RandomReward").priority(20).addEditButton( new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("RandomReward", null) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java index 2ce15237c4..1a21ed0dcc 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java @@ -42,6 +42,11 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + @Override public boolean supportsAsyncSynchronization() { return false; } @@ -116,12 +121,15 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor if (selected.length != 2) return CompletableFuture.completedFuture(null); for (Entry entry : map.entrySet()) { if (entry.getValue().equals(selected[0])) { - return new RewardBuilder(section, entry.getValue()) - .withPrefix(reward.getName() + "_SpecialChance").withPlaceHolder(placeholders) - .withPlaceHolder("chance", selected[1]).sendAsync(user).thenApply(ignored -> null); - } - } - return CompletableFuture.completedFuture(null); + RewardBuilder builder = new RewardBuilder(section, entry.getValue()) + .withPrefix(reward.getName() + "_SpecialChance").withPlaceHolder(placeholders) + .withPlaceHolder("chance", selected[1]); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> builder.sendAsync(user)).thenApply(ignored -> null); + } + } + return CompletableFuture.failedFuture( + new IllegalStateException("Selected special-chance reward could not be resolved: " + selected[0])); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java index 4cb4beb60d..606bf81732 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java @@ -38,6 +38,11 @@ public boolean requiresConfiguredDataForAsync() { return true; } + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplayNestedRewardSnapshot(placeholders, "nested-list:" + getPath()); + } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -52,7 +57,10 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU ConfigurationSection data, HashMap placeholders) { if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) && !isAlwaysForceNoData()) { - return java.util.concurrent.CompletableFuture.completedFuture(null); + return hasPendingReplayWork(placeholders) + ? java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException( + "Pending reward replay configuration is missing: " + getPath())) + : java.util.concurrent.CompletableFuture.completedFuture(null); } return new RewardBuilder(reward.getConfig().getConfigData(), "Rewards").withPrefix(reward.getName()) .withPlaceHolder(placeholders).sendAsync(user).thenApply(ignored -> null); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/QueuedGeneratedRewardDispatchTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/QueuedGeneratedRewardDispatchTest.java index 6d3c8b7fc1..d036084c6a 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/QueuedGeneratedRewardDispatchTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/QueuedGeneratedRewardDispatchTest.java @@ -6,12 +6,16 @@ import static org.mockito.Mockito.when; import java.nio.charset.StandardCharsets; +import java.io.File; import java.util.Base64; +import java.util.Set; import java.util.logging.Logger; +import java.util.concurrent.CompletionException; import org.bukkit.Bukkit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.mockito.MockedStatic; import com.bencodez.advancedcore.AdvancedCorePlugin; @@ -23,6 +27,8 @@ import com.bencodez.advancedcore.api.user.AdvancedCoreUser; class QueuedGeneratedRewardDispatchTest { + @TempDir + File tempDir; private static final String QUEUE_PREFIX = "\\AdvancedCoreQueue/1/"; @@ -235,4 +241,23 @@ void normalStandaloneDispatchCannotResolveGeneratedSnapshot() { verify(handler, never()).getQueuedGeneratedReward("QueuedReward", "uuid"); } + + @Test + void asyncGeneratedSnapshotRejectsAUserOutsideItsPersistedQueueCapability() throws Exception { + AdvancedCorePlugin.setInstance(plugin); + try { + java.lang.reflect.Constructor constructor = Class + .forName("com.bencodez.advancedcore.api.rewards.QueuedGeneratedReward") + .getDeclaredConstructor(File.class, String.class, Set.class); + constructor.setAccessible(true); + Reward queued = (Reward) constructor.newInstance(tempDir, "QueuedReward", Set.of("allowed")); + + org.junit.jupiter.api.Assertions.assertThrows(CompletionException.class, + () -> queued.giveRewardAsync(user, new RewardOptions()).toCompletableFuture().join()); + + verify(plugin, never()).getOptions(); + } finally { + AdvancedCorePlugin.setInstance(null); + } + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index 66b4ecf392..ceae229d89 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 @@ -1462,6 +1462,28 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, assertEquals(List.of("after-child"), events); } + @Test + void removedSubRewardsSectionCannotDropItsPendingNestedSnapshot() throws Exception { + RewardSubRewards.register(handler, plugin); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Object replayState = constructor.newInstance(new HashMap<>(), new HashMap<>(), false); + @SuppressWarnings("unchecked") + CompletionStage> snapshot = Reward.replayNestedRewardSnapshot(plugin, new HashMap<>(), + "nested-list:Rewards", List.of("First", "Second"), (Reward.ReplayState) replayState, + "AsyncReward/0"); + snapshot.toCompletableFuture().join(); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + } + @Test void everyNestedRewardInjectorWaitsForItsSelectedChild() { handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); @@ -1491,6 +1513,52 @@ void everyNestedRewardInjectorWaitsForItsSelectedChild() { assertEquals(List.of("after-child"), events); } + @Test + void randomRewardResumesItsSelectedChildAfterConfigurationRemoval() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); + when(plugin.getRewardHandler()).thenReturn(handler); + when(user.getPlugin()).thenReturn(plugin); + data.set("RandomReward", new ArrayList<>(List.of("child"))); + doReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))) + .when(handler).giveRewardAsync(eq(user), eq("child"), any(RewardOptions.class)); + RewardRandomReward.register(handler, plugin); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object initialState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + List checkpoints = new ArrayList<>(); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(initialState, + (java.util.function.Consumer) checkpoints::add); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + CompletionStage first = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + initialState, "AsyncReward", "occurrence"); + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + verify(handler, never()).giveRewardAsync(eq(user), eq("child"), any(RewardOptions.class)); + + writes.getValue().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join()); + assertEquals(1, checkpoints.size()); + assertTrue(checkpoints.get(0).getPlaceholders().keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_selection_"))); + data.set("RandomReward", null); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, + checkpoints.get(0).getPlaceholders(), 0, + state.newInstance(checkpoints.get(0).getReplayProgress(), + checkpoints.get(0).getReplayRegistryFingerprints(), false), + "AsyncReward", "occurrence"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + verify(handler, times(2)).giveRewardAsync(eq(user), eq("child"), any(RewardOptions.class)); + } + @Test void javascriptChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { ConfigurationSection javascript = data.createSection("Javascript"); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index c3688d1fea..594d4d0045 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -214,6 +214,40 @@ public void asyncListReplayDoesNotRepeatCompletedDirectCommands() { verify(misc, never()).executeConsoleCommandsAsync(eq("Ben"), eq("/inserted"), any()); } + @Test + public void snapshottedListFailsWhenANamedChildDisappears() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("First", "Missing"))); + Reward firstReward = mock(Reward.class); + Reward missingReward = mock(Reward.class); + when(firstReward.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + when(missingReward.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); + when(handler.getReward("First")).thenReturn(firstReward); + when(handler.getReward("Missing")).thenReturn(missingReward, (Reward) null); + AtomicReference checkpoint = new AtomicReference<>(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0).run(); + return null; + }).when(timer).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(timer); + RewardOptions first = new RewardOptions(); + first.setAsyncReplayCheckpointConsumer(checkpoint::set); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Rewards", first).toCompletableFuture().join()); + assertNotNull(checkpoint.get()); + RewardOptions retry = new RewardOptions() + .setPlaceholders(new java.util.HashMap<>(checkpoint.get().getPlaceholders())); + retry.setAsyncReplayCheckpointConsumer(ignored -> { }); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Rewards", retry).toCompletableFuture().join()); + verify(missingReward).giveRewardAsync(eq(user), any(RewardOptions.class)); + } + @Test public void stringRewardDispatchesNamedReward() { YamlConfiguration data = new YamlConfiguration(); @@ -364,6 +398,15 @@ public void unresolvedPersistedRewardFailsSoTheQueueCanRetainIt() { .toCompletableFuture().join()); } + @Test + public void unresolvedNestedConfigurationFailsSoReplayCannotDropIt() { + YamlConfiguration data = new YamlConfiguration(); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, data, "Removed", new RewardOptions()) + .toCompletableFuture().join()); + } + @Test public void choicesDispatchThroughRewardBuilder() { Reward mainReward = mock(Reward.class); From 166596eefae492f95022537a959d74959e739dd0 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:14:08 -0600 Subject: [PATCH 21/78] Fix async reward replay safety --- .../advancedcore/api/rewards/Reward.java | 4 + .../api/rewards/RewardExecutor.java | 56 ++++++- .../api/rewards/builtin/RewardJavascript.java | 4 +- .../rewards/builtin/RewardSpecialChance.java | 2 + .../api/user/AdvancedCoreUser.java | 20 ++- .../rewards/RewardAsyncInjectionTest.java | 145 ++++++++++++++++++ .../tests/rewards/RewardExecutorTest.java | 44 +++++- 7 files changed, 267 insertions(+), 8 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 4351520b87..0e52fea7ac 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 @@ -1371,6 +1371,10 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< return CompletableFuture.failedFuture(throwable); } if (placeholders == null) { + if (rewardOptions.getAsyncReplayCheckpointConsumer() != null || hasPersistedReplayCheckpoint(rewardOptions)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Player became unavailable before persisted reward replay")); + } return CompletableFuture.completedFuture(null); } return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, replayKey, diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index d34030696a..30f695176c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -116,6 +116,10 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio if (!plugin.isEnabled()) return disabledDispatch(); Reward.ReplayState replayState = Reward.replayStateFor(options); + if (options.getAsyncReplayOccurrenceId() == null) { + String activeOccurrenceId = Reward.currentReplayOccurrenceId(); + if (activeOccurrenceId != null) options.setAsyncReplayOccurrenceId(activeOccurrenceId); + } String parentReplayKey = options.getAsyncReplayKey(); if (parentReplayKey == null) parentReplayKey = Reward.currentReplayKey(); if (parentReplayKey == null) parentReplayKey = "list:" + path; @@ -170,7 +174,14 @@ public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewar return; } - if (Bukkit.isPrimaryThread()) { + boolean primaryThread = false; + try { + primaryThread = Bukkit.isPrimaryThread(); + } catch (IllegalStateException | NullPointerException ignored) { + // Unit tests and early bootstrap can call this facade before Bukkit has + // installed a server. There is no primary server thread to leave then. + } + if (primaryThread) { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> reward.giveReward(user, context.getOptions())); } else { @@ -182,7 +193,48 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward rewar if (reward == null) return CompletableFuture.completedFuture(null); if (!plugin.isEnabled()) return disabledDispatch(); RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); - return reward.giveRewardAsync(user, context.getOptions()); + RewardOptions options = context.getOptions(); + Reward.ReplayState activeState = Reward.currentReplayState(); + if (options.getAsyncReplayState() == null && activeState != null) options.setAsyncReplayState(activeState); + String activeKey = Reward.currentReplayKey(); + if (options.getAsyncReplayKey() == null && activeKey != null) { + options.setAsyncReplayKey(activeKey + "/" + reward.getRewardName()); + } + String activeOccurrenceId = Reward.currentReplayOccurrenceId(); + if (options.getAsyncReplayOccurrenceId() == null && activeOccurrenceId != null) { + options.setAsyncReplayOccurrenceId(activeOccurrenceId); + } + boolean primaryThread = false; + try { + primaryThread = Bukkit.isPrimaryThread(); + } catch (IllegalStateException | NullPointerException ignored) { + // Unit tests and early bootstrap have no primary server thread. + } + if (primaryThread) { + CompletableFuture dispatched = new CompletableFuture<>(); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + try { + CompletionStage result = reward.giveRewardAsync(user, options); + if (result == null) { + dispatched.completeExceptionally( + new IllegalStateException("Asynchronous reward dispatch returned no completion stage")); + } else { + result.whenComplete((ignored, failure) -> { + if (failure == null) dispatched.complete(null); + else dispatched.completeExceptionally(failure); + }); + } + } catch (Throwable failure) { + dispatched.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + dispatched.completeExceptionally(failure); + } + return dispatched; + } + return reward.giveRewardAsync(user, options); } public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java index dd6ab66136..1de4404e34 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java @@ -88,7 +88,9 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat @Override public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { - if (!section.getBoolean("Enabled")) return java.util.concurrent.CompletableFuture.completedFuture(null); + if (!section.getBoolean("Enabled") && !hasPendingReplayWork(placeholders)) { + return java.util.concurrent.CompletableFuture.completedFuture(null); + } String path = Reward.replaySelection(placeholders, () -> new JavascriptEngine().addPlayer(user.getOfflinePlayer()).addPlaceholders(placeholders) .getBooleanValue(section.getString("Expression")) ? "TrueRewards" : "FalseRewards"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java index 1a21ed0dcc..87cbb39505 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java @@ -124,6 +124,8 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor RewardBuilder builder = new RewardBuilder(section, entry.getValue()) .withPrefix(reward.getName() + "_SpecialChance").withPlaceHolder(placeholders) .withPlaceHolder("chance", selected[1]); + Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), + Reward.currentReplayKey(), "path:" + entry.getValue(), Reward.currentReplayOccurrenceId()); return Reward.persistReplayMetadataAsync(plugin, placeholders) .thenCompose(ignored -> builder.sendAsync(user)).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 525caceda0..75e79bd3b5 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 @@ -107,7 +107,8 @@ private synchronized boolean add(Supplier> action, String String fingerprint = Reward.legacyActionFingerprint(descriptor); int occurrence = actionOccurrences.getOrDefault(fingerprint, 0); actionOccurrences.put(fingerprint, occurrence + 1); - actions.add(new ReplayAction(fingerprint + "/" + occurrence, fingerprint, action)); + actions.add(new ReplayAction(fingerprint + "/" + occurrence, fingerprint, action, + !descriptor.startsWith("failure:"))); return true; } @@ -118,7 +119,9 @@ private boolean belongsTo(AdvancedCoreUser user) { private synchronized CompletionStage closeAndAwait() { closed = true; HashMap currentSnapshot = new HashMap<>(); - for (ReplayAction action : actions) currentSnapshot.put(action.identity, action.fingerprint); + for (ReplayAction action : actions) { + if (action.durable) currentSnapshot.put(action.identity, action.fingerprint); + } HashMap persistedSnapshot; HashSet completed; try { @@ -137,6 +140,13 @@ private synchronized CompletionStage closeAndAwait() { boolean snapshotChanged = persistedSnapshot.isEmpty(); if (snapshotChanged) persistedSnapshot = new HashMap<>(currentSnapshot); else if (!persistedSnapshot.equals(currentSnapshot)) { + for (Entry persisted : persistedSnapshot.entrySet()) { + if (!completed.contains(persisted.getKey()) + && !persisted.getValue().equals(currentSnapshot.get(persisted.getKey()))) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Cannot safely resume because an unfinished legacy reward action changed or disappeared")); + } + } // The actions are deliberately matched individually below. Retaining the // original snapshot lets a reordered, shortened, or extended config skip // only the exact effects known to have completed. New actions are added @@ -160,6 +170,7 @@ else if (!persistedSnapshot.equals(currentSnapshot)) { return CompletableFuture.failedFuture(failure); } }).thenCompose(ignored -> { + if (!action.durable) return CompletableFuture.completedFuture(null); completed.add(action.identity); recordReplayValue(checkpointKey, encodeCompleted(completed)); return checkpoint(); @@ -243,11 +254,14 @@ private static final class ReplayAction { private final String identity; private final String fingerprint; private final Supplier> action; + private final boolean durable; - private ReplayAction(String identity, String fingerprint, Supplier> action) { + private ReplayAction(String identity, String fingerprint, Supplier> action, + boolean durable) { this.identity = identity; this.fingerprint = fingerprint; this.action = action; + this.durable = durable; } } } 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 ceae229d89..8110bb1bab 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 @@ -26,6 +26,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -67,6 +68,7 @@ import com.bencodez.advancedcore.api.rewards.builtin.RewardSubRewards; import com.bencodez.advancedcore.api.rewards.builtin.RewardRandomReward; import com.bencodez.advancedcore.api.rewards.builtin.RewardJavascript; +import com.bencodez.advancedcore.api.rewards.builtin.RewardSpecialChance; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.thread.FileThread; @@ -621,6 +623,63 @@ void legacyActionSnapshotsSurviveReorderRemovalAndExtension() throws Exception { verify(economy).depositPlayer(offlinePlayer, 3D); } + @Test + void legacyActionReplayRejectsRemovalOfAnUnfinishedAction() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("RemovedUnfinishedAction"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + AtomicReference> amounts = new AtomicReference<>(new ArrayList<>(List.of(1D, 2D))); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + for (double amount : amounts.get()) realUser.giveMoney(amount); + return CompletableFuture.completedFuture(null); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + CompletionStage first = reward.giveInjectedRewardsAsync(realUser, new HashMap<>()); + queued.remove(0).run(); + enabled.set(false); + queued.remove(0).run(); + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows( + java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join())); + + enabled.set(true); + amounts.set(new ArrayList<>(List.of(1D))); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + assertTrue(queued.isEmpty(), "no action may run after an unfinished persisted action disappears"); + } + } + @Test void itemActionFingerprintIsCanonicalAndIncludesMetadata() throws Exception { LinkedHashMap first = new LinkedHashMap<>(); @@ -1589,6 +1648,92 @@ void javascriptChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { assertFalse(nestedOptions.getPlaceholders().isEmpty()); } + @Test + void disabledJavascriptResumesItsPersistedSelectedChild() throws Exception { + ConfigurationSection javascript = data.createSection("Javascript"); + javascript.set("Enabled", true); + javascript.set("Expression", "true"); + javascript.createSection("TrueRewards"); + RewardJavascript.register(handler, plugin); + AtomicInteger sends = new AtomicInteger(); + + try (MockedConstruction engines = mockConstruction(JavascriptEngine.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (engine, context) -> when(engine.getBooleanValue("true")).thenReturn(true)); + MockedConstruction builders = mockConstruction(RewardBuilder.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> { + RewardOptions nested = new RewardOptions(); + when(builder.getRewardOptions()).thenReturn(nested); + when(builder.withPlaceHolder(any())).thenAnswer(invocation -> { + nested.withPlaceHolder(invocation.getArgument(0)); + return builder; + }); + when(builder.sendAsync(user)).thenAnswer(ignored -> sends.getAndIncrement() == 0 + ? CompletableFuture.failedFuture(new IllegalStateException("temporary child failure")) + : CompletableFuture.completedFuture(null)); + })) { + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows(CompletionException.class, + () -> reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join())); + javascript.set("Enabled", false); + + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + resumed.toCompletableFuture().join(); + + assertEquals(2, sends.get()); + assertEquals(1, engines.constructed().size(), "a persisted selection must not rerun JavaScript"); + } + } + + @Test + void specialChanceChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { + ConfigurationSection specialChance = data.createSection("SpecialChance"); + specialChance.createSection("1"); + RewardSpecialChance.register(handler, plugin); + RewardOptions nestedOptions = new RewardOptions(); + + try (MockedConstruction builders = mockConstruction(RewardBuilder.class, + org.mockito.Mockito.withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> { + when(builder.getRewardOptions()).thenReturn(nestedOptions); + when(builder.withPlaceHolder(any())).thenAnswer(invocation -> { + nestedOptions.withPlaceHolder(invocation.getArgument(0)); + return builder; + }); + when(builder.sendAsync(user)).thenReturn(CompletableFuture.completedFuture(null)); + })) { + reward.giveInjectedRewardsAsync(user, new HashMap<>()).toCompletableFuture().join(); + } + + assertNotNull(nestedOptions.getAsyncReplayState()); + assertTrue(nestedOptions.getAsyncReplayKey().endsWith("/path:1")); + assertFalse(nestedOptions.getPlaceholders().isEmpty()); + } + + @Test + void persistedReplayFailsWhenItsPlayerDisappearsBeforePreparation() { + when(user.getPlayerName()).thenReturn("Departed"); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> { }); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer("Departed")).thenReturn(null); + CompletionStage result = reward.giveRewardUserAsync(user, new HashMap<>(), options); + CompletionException failure = assertThrows(CompletionException.class, + () -> result.toCompletableFuture().join()); + assertTrue(failure.getCause().getMessage().contains("Player became unavailable")); + } + } + @Test void replayCheckpointSkipsAlreadyAppliedInjectionAfterLaterFailure() throws Exception { AtomicInteger alreadyApplied = new AtomicInteger(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 594d4d0045..3f1e7e356d 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -130,7 +131,7 @@ public void listRewardDispatchesEveryNamedReward() { } @Test - public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { + public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { YamlConfiguration data = new YamlConfiguration(); data.set("Rewards", new ArrayList<>(List.of("Child", "Child"))); Reward child = mock(Reward.class); @@ -138,7 +139,11 @@ public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { when(child.giveRewardAsync(eq(user), any(RewardOptions.class))) .thenReturn(CompletableFuture.completedFuture(null)); - executor.giveRewardAsync(user, data, "Rewards", new RewardOptions()).toCompletableFuture().join(); + try (MockedStatic replay = mockStatic(Reward.class, + org.mockito.Mockito.CALLS_REAL_METHODS)) { + replay.when(Reward::currentReplayOccurrenceId).thenReturn("parent-occurrence"); + executor.giveRewardAsync(user, data, "Rewards", new RewardOptions()).toCompletableFuture().join(); + } ArgumentCaptor captured = ArgumentCaptor.forClass(RewardOptions.class); verify(child, org.mockito.Mockito.times(2)).giveRewardAsync(eq(user), captured.capture()); @@ -146,8 +151,43 @@ public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { assertFalse(children.get(0) == children.get(1)); assertFalse(children.get(0).getAsyncReplayKey().equals(children.get(1).getAsyncReplayKey())); assertSame(children.get(0).getAsyncReplayState(), children.get(1).getAsyncReplayState()); + assertEquals("parent-occurrence", children.get(0).getAsyncReplayOccurrenceId()); + assertEquals("parent-occurrence", children.get(1).getAsyncReplayOccurrenceId()); } + @Test + public void asyncRewardDispatchLeavesThePrimaryThreadAndRetainsCompletion() { + Reward child = mock(Reward.class); + CompletableFuture childResult = new CompletableFuture<>(); + when(child.giveRewardAsync(eq(user), any(RewardOptions.class))).thenReturn(childResult); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + ArgumentCaptor dispatch = ArgumentCaptor.forClass(Runnable.class); + RewardOptions options = new RewardOptions(); + Reward.ReplayState replayState = Reward.replayStateFor(new RewardOptions()); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic replay = mockStatic(Reward.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + replay.when(Reward::currentReplayState).thenReturn(replayState); + replay.when(Reward::currentReplayKey).thenReturn("parent"); + replay.when(Reward::currentReplayOccurrenceId).thenReturn("occurrence"); + when(child.getRewardName()).thenReturn("child"); + CompletionStage result = executor.giveRewardAsync(user, child, options); + + verify(scheduler).runTaskAsynchronously(eq(plugin), dispatch.capture()); + verify(child, never()).giveRewardAsync(eq(user), any(RewardOptions.class)); + assertSame(replayState, options.getAsyncReplayState()); + assertEquals("parent/child", options.getAsyncReplayKey()); + assertEquals("occurrence", options.getAsyncReplayOccurrenceId()); + assertFalse(result.toCompletableFuture().isDone()); + dispatch.getValue().run(); + assertFalse(result.toCompletableFuture().isDone()); + childResult.completeExceptionally(new IllegalStateException("child failed")); + assertThrows(CompletionException.class, () -> result.toCompletableFuture().join()); + } + } + @Test public void asyncListLazilyClonesAndCarriesOnlyReplayMetadata() { YamlConfiguration data = new YamlConfiguration(); From fe3e41998dda269c4a89ab517c6eaf4f244d9b8d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:02:51 -0600 Subject: [PATCH 22/78] Release unstarted replay reservations --- .../api/user/AdvancedCoreUser.java | 48 +++++++++++-- .../rewards/RewardAsyncInjectionTest.java | 67 ++++++++++++++++++- 2 files changed, 107 insertions(+), 8 deletions(-) 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 75e79bd3b5..b456ed00b1 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 @@ -159,13 +159,26 @@ else if (!persistedSnapshot.equals(currentSnapshot)) { recordReplayValue(snapshotKey, encodeSnapshot(persistedSnapshot)); result = checkpoint(); } + HashMap snapshotLedger = persistedSnapshot; for (ReplayAction action : actions) { if (completed.contains(action.identity)) continue; result = result.thenCompose(ignored -> { try { CompletionStage stage = action.action.get(); - return stage == null ? CompletableFuture.failedFuture( - new IllegalStateException("Scheduled reward action returned null")) : stage; + if (stage == null) return CompletableFuture.failedFuture( + new IllegalStateException("Scheduled reward action returned null")); + return stage.exceptionallyCompose(failure -> { + Throwable cause = unwrapCompletionFailure(failure); + if (!(cause instanceof LegacyActionNotStartedException)) { + return CompletableFuture.failedFuture(cause); + } + // Scheduler rejection, shutdown-before-dispatch, and the bounded + // pre-dispatch timeout all prove the action never began. Release its + // reservation so a retry may safely regenerate a random payload. + snapshotLedger.remove(action.identity); + recordReplayValue(snapshotKey, encodeSnapshot(snapshotLedger)); + return checkpoint().thenCompose(unused -> CompletableFuture.failedFuture(cause)); + }); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } @@ -179,6 +192,15 @@ else if (!persistedSnapshot.equals(currentSnapshot)) { return result; } + private static Throwable unwrapCompletionFailure(Throwable failure) { + Throwable current = failure; + while ((current instanceof java.util.concurrent.CompletionException + || current instanceof java.util.concurrent.ExecutionException) && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + private String readReplayValue(String key) { String value = placeholders == null ? null : placeholders.get(key); return value == null && replayState != null ? replayState.replayMetadata(key) : value; @@ -266,6 +288,19 @@ private ReplayAction(String identity, String fingerprint, Supplier> act Runnable dispatch = () -> { if (!claimed.compareAndSet(false, true)) return; if (!plugin.isEnabled()) { - completion.completeExceptionally(new IllegalStateException("Plugin disabled before scheduled reward action ran")); + completion.completeExceptionally(new LegacyActionNotStartedException( + "Plugin disabled before scheduled reward action ran")); return; } try { @@ -1796,11 +1832,13 @@ private void scheduleLegacyRewardActionAsync(Supplier> act else getPlugin().getBukkitScheduler().runTask(plugin, dispatch); } catch (Throwable failure) { claimed.set(true); - completion.completeExceptionally(failure); + completion.completeExceptionally(new LegacyActionNotStartedException( + "Scheduler rejected reward action before dispatch", failure)); } CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS).execute(() -> { if (claimed.compareAndSet(false, true)) { - completion.completeExceptionally(new TimeoutException("Timed out waiting for scheduled reward action")); + completion.completeExceptionally(new LegacyActionNotStartedException( + "Timed out waiting for scheduled reward action", new TimeoutException())); } }); return completion; 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 8110bb1bab..c82d27de92 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 @@ -485,6 +485,65 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, } } + @Test + void schedulerRejectionReleasesUnstartedRandomActionForReplay() throws Exception { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("RandomReplay"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + AtomicReference amount = new AtomicReference<>(1D); + AtomicBoolean reject = new AtomicBoolean(true); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + if (reject.get()) throw new IllegalStateException("scheduler stopped"); + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(amount.get()); + return CompletableFuture.completedFuture(null); + } + }); + + UUID uuid = UUID.fromString(realUser.getUUID()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + Reward.RewardReplayFailure checkpoint = findCheckpoint(assertThrows( + java.util.concurrent.CompletionException.class, + () -> reward.giveInjectedRewardsAsync(realUser, new HashMap<>()).toCompletableFuture().join())); + + amount.set(2D); + reject.set(false); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + CompletionStage resumed = (CompletionStage) replay.invoke(reward, realUser, + checkpoint.getReplayPlaceholders(), 0, + state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), + "AsyncReward"); + assertEquals(1, queued.size(), "the safely unstarted action is regenerated on retry"); + queued.remove(0).run(); + resumed.toCompletableFuture().join(); + } + verify(economy, never()).depositPlayer(offlinePlayer, 1D); + verify(economy).depositPlayer(offlinePlayer, 2D); + } + @Test void capturedAsyncContinuationActionCheckpointsRetryOnlyTheUnfinishedSuffix() throws Exception { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); @@ -624,7 +683,7 @@ void legacyActionSnapshotsSurviveReorderRemovalAndExtension() throws Exception { } @Test - void legacyActionReplayRejectsRemovalOfAnUnfinishedAction() throws Exception { + void legacyActionReplayAllowsRemovalOfAConclusiveUnstartedAction() throws Exception { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); when(config.isOnlineMode()).thenReturn(true); when(plugin.getOptions()).thenReturn(config); @@ -675,9 +734,11 @@ void legacyActionReplayRejectsRemovalOfAnUnfinishedAction() throws Exception { checkpoint.getReplayPlaceholders(), 0, state.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false), "AsyncReward"); - assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); - assertTrue(queued.isEmpty(), "no action may run after an unfinished persisted action disappears"); + resumed.toCompletableFuture().join(); + assertTrue(queued.isEmpty(), "an action proven unstarted may disappear before retry"); } + verify(economy).depositPlayer(offlinePlayer, 1D); + verify(economy, never()).depositPlayer(offlinePlayer, 2D); } @Test From e8519be18fe137062cb31e6b1f0efc4e1d70722e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:15:50 -0600 Subject: [PATCH 23/78] Release unstarted item replay reservations --- .../api/item/FullInventoryHandler.java | 17 +++++++++----- .../api/user/AdvancedCoreUser.java | 15 ++++++++++++ .../tests/item/FullInventoryHandlerTest.java | 23 +++++++++++++++++-- 3 files changed, 47 insertions(+), 8 deletions(-) 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 f7b0980dcf..a8130f05af 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 @@ -25,6 +25,7 @@ import org.bukkit.inventory.ItemStack; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.data.ServerData; import com.bencodez.simpleapi.messages.MessageAPI; @@ -111,11 +112,13 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu try { playerId = player.getUniqueId(); if (playerId == null) { - completion.completeExceptionally(new IllegalStateException("Item delivery player has no UUID")); + completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Item delivery player has no UUID")); return; } } catch (Throwable failure) { - completion.completeExceptionally(failure); + completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Unable to resolve item delivery player before dispatch", failure)); return; } } @@ -137,13 +140,15 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu plugin.getBukkitScheduler().runTask(plugin, delivery, player); } catch (Throwable failure) { deliveryClaimed.set(true); - if (completion != null) completion.completeExceptionally(failure); + if (completion != null) completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Scheduler rejected item delivery before dispatch", failure)); else rethrowDeliveryFailure(failure); } if (completion == null) return; CompletableFuture.delayedExecutor(getItemDeliveryTimeoutMillis(), TimeUnit.MILLISECONDS).execute(() -> { if (deliveryClaimed.compareAndSet(false, true)) { - completion.completeExceptionally(new TimeoutException("Timed out waiting for item delivery")); + completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Timed out waiting for item delivery", new TimeoutException())); } }); } @@ -161,11 +166,11 @@ protected long getItemDeliveryTimeoutMillis() { */ private void validateReplayDeliveryTarget(Player player, UUID playerId) { if (!plugin.isEnabled()) { - throw new IllegalStateException("Plugin disabled before item delivery"); + throw AdvancedCoreUser.replayActionNotStarted("Plugin disabled before item delivery"); } Player current = Bukkit.getPlayer(playerId); if (current != player || !current.isOnline()) { - throw new IllegalStateException("Player became unavailable before item delivery"); + throw AdvancedCoreUser.replayActionNotStarted("Player became unavailable before item delivery"); } } 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 b456ed00b1..930687394e 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 @@ -301,6 +301,21 @@ private LegacyActionNotStartedException(String message, Throwable cause) { } } + /** Internal cross-package signal for a replay action proven not to have begun. */ + public static RuntimeException replayActionNotStarted(String message) { + return new LegacyActionNotStartedException(message); + } + + /** Internal cross-package signal for scheduler rejection before a replay action began. */ + public static RuntimeException replayActionNotStarted(String message, Throwable cause) { + return new LegacyActionNotStartedException(message, cause); + } + + /** Returns whether a completion failed before its replay-aware action began. */ + public static boolean isReplayActionNotStarted(Throwable failure) { + return AsyncActionCollection.unwrapCompletionFailure(failure) instanceof LegacyActionNotStartedException; + } + /** * Explicitly carries an async injector's originating action scope into a * completion callback. Injectors capture this while their request method is 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 5cdb24252b..32b74fbacb 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 @@ -37,6 +37,7 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.data.ServerData; import com.bencodez.simpleapi.scheduler.BukkitScheduler; @@ -164,11 +165,28 @@ public void giveItemAsyncFailsWithoutMutationWhenPlayerDisconnectsAfterEnqueue() task.getValue().run(); } - assertThrows(java.util.concurrent.ExecutionException.class, + java.util.concurrent.ExecutionException failure = assertThrows(java.util.concurrent.ExecutionException.class, () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertTrue(AdvancedCoreUser.isReplayActionNotStarted(failure)); verify(inventory, never()).addItem(item); } + @Test + public void rejectedItemSchedulerSignalsThatDeliveryNeverStarted() throws Exception { + Fixture fixture = createFixture(); + Player player = mock(Player.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + org.mockito.Mockito.doThrow(new IllegalStateException("scheduler stopped")) + .when(fixture.bukkitScheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(player)); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + java.util.concurrent.ExecutionException failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + + assertTrue(AdvancedCoreUser.isReplayActionNotStarted(failure)); + } + @Test public void timedOutGiveItemAsyncSuppressesTheQueuedDeliveryToPreventReplayDuplicates() throws Exception { Fixture fixture = createFixture(0L); @@ -181,8 +199,9 @@ public void timedOutGiveItemAsyncSuppressesTheQueuedDeliveryToPreventReplayDupli when(inventory.addItem(item)).thenReturn(new HashMap<>()); CompletionStage delivery = fixture.handler.giveItemAsync(player, item); - assertThrows(java.util.concurrent.ExecutionException.class, + java.util.concurrent.ExecutionException failure = assertThrows(java.util.concurrent.ExecutionException.class, () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertTrue(AdvancedCoreUser.isReplayActionNotStarted(failure)); ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); From 4445156f6dc037535fc99579dfc680f5b0d17a07 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:21:50 -0600 Subject: [PATCH 24/78] Release disconnected item replay reservations --- .../com/bencodez/advancedcore/api/user/AdvancedCoreUser.java | 2 +- .../advancedcore/tests/rewards/RewardAsyncInjectionTest.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 930687394e..cdab0dba5e 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 @@ -1705,7 +1705,7 @@ private void scheduleLegacyItemAction(Player player, ItemStack... item) { descriptor.append('\n').append(itemDescriptor(current)); } if (collectAsyncAction(() -> player == null ? CompletableFuture.failedFuture( - new IllegalStateException("Player became unavailable before item reward delivery")) + replayActionNotStarted("Player became unavailable before item reward delivery")) : plugin.getFullInventoryHandler().giveItemAsync(player, item), descriptor.toString())) { return; } 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 c82d27de92..ffeaa08e5d 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 @@ -224,6 +224,7 @@ void replayItemsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() Player player = mock(Player.class); when(player.getDisplayName()).thenReturn("Dispatch"); AtomicReference availablePlayer = new AtomicReference<>(player); + AtomicReference itemType = new AtomicReference<>(Material.STONE); AtomicReference queuedInjection = new AtomicReference<>(); doAnswer(invocation -> { queuedInjection.set(invocation.getArgument(1, Runnable.class)); @@ -234,7 +235,7 @@ void replayItemsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() try (MockedConstruction builders = mockConstruction(ItemBuilder.class, (builder, context) -> { when(builder.setPlaceholders(any(HashMap.class))).thenReturn(builder); - when(builder.toItemStack(any(Player.class))).thenReturn(new ItemStack(Material.STONE)); + when(builder.toItemStack(any(Player.class))).thenAnswer(ignored -> new ItemStack(itemType.get())); }); org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { bukkit.when(() -> Bukkit.getPlayer(uuid)).thenAnswer(ignored -> availablePlayer.get()); CompletionStage delivery = reward.giveRewardUserAsync(realUser, new HashMap<>(), new RewardOptions()); @@ -257,6 +258,7 @@ void replayItemsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() verify(inventory, never()).giveItemAsync(any(Player.class), any(ItemStack.class)); availablePlayer.set(player); + itemType.set(Material.DIRT); when(inventory.giveItemAsync(eq(player), any(ItemStack.class))) .thenReturn(CompletableFuture.completedFuture(null)); doAnswer(invocation -> { From da1fc66f9a484d1e9e61586bf68d308470e45515 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:28:42 -0600 Subject: [PATCH 25/78] Release unstarted player reward reservations --- .../api/user/AdvancedCoreUser.java | 4 ++-- .../rewards/RewardAsyncInjectionTest.java | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) 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 cdab0dba5e..f33924f46a 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 @@ -1876,11 +1876,11 @@ private void scheduleLegacyRewardActionAsync(Supplier> act /** Runs on the player-owned scheduler before a replay-aware player mutation. */ private void validateLiveScheduledPlayer(Player player) { if (player == null || player.getUniqueId() == null) { - throw new IllegalStateException("Scheduled player reward has no live player identity"); + throw replayActionNotStarted("Scheduled player reward has no live player identity"); } Player current = Bukkit.getPlayer(player.getUniqueId()); if (current != player || !current.isOnline()) { - throw new IllegalStateException("Player became unavailable before scheduled reward delivery"); + throw replayActionNotStarted("Player became unavailable before scheduled reward delivery"); } } 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 ffeaa08e5d..dd814cd46d 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 @@ -423,7 +423,24 @@ void replayExperienceRemainsPendingForNullAndDisconnectedPlayers() throws Except assertNotNull(queuedExperience.get()); availablePlayer.set(null); queuedExperience.get().run(); - assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); + Throwable scheduledFailure = assertThrows(java.util.concurrent.CompletionException.class, + () -> resumed.toCompletableFuture().join()); + Reward.RewardReplayFailure retryCheckpoint = findCheckpoint(scheduledFailure); + + // The disconnected owner task never mutated the player, so its reservation + // must be released and a changed retry payload must be accepted. + data.set("EXP", 7); + availablePlayer.set(player); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + CompletionStage changedRetry = (CompletionStage) replay.invoke(reward, realUser, + retryCheckpoint.getReplayPlaceholders(), 0, + state.newInstance(retryCheckpoint.getReplayProgress(), + retryCheckpoint.getReplayRegistryFingerprints(), false), "AsyncReward"); + changedRetry.toCompletableFuture().join(); + verify(player).giveExp(7); } verify(player, never()).giveExp(5); } From 4f5d16ce23027ce2d7a79155882e1a0c40bfa15b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:01:04 -0600 Subject: [PATCH 26/78] Fix replay state reuse across reward sends --- .../advancedcore/api/rewards/Reward.java | 16 ++++++--- .../rewards/RewardAsyncInjectionTest.java | 35 +++++++++++++++++++ 2 files changed, 46 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 0e52fea7ac..9eee684230 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 @@ -457,9 +457,17 @@ public static ReplayState replayStateFor(RewardOptions options) { ReplayState replayState = options.getAsyncReplayState(); if (replayState == null) { replayState = ACTIVE_REPLAY_STATE.get(); - if (replayState == null) replayState = new ReplayState(options.getAsyncReplayProgress(), - options.getAsyncReplayRegistryFingerprints(), options.isLegacyAsyncReplayCheckpoint()); - options.setAsyncReplayState(replayState); + if (replayState == null) { + // A caller may reuse its RewardOptions for independent recipients. Keep + // fresh top-level execution state local to this dispatch so completed + // stages cannot leak into the next send. + replayState = new ReplayState(options.getAsyncReplayProgress(), + options.getAsyncReplayRegistryFingerprints(), options.isLegacyAsyncReplayCheckpoint()); + } else { + // Deferred/nested options must retain their explicitly inherited parent + // state after the current thread-local scope ends. + options.setAsyncReplayState(replayState); + } } if (options.getAsyncReplayCheckpointConsumer() != null) { replayState.setCheckpointConsumer(options.getAsyncReplayCheckpointConsumer()); @@ -1348,7 +1356,6 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< if (replayKey == null) { String parentKey = ACTIVE_REPLAY_KEY.get(); replayKey = parentKey == null ? getRewardName() : parentKey + "/" + getRewardName(); - rewardOptions.setAsyncReplayKey(replayKey); } if (!replayState.matchesRegistryFingerprint(injectionRegistryFingerprint(orderedInjectedRewards()))) { return CompletableFuture.failedFuture(new IncompatibleReplayCheckpointException(replayKey)); @@ -1361,7 +1368,6 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< // inventing a different id for each retry. if (occurrenceId == null && rewardOptions.getAsyncReplayCheckpointConsumer() == null) { occurrenceId = UUID.randomUUID().toString(); - rewardOptions.setAsyncReplayOccurrenceId(occurrenceId); } } final HashMap placeholders; 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 dd814cd46d..7ba60b9a43 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 @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -131,6 +133,39 @@ void tearDown() { AdvancedCorePlugin.setInstance(null); } + @Test + void reusedTopLevelOptionsDoNotShareCompletedReplayState() { + AtomicInteger invocations = new AtomicInteger(); + Player player = mock(Player.class); + when(user.getPlayer()).thenReturn(player); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + handler.getInjectedRewards().add(new RewardInject("Async") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + invocations.incrementAndGet(); + return CompletableFuture.completedFuture(null); + } + }); + RewardOptions options = new RewardOptions().forceOffline(); + + Reward.ReplayState firstDispatch = Reward.replayStateFor(options); + Reward.ReplayState secondDispatch = Reward.replayStateFor(options); + reward.giveRewardUserAsync(user, new HashMap<>(), options).toCompletableFuture().join(); + reward.giveRewardUserAsync(user, new HashMap<>(), options).toCompletableFuture().join(); + + assertNotSame(firstDispatch, secondDispatch); + assertEquals(2, invocations.get()); + assertNull(options.getAsyncReplayState()); + assertNull(options.getAsyncReplayKey()); + assertNull(options.getAsyncReplayOccurrenceId()); + } + @Test void legacyScheduledRewardsWaitForQueuedBukkitActions() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From d09c29df83293e69b197cb2bff70ee15ab7717e0 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:18:05 -0600 Subject: [PATCH 27/78] Await asynchronous choice rewards --- .../api/rewards/builtin/RewardChoices.java | 36 +++++++++++++++++ .../rewards/injected/RewardInjectBoolean.java | 40 +++++++++++++++---- .../rewards/BuiltinRewardBehaviorTest.java | 18 +++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java index 34494a81d6..64eac3b12c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -13,6 +15,7 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.rewards.DefinedReward; import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.advancedcore.api.rewards.RewardEditData; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.SubDirectlyDefinedReward; @@ -29,6 +32,20 @@ private RewardChoices() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectBoolean("EnableChoices") { + @Override + public boolean supportsAsyncRequest() { return true; } + + @Override + public boolean requiresConfiguredDataForAsync() { return true; } + + @Override + public boolean hasPendingReplayWork(HashMap placeholders) { + return Reward.hasReplaySelection(placeholders); + } + + @Override + public boolean supportsAsyncSynchronization() { return false; } + @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean value, HashMap placeholders) { @@ -45,6 +62,25 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean valu return null; } + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, boolean value, + HashMap placeholders) { + if (!value && !hasPendingReplayWork(placeholders)) return CompletableFuture.completedFuture(null); + String choice = Reward.replaySelection(placeholders, + () -> value ? user.getChoicePreference(reward.getName()) : null); + if (choice == null || choice.isEmpty() || choice.equalsIgnoreCase("none")) { + user.addUnClaimedChoiceReward(reward.getName()); + return CompletableFuture.completedFuture(null); + } + RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), + reward.getConfig().getChoicesRewardsPath(choice)).withPrefix(reward.getName()) + .withPlaceHolder(placeholders).withPlaceHolder("choice", choice); + Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), + Reward.currentReplayKey(), "choice:" + choice, Reward.currentReplayOccurrenceId()); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> builder.sendAsync(user)).thenApply(ignored -> choice); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectBoolean.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectBoolean.java index 077ead5ace..75dc295fe7 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectBoolean.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectBoolean.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.api.rewards.injected; -import java.util.HashMap; +import java.util.HashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.bukkit.configuration.ConfigurationSection; @@ -26,8 +28,18 @@ public RewardInjectBoolean(String path, boolean defaultValue) { this.defaultValue = defaultValue; } - public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean num, - HashMap placeholders); + public abstract String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean num, + HashMap placeholders); + + /** Completion-aware counterpart for boolean injections. */ + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, boolean value, + HashMap placeholders) { + try { + return CompletableFuture.completedFuture(onRewardRequest(reward, user, value, placeholders)); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ConfigurationSection data, @@ -42,7 +54,21 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, Configuratio } return re; } - return null; - } - -} + return null; + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, + ConfigurationSection data, HashMap placeholders) { + if (!(data.isBoolean(getPath()) || (isAlwaysForce() && data.contains(getPath(), true)) + || isAlwaysForceNoData())) { + if (!hasPendingReplayWork(placeholders)) return CompletableFuture.completedFuture(null); + } + boolean value = data.getBoolean(getPath(), isDefaultValue()); + CompletionStage result = onRewardRequestAsync(reward, user, value, placeholders); + if (result == null) return CompletableFuture.failedFuture(new IllegalStateException( + "Reward injection returned a null asynchronous result: " + getPath())); + return result.thenApply(valueResult -> (Object) (valueResult == null ? String.valueOf(value) : valueResult)); + } + +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 5f3ac2c656..0a53e1e1bf 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -660,6 +660,24 @@ public void choicesActuallyQueuesOrDispatchesSelectedChoice() { verify(handler).giveChoicesReward(reward, user, "OptionA"); } + @Test + public void choicesAsyncDispatchWaitsForSelectedReward() { + RewardChoices.register(handler, plugin); + RewardInjectBoolean choices = (RewardInjectBoolean) injects.get(0); + when(user.getChoicePreference("SourceReward")).thenReturn("OptionA"); + CompletableFuture child = new CompletableFuture<>(); + + try (MockedConstruction builders = mockConstruction(RewardBuilder.class, + withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> when(builder.sendAsync(user)).thenReturn(child))) { + CompletionStage result = choices.onRewardRequestAsync(reward, user, true, placeholders); + assertFalse(result.toCompletableFuture().isDone()); + child.complete(null); + assertEquals("OptionA", result.toCompletableFuture().join()); + verify(builders.constructed().get(0)).sendAsync(user); + } + } + @Test public void javascriptActuallyExecutesScriptsAndTrueBranch() { RewardJavascript.register(handler, plugin); From 78609c3a3f1a72da4f289bfa55e0b68e80c4d949 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:08:03 -0600 Subject: [PATCH 28/78] Persist unclaimed choice selection before enqueue --- .../api/rewards/builtin/RewardChoices.java | 6 ++- .../rewards/RewardAsyncInjectionTest.java | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java index 64eac3b12c..3f615a00d0 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java @@ -69,8 +69,10 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU String choice = Reward.replaySelection(placeholders, () -> value ? user.getChoicePreference(reward.getName()) : null); if (choice == null || choice.isEmpty() || choice.equalsIgnoreCase("none")) { - user.addUnClaimedChoiceReward(reward.getName()); - return CompletableFuture.completedFuture(null); + return Reward.persistReplayMetadataAsync(plugin, placeholders).thenApply(ignored -> { + user.addUnClaimedChoiceReward(reward.getName()); + return null; + }); } RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), reward.getConfig().getChoicesRewardsPath(choice)).withPrefix(reward.getName()) 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 7ba60b9a43..e6edf36897 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 @@ -70,6 +70,7 @@ import com.bencodez.advancedcore.api.rewards.builtin.RewardSubRewards; import com.bencodez.advancedcore.api.rewards.builtin.RewardRandomReward; import com.bencodez.advancedcore.api.rewards.builtin.RewardJavascript; +import com.bencodez.advancedcore.api.rewards.builtin.RewardChoices; import com.bencodez.advancedcore.api.rewards.builtin.RewardSpecialChance; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; @@ -1733,6 +1734,45 @@ void randomRewardResumesItsSelectedChildAfterConfigurationRemoval() throws Excep verify(handler, times(2)).giveRewardAsync(eq(user), eq("child"), any(RewardOptions.class)); } + @Test + void unclaimedChoiceIsAddedOnlyAfterItsSelectionIsDurable() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + data.set("EnableChoices", true); + when(user.getChoicePreference("AsyncReward")).thenReturn(""); + RewardChoices.register(handler, plugin); + List writes = new ArrayList<>(); + doAnswer(invocation -> { + writes.add(invocation.getArgument(0)); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor stateConstructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + stateConstructor.setAccessible(true); + Object replayState = stateConstructor.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward"); + + assertEquals(1, writes.size()); + verify(user, never()).addUnClaimedChoiceReward("AsyncReward"); + writes.get(0).run(); + verify(user).addUnClaimedChoiceReward("AsyncReward"); + assertEquals(2, writes.size()); + writes.get(1).run(); + result.toCompletableFuture().join(); + } + @Test void javascriptChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { ConfigurationSection javascript = data.createSection("Javascript"); From c56807995c0ca93e59272116bdb1adf02b5e4ad2 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:17:22 -0600 Subject: [PATCH 29/78] Make unclaimed choice replay idempotent --- .../api/rewards/builtin/RewardChoices.java | 3 +- .../api/user/AdvancedCoreUser.java | 66 +++++++++++++++++-- .../rewards/RewardAsyncInjectionTest.java | 4 +- .../tests/user/AdvancedCoreUserTest.java | 37 +++++++++++ 4 files changed, 100 insertions(+), 10 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java index 3f615a00d0..149a5fa3c5 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java @@ -66,11 +66,12 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean valu public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, boolean value, HashMap placeholders) { if (!value && !hasPendingReplayWork(placeholders)) return CompletableFuture.completedFuture(null); + String occurrenceId = Reward.currentReplayOccurrenceId(); String choice = Reward.replaySelection(placeholders, () -> value ? user.getChoicePreference(reward.getName()) : null); if (choice == null || choice.isEmpty() || choice.equalsIgnoreCase("none")) { return Reward.persistReplayMetadataAsync(plugin, placeholders).thenApply(ignored -> { - user.addUnClaimedChoiceReward(reward.getName()); + user.addUnClaimedChoiceReward(reward.getName(), occurrenceId); return 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 f33924f46a..547583e169 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 @@ -71,6 +71,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 CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/1/"; private static final Object REPLAY_CLAIMS_LOCK = new Object(); private static final WeakHashMap> REPLAY_CLAIMS = new WeakHashMap<>(); private static final ThreadLocal ASYNC_ACTION_COLLECTION = new ThreadLocal<>(); @@ -894,12 +895,32 @@ private QueuedReplay(String rewardReference, int completedAsyncInjections, * * @param name the reward name */ - public void addUnClaimedChoiceReward(String name) { + public synchronized void addUnClaimedChoiceReward(String name) { ArrayList choices = getUnClaimedChoices(); choices.add(name); setUnClaimedChoice(choices); } + /** + * Adds an unclaimed choice once for a stable reward occurrence. The encoded + * occurrence remains in the same durable list as the user-facing reward name, + * so a replay cannot append a duplicate after a crash. + * + * @param name reward name shown in the choice UI + * @param occurrenceId stable logical reward occurrence + */ + public synchronized void addUnClaimedChoiceReward(String name, String occurrenceId) { + if (occurrenceId == null || occurrenceId.isEmpty()) { + addUnClaimedChoiceReward(name); + return; + } + ArrayList stored = getStoredUnClaimedChoices(); + String entry = encodeUnclaimedChoice(name, occurrenceId); + if (stored.contains(entry)) return; + stored.add(entry); + getData().setStringList("UnClaimedChoices", stored); + } + /** * Caches the user data. */ @@ -1515,8 +1536,10 @@ public HashMap getTimedRewards() { * * @return the unclaimed choices */ - public ArrayList getUnClaimedChoices() { - return getData().getStringList("UnClaimedChoices", userDataFetchMode); + public synchronized ArrayList getUnClaimedChoices() { + ArrayList choices = new ArrayList<>(); + for (String stored : getStoredUnClaimedChoices()) choices.add(decodeUnclaimedChoice(stored)); + return choices; } /** @@ -2366,10 +2389,15 @@ public void removePermission(String permission) { * * @param name the reward name */ - public void removeUnClaimedChoiceReward(String name) { - ArrayList choices = getUnClaimedChoices(); - choices.remove(name); - setUnClaimedChoice(choices); + public synchronized void removeUnClaimedChoiceReward(String name) { + ArrayList choices = getStoredUnClaimedChoices(); + for (int index = 0; index < choices.size(); index++) { + if (java.util.Objects.equals(name, decodeUnclaimedChoice(choices.get(index)))) { + choices.remove(index); + break; + } + } + getData().setStringList("UnClaimedChoices", choices); } /** @@ -2746,6 +2774,30 @@ public void setUnClaimedChoice(ArrayList rewards) { getData().setStringList("UnClaimedChoices", rewards); } + private ArrayList getStoredUnClaimedChoices() { + return getData().getStringList("UnClaimedChoices", userDataFetchMode); + } + + private static String encodeUnclaimedChoice(String name, String occurrenceId) { + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + return CHOICE_OCCURRENCE_PREFIX + + encoder.encodeToString(occurrenceId.getBytes(StandardCharsets.UTF_8)) + "." + + encoder.encodeToString((name == null ? "" : name).getBytes(StandardCharsets.UTF_8)); + } + + private static String decodeUnclaimedChoice(String stored) { + if (stored == null || !stored.startsWith(CHOICE_OCCURRENCE_PREFIX)) return stored; + String encoded = stored.substring(CHOICE_OCCURRENCE_PREFIX.length()); + int separator = encoded.indexOf('.'); + if (separator < 1) return stored; + try { + Base64.getUrlDecoder().decode(encoded.substring(0, separator)); + return new String(Base64.getUrlDecoder().decode(encoded.substring(separator + 1)), StandardCharsets.UTF_8); + } catch (IllegalArgumentException failure) { + return stored; + } + } + /** * Sets the user to not cache data. */ 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 e6edf36897..30ab530845 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 @@ -1765,9 +1765,9 @@ void unclaimedChoiceIsAddedOnlyAfterItsSelectionIsDurable() throws Exception { replayState, "AsyncReward"); assertEquals(1, writes.size()); - verify(user, never()).addUnClaimedChoiceReward("AsyncReward"); + verify(user, never()).addUnClaimedChoiceReward(eq("AsyncReward"), any(String.class)); writes.get(0).run(); - verify(user).addUnClaimedChoiceReward("AsyncReward"); + verify(user).addUnClaimedChoiceReward(eq("AsyncReward"), any(String.class)); assertEquals(2, writes.size()); writes.get(1).run(); result.toCompletableFuture().join(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 4f781cf950..2a92fe93f0 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -81,6 +81,43 @@ public void setUp() { user.setData(data); // Inject the mocked UserData object } + @Test + void unclaimedChoiceOccurrenceIsIdempotentButDistinctOccurrencesRemainClaimable() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-two"); + + assertEquals(List.of("ChoiceReward", "ChoiceReward"), user.getUnClaimedChoices()); + assertEquals(2, stored.get().size()); + assertTrue(stored.get().stream().noneMatch("ChoiceReward"::equals)); + } + + @Test + void claimingOneOccurrencePreservesAnotherIdenticalChoiceReward() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-two"); + + user.removeUnClaimedChoiceReward("ChoiceReward"); + + assertEquals(List.of("ChoiceReward"), user.getUnClaimedChoices()); + assertEquals(1, stored.get().size()); + } + @Test void checkOfflineRewards_preservesServerRequirementForNormalReplay() { ArrayList rewards = new ArrayList<>(); From c94b8919116a2f502b65ad3db9170073692ff7ee Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:24:02 -0600 Subject: [PATCH 30/78] Preserve choice occurrence IDs in legacy writes --- .../api/user/AdvancedCoreUser.java | 4 ++-- .../tests/user/AdvancedCoreUserTest.java | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) 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 547583e169..e8bcd1d0d0 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 @@ -896,9 +896,9 @@ private QueuedReplay(String rewardReference, int completedAsyncInjections, * @param name the reward name */ public synchronized void addUnClaimedChoiceReward(String name) { - ArrayList choices = getUnClaimedChoices(); + ArrayList choices = getStoredUnClaimedChoices(); choices.add(name); - setUnClaimedChoice(choices); + getData().setStringList("UnClaimedChoices", choices); } /** diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 2a92fe93f0..07e8bc6770 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -118,6 +118,26 @@ void claimingOneOccurrencePreservesAnotherIdenticalChoiceReward() { assertEquals(1, stored.get().size()); } + @Test + void legacyChoiceAdditionPreservesModernOccurrenceIdentity() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + + user.addUnClaimedChoiceReward("Modern", "occurrence-one"); + String encodedModern = stored.get().get(0); + user.addUnClaimedChoiceReward("Legacy"); + user.addUnClaimedChoiceReward("Modern", "occurrence-one"); + + assertEquals(List.of("Modern", "Legacy"), user.getUnClaimedChoices()); + assertEquals(encodedModern, stored.get().get(0)); + assertEquals(2, stored.get().size()); + } + @Test void checkOfflineRewards_preservesServerRequirementForNormalReplay() { ArrayList rewards = new ArrayList<>(); From f66c1a56aac4acd6d8987f6b30bd40c0b91d7262 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:34:42 -0600 Subject: [PATCH 31/78] Persist replay overflow before checkpointing --- .../api/item/FullInventoryHandler.java | 41 ++++++++++++++++--- .../tests/item/FullInventoryHandlerTest.java | 40 ++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) 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 a8130f05af..58187b6999 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 @@ -129,8 +129,17 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu if (!deliveryClaimed.compareAndSet(false, true)) return; try { if (completion != null) validateReplayDeliveryTarget(player, deliveryPlayerId); - giveItemOwnedPlayer(player, itemsToGive); - if (completion != null) completion.complete(null); + boolean pendingOverflow = giveItemOwnedPlayer(player, itemsToGive); + if (completion != null) { + if (pendingOverflow) { + persistPendingItemsAsync().whenComplete((ignored, failure) -> { + if (failure == null) completion.complete(null); + else completion.completeExceptionally(failure); + }); + } else { + completion.complete(null); + } + } } catch (Throwable failure) { if (completion != null) completion.completeExceptionally(failure); else rethrowDeliveryFailure(failure); @@ -211,11 +220,16 @@ public synchronized void shutdown() { * the temporary remove/re-add state of an in-flight inventory check. */ public void save() { + saveDurably(); + } + + /** Saves pending items and reports whether the disk snapshot completed. */ + public boolean saveDurably() { deliveryLock.writeLock().lock(); try { ServerData serverData = plugin.getServerDataFile(); if (serverData == null || serverData.getData() == null) { - return; + return false; } YamlConfiguration snapshot = new YamlConfiguration(); @@ -240,13 +254,29 @@ public void save() { } } serverData.saveData(); + return true; } catch (Exception e) { plugin.getLogger().log(Level.WARNING, "Failed to save pending full-inventory items", e); + return false; } finally { deliveryLock.writeLock().unlock(); } } + private CompletionStage persistPendingItemsAsync() { + CompletableFuture persisted = new CompletableFuture<>(); + try { + timer.execute(() -> { + if (saveDurably()) persisted.complete(null); + else persisted.completeExceptionally( + new IllegalStateException("Unable to persist full-inventory reward overflow")); + }); + } catch (Throwable failure) { + persisted.completeExceptionally(failure); + } + return persisted; + } + public void startup() { try { if (plugin.getServerDataFile() == null || plugin.getServerDataFile().getData() == null) { @@ -331,13 +361,13 @@ private void checkOwnedPlayer(Player player) { } } - private void giveItemOwnedPlayer(Player player, ItemStack[] item) { + private boolean giveItemOwnedPlayer(Player player, ItemStack[] item) { deliveryLock.readLock().lock(); try { HashMap excess = player.getInventory().addItem(item); if (excess.isEmpty()) { player.updateInventory(); - return; + return false; } boolean dropItems = plugin.getOptions().isDropOnFullInv(); @@ -353,6 +383,7 @@ private void giveItemOwnedPlayer(Player player, ItemStack[] item) { sendMessage(player); } player.updateInventory(); + return !dropItems; } finally { deliveryLock.readLock().unlock(); } 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 32b74fbacb..15cf9d27ce 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 @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -35,6 +36,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.item.FullInventoryHandler; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; @@ -146,6 +148,42 @@ public void giveItemAsyncCompletesOnlyAfterOwnedInventoryDelivery() { verify(inventory).addItem(item); } + @Test + public void giveItemAsyncWaitsForOverflowToBePersisted() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + CountDownLatch saveStarted = new CountDownLatch(1); + CountDownLatch releaseSave = new CountDownLatch(1); + doAnswer(invocation -> { + saveStarted.countDown(); + assertTrue(releaseSave.await(2, TimeUnit.SECONDS)); + return null; + }).when(fixture.serverData).saveData(); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + assertTrue(saveStarted.await(2, TimeUnit.SECONDS)); + assertFalse(delivery.toCompletableFuture().isDone()); + releaseSave.countDown(); + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + assertEquals(List.of(excess), fixture.handler.getItems().get(uuid)); + } + @Test public void giveItemAsyncFailsWithoutMutationWhenPlayerDisconnectsAfterEnqueue() throws Exception { Fixture fixture = createFixture(); @@ -313,12 +351,14 @@ private Fixture createFixture() { private Fixture createFixture(long itemDeliveryTimeoutMillis) { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); ScheduledExecutorService sharedInventoryTimer = mock(ScheduledExecutorService.class); BukkitScheduler bukkitScheduler = mock(BukkitScheduler.class); ServerData serverData = mock(ServerData.class); YamlConfiguration data = new YamlConfiguration(); when(plugin.getInventoryTimer()).thenReturn(sharedInventoryTimer); + when(plugin.getOptions()).thenReturn(options); when(plugin.isEnabled()).thenReturn(true); when(plugin.getBukkitScheduler()).thenReturn(bukkitScheduler); when(plugin.getServerDataFile()).thenReturn(serverData); From 53be3c2e97640e5399a438163c1487ab2c851cf9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:47:15 -0600 Subject: [PATCH 32/78] Reserve replay overflow until persistence --- .../api/item/FullInventoryHandler.java | 216 +++++++++++++++--- .../tests/item/FullInventoryHandlerTest.java | 64 ++++++ 2 files changed, 248 insertions(+), 32 deletions(-) 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 58187b6999..ba5de33d19 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 @@ -41,6 +41,14 @@ public class FullInventoryHandler { @Getter private final ConcurrentHashMap> items = new ConcurrentHashMap<>(); + /* + * Replay-aware delivery cannot place an overflow into the ordinary pending map + * until its snapshot has been written. The ordinary periodic sweep is allowed + * to consume that map, so doing so earlier makes a failed save indistinguishable + * from a delivery that has already happened and a replay can duplicate items. + */ + private final ConcurrentHashMap replayOverflowReservations = new ConcurrentHashMap<>(); + private final AdvancedCorePlugin plugin; private final ReentrantReadWriteLock deliveryLock = new ReentrantReadWriteLock(true); @@ -52,6 +60,19 @@ public class FullInventoryHandler { @Getter private final ConcurrentHashMap lastMessageTime = new ConcurrentHashMap<>(); + private static final class ReservedOverflow { + private final UUID playerId; + private final ArrayList items; + + private ReservedOverflow(UUID playerId, Collection items) { + this.playerId = playerId; + this.items = new ArrayList<>(); + for (ItemStack item : items) { + if (item != null) this.items.add(item); + } + } + } + public FullInventoryHandler(AdvancedCorePlugin plugin) { this.plugin = plugin; loadTimer(); @@ -123,18 +144,19 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu } } final UUID deliveryPlayerId = playerId; + final String reservationId = completion == null ? null : UUID.randomUUID().toString(); ItemStack[] itemsToGive = item.clone(); AtomicBoolean deliveryClaimed = new AtomicBoolean(); Runnable delivery = () -> { if (!deliveryClaimed.compareAndSet(false, true)) return; try { if (completion != null) validateReplayDeliveryTarget(player, deliveryPlayerId); - boolean pendingOverflow = giveItemOwnedPlayer(player, itemsToGive); + boolean pendingOverflow = giveItemOwnedPlayer(player, itemsToGive, reservationId); if (completion != null) { if (pendingOverflow) { - persistPendingItemsAsync().whenComplete((ignored, failure) -> { + persistReservedOverflowAsync(reservationId).whenComplete((ignored, failure) -> { if (failure == null) completion.complete(null); - else completion.completeExceptionally(failure); + else completeStartedOverflowWithFallback(player, deliveryPlayerId, reservationId, completion); }); } else { completion.complete(null); @@ -226,55 +248,182 @@ public void save() { /** Saves pending items and reports whether the disk snapshot completed. */ public boolean saveDurably() { deliveryLock.writeLock().lock(); + try { + HashMap reservations = new HashMap<>(replayOverflowReservations); + if (!savePendingItemsLocked(reservations.values())) return false; + for (Entry entry : reservations.entrySet()) { + promoteReservedOverflowLocked(entry.getKey(), entry.getValue()); + } + return true; + } finally { + deliveryLock.writeLock().unlock(); + } + } + + /** Persists one replay reservation before making it visible to ordinary pending checks. */ + private CompletionStage persistReservedOverflowAsync(String reservationId) { + CompletableFuture persisted = new CompletableFuture<>(); + try { + timer.execute(() -> { + if (persistReservedOverflow(reservationId)) persisted.complete(null); + else persisted.completeExceptionally( + new IllegalStateException("Unable to persist full-inventory reward overflow")); + }); + } catch (Throwable failure) { + persisted.completeExceptionally(failure); + } + return persisted; + } + + private boolean persistReservedOverflow(String reservationId) { + deliveryLock.writeLock().lock(); + try { + ReservedOverflow reservation = replayOverflowReservations.get(reservationId); + if (reservation == null || reservation.items.isEmpty()) return true; + if (!savePendingItemsLocked(java.util.List.of(reservation))) return false; + promoteReservedOverflowLocked(reservationId, reservation); + return true; + } finally { + deliveryLock.writeLock().unlock(); + } + } + + /** + * A save failure happens after Bukkit has already accepted part of this reward. + * Complete the replay action after a best-effort owner-thread handoff instead of + * replaying those already-inserted items. Un-dropped items stay reserved and are + * retried for persistence without being visible to the normal delivery sweep. + */ + private void completeStartedOverflowWithFallback(Player player, UUID playerId, String reservationId, + CompletableFuture completion) { + Runnable fallback = () -> { + try { + dropReservedOverflowOwnedPlayer(player, playerId, reservationId); + } finally { + scheduleReservedOverflowPersistenceRetry(reservationId); + completion.complete(null); + } + }; + try { + plugin.getBukkitScheduler().runTask(plugin, fallback, player); + } catch (Throwable failure) { + // The mutation has already started. Retain the reservation for a later disk + // retry, but never make the outer replay issue the full item stack again. + scheduleReservedOverflowPersistenceRetry(reservationId); + completion.complete(null); + } + } + + /** Runs only on the player owner scheduler. */ + private void dropReservedOverflowOwnedPlayer(Player player, UUID playerId, String reservationId) { + if (player == null || playerId == null || Bukkit.getPlayer(playerId) != player || !player.isOnline()) return; + deliveryLock.writeLock().lock(); + try { + ReservedOverflow reservation = replayOverflowReservations.get(reservationId); + if (reservation == null || !playerId.equals(reservation.playerId)) return; + ArrayList remaining = new ArrayList<>(); + for (ItemStack item : reservation.items) { + try { + if (player.getWorld().dropItem(player.getLocation(), item) == null) remaining.add(item); + } catch (Throwable failure) { + remaining.add(item); + } + } + if (remaining.isEmpty()) replayOverflowReservations.remove(reservationId, reservation); + else replayOverflowReservations.put(reservationId, new ReservedOverflow(playerId, remaining)); + } finally { + deliveryLock.writeLock().unlock(); + } + } + + private void scheduleReservedOverflowPersistenceRetry(String reservationId) { + if (!replayOverflowReservations.containsKey(reservationId)) return; + try { + timer.schedule(() -> { + if (!persistReservedOverflow(reservationId)) scheduleReservedOverflowPersistenceRetry(reservationId); + }, 30, TimeUnit.SECONDS); + } catch (Throwable ignored) { + // Shutdown may reject this retry. The retained reservation remains excluded + // from normal checks and can be saved by a later live handler instance. + } + } + + /** Caller holds the write lock. Includes only the reservations in this durable handoff. */ + private boolean savePendingItemsLocked(Collection reservedOverflows) { + FileConfiguration data = null; + YamlConfiguration previous = null; try { ServerData serverData = plugin.getServerDataFile(); - if (serverData == null || serverData.getData() == null) { - return false; + if (serverData == null || serverData.getData() == null) return false; + + HashMap> snapshotItems = new HashMap<>(); + for (Entry> entry : items.entrySet()) { + snapshotItems.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + for (ReservedOverflow reservedOverflow : reservedOverflows) { + if (!reservedOverflow.items.isEmpty()) { + snapshotItems.computeIfAbsent(reservedOverflow.playerId, ignored -> new ArrayList<>()) + .addAll(reservedOverflow.items); + } } YamlConfiguration snapshot = new YamlConfiguration(); long now = System.currentTimeMillis(); - for (Entry> entry : items.entrySet()) { - ArrayList pending = new ArrayList<>(entry.getValue()); + for (Entry> entry : snapshotItems.entrySet()) { String basePath = "FullInventory." + entry.getKey(); - for (int i = 0; i < pending.size(); i++) { - snapshot.set(basePath + ".Items." + i, pending.get(i)); + for (int i = 0; i < entry.getValue().size(); i++) { + snapshot.set(basePath + ".Items." + i, entry.getValue().get(i)); } snapshot.set(basePath + ".Time", now); } - FileConfiguration data = serverData.getData(); + data = serverData.getData(); + previous = copySection(data, "FullInventory"); data.set("FullInventory", null); ConfigurationSection replacement = snapshot.getConfigurationSection("FullInventory"); if (replacement != null) { for (String path : replacement.getKeys(true)) { - if (!replacement.isConfigurationSection(path)) { - data.set("FullInventory." + path, replacement.get(path)); - } + if (!replacement.isConfigurationSection(path)) data.set("FullInventory." + path, replacement.get(path)); } } serverData.saveData(); return true; } catch (Exception e) { - plugin.getLogger().log(Level.WARNING, "Failed to save pending full-inventory items", e); + if (data != null && previous != null) replaceSection(data, "FullInventory", previous); + try { + plugin.getLogger().log(Level.WARNING, "Failed to save pending full-inventory items", e); + } catch (Throwable ignored) { + // A partially initialized plugin can still retain the reservation for retry. + } return false; - } finally { - deliveryLock.writeLock().unlock(); } } - private CompletionStage persistPendingItemsAsync() { - CompletableFuture persisted = new CompletableFuture<>(); - try { - timer.execute(() -> { - if (saveDurably()) persisted.complete(null); - else persisted.completeExceptionally( - new IllegalStateException("Unable to persist full-inventory reward overflow")); - }); - } catch (Throwable failure) { - persisted.completeExceptionally(failure); + private static YamlConfiguration copySection(ConfigurationSection source, String path) { + YamlConfiguration copy = new YamlConfiguration(); + ConfigurationSection section = source.getConfigurationSection(path); + if (section == null) return copy; + for (String child : section.getKeys(true)) { + if (!section.isConfigurationSection(child)) copy.set(child, section.get(child)); } - return persisted; + return copy; + } + + private static void replaceSection(ConfigurationSection target, String path, YamlConfiguration replacement) { + target.set(path, null); + for (String child : replacement.getKeys(true)) { + if (!replacement.isConfigurationSection(child)) target.set(path + "." + child, replacement.get(child)); + } + } + + /** Caller holds the write lock. */ + private void promoteReservedOverflowLocked(String reservationId, ReservedOverflow reservation) { + if (!replayOverflowReservations.remove(reservationId, reservation)) return; + items.compute(reservation.playerId, (key, current) -> { + ArrayList merged = current == null ? new ArrayList<>() : new ArrayList<>(current); + merged.addAll(reservation.items); + return merged; + }); } public void startup() { @@ -361,7 +510,7 @@ private void checkOwnedPlayer(Player player) { } } - private boolean giveItemOwnedPlayer(Player player, ItemStack[] item) { + private boolean giveItemOwnedPlayer(Player player, ItemStack[] item, String reservationId) { deliveryLock.readLock().lock(); try { HashMap excess = player.getInventory().addItem(item); @@ -371,12 +520,15 @@ private boolean giveItemOwnedPlayer(Player player, ItemStack[] item) { } boolean dropItems = plugin.getOptions().isDropOnFullInv(); - for (ItemStack extra : excess.values()) { - if (dropItems) { + if (dropItems) { + for (ItemStack extra : excess.values()) { player.getWorld().dropItem(player.getLocation(), extra); - } else { - add(player.getUniqueId(), extra); } + } else if (reservationId != null) { + replayOverflowReservations.put(reservationId, + new ReservedOverflow(player.getUniqueId(), excess.values())); + } else { + for (ItemStack extra : excess.values()) add(player.getUniqueId(), extra); } if (shouldSendMessage(player.getUniqueId())) { 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 15cf9d27ce..a809a7e2b7 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 @@ -13,6 +13,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -24,8 +25,12 @@ 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 org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; @@ -184,6 +189,65 @@ public void giveItemAsyncWaitsForOverflowToBePersisted() throws Exception { assertEquals(List.of(excess), fixture.handler.getItems().get(uuid)); } + @Test + public void replayOverflowSaveFailureDropsOnlyReservedOverflowWithoutReplayingPartialInsert() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + World world = mock(World.class); + Location location = mock(Location.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(false); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(location); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + when(world.dropItem(location, excess)).thenReturn(mock(org.bukkit.entity.Item.class)); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + doAnswer(invocation -> { + throw new IllegalStateException("disk unavailable"); + }).when(fixture.serverData).saveData(); + + AtomicInteger scheduled = new AtomicInteger(); + AtomicReference initialDelivery = new AtomicReference<>(); + AtomicReference fallback = new AtomicReference<>(); + CountDownLatch fallbackScheduled = new CountDownLatch(1); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + if (scheduled.incrementAndGet() == 1) initialDelivery.set(task); + else { + fallback.set(task); + fallbackScheduled.countDown(); + } + return null; + }).when(fixture.bukkitScheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(player)); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + initialDelivery.get().run(); + assertTrue(fallbackScheduled.await(2, TimeUnit.SECONDS)); + assertFalse(delivery.toCompletableFuture().isDone()); + + // The overflow remains reserved while its persistence is failing, so an + // ordinary sweep cannot consume it before the fallback owns it. + fixture.handler.check(); + assertEquals(2, scheduled.get()); + fallback.get().run(); + } + + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + verify(inventory, times(1)).addItem(item); + verify(world).dropItem(location, excess); + assertFalse(fixture.handler.getItems().containsKey(uuid)); + assertFalse(fixture.data.contains("FullInventory")); + } + @Test public void giveItemAsyncFailsWithoutMutationWhenPlayerDisconnectsAfterEnqueue() throws Exception { Fixture fixture = createFixture(); From 015430b35092c01e8506c37f583179e2e4829a1b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:53:35 -0600 Subject: [PATCH 33/78] Keep replay setup and overflow handoff safe --- .../api/item/FullInventoryHandler.java | 43 ++++++++--------- .../api/rewards/RewardExecutor.java | 33 ++----------- .../tests/item/FullInventoryHandlerTest.java | 46 +++++++++++++++++++ .../tests/rewards/RewardExecutorTest.java | 10 +--- 4 files changed, 73 insertions(+), 59 deletions(-) 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 ba5de33d19..1f0e203a37 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 @@ -290,37 +290,33 @@ private boolean persistReservedOverflow(String reservationId) { /** * A save failure happens after Bukkit has already accepted part of this reward. - * Complete the replay action after a best-effort owner-thread handoff instead of - * replaying those already-inserted items. Un-dropped items stay reserved and are - * retried for persistence without being visible to the normal delivery sweep. + * Complete the replay action only after an owner-thread fallback consumes every + * reserved item or a later retry persists the remainder. Reserved items stay + * excluded from the normal delivery sweep while either handoff is pending. */ private void completeStartedOverflowWithFallback(Player player, UUID playerId, String reservationId, CompletableFuture completion) { Runnable fallback = () -> { - try { - dropReservedOverflowOwnedPlayer(player, playerId, reservationId); - } finally { - scheduleReservedOverflowPersistenceRetry(reservationId); - completion.complete(null); - } + if (dropReservedOverflowOwnedPlayer(player, playerId, reservationId)) completion.complete(null); + else scheduleReservedOverflowPersistenceRetry(reservationId, completion); }; try { plugin.getBukkitScheduler().runTask(plugin, fallback, player); } catch (Throwable failure) { - // The mutation has already started. Retain the reservation for a later disk - // retry, but never make the outer replay issue the full item stack again. - scheduleReservedOverflowPersistenceRetry(reservationId); - completion.complete(null); + // The mutation has already started, so neither acknowledge it nor fail it as + // replayable. Keep the stage pending until the reservation is durable. + scheduleReservedOverflowPersistenceRetry(reservationId, completion); } } /** Runs only on the player owner scheduler. */ - private void dropReservedOverflowOwnedPlayer(Player player, UUID playerId, String reservationId) { - if (player == null || playerId == null || Bukkit.getPlayer(playerId) != player || !player.isOnline()) return; + private boolean dropReservedOverflowOwnedPlayer(Player player, UUID playerId, String reservationId) { + if (player == null || playerId == null || Bukkit.getPlayer(playerId) != player || !player.isOnline()) return false; deliveryLock.writeLock().lock(); try { ReservedOverflow reservation = replayOverflowReservations.get(reservationId); - if (reservation == null || !playerId.equals(reservation.playerId)) return; + if (reservation == null) return true; + if (!playerId.equals(reservation.playerId)) return false; ArrayList remaining = new ArrayList<>(); for (ItemStack item : reservation.items) { try { @@ -331,20 +327,25 @@ private void dropReservedOverflowOwnedPlayer(Player player, UUID playerId, Strin } if (remaining.isEmpty()) replayOverflowReservations.remove(reservationId, reservation); else replayOverflowReservations.put(reservationId, new ReservedOverflow(playerId, remaining)); + return remaining.isEmpty(); } finally { deliveryLock.writeLock().unlock(); } } - private void scheduleReservedOverflowPersistenceRetry(String reservationId) { - if (!replayOverflowReservations.containsKey(reservationId)) return; + private void scheduleReservedOverflowPersistenceRetry(String reservationId, CompletableFuture completion) { + if (!replayOverflowReservations.containsKey(reservationId)) { + completion.complete(null); + return; + } try { timer.schedule(() -> { - if (!persistReservedOverflow(reservationId)) scheduleReservedOverflowPersistenceRetry(reservationId); + if (persistReservedOverflow(reservationId)) completion.complete(null); + else scheduleReservedOverflowPersistenceRetry(reservationId, completion); }, 30, TimeUnit.SECONDS); } catch (Throwable ignored) { - // Shutdown may reject this retry. The retained reservation remains excluded - // from normal checks and can be saved by a later live handler instance. + // Shutdown may reject this retry. Leave the completion pending: acknowledging + // an in-memory-only remainder would lose it from the durable reward replay. } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index 30f695176c..d1d1562a2b 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -204,36 +204,9 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward rewar if (options.getAsyncReplayOccurrenceId() == null && activeOccurrenceId != null) { options.setAsyncReplayOccurrenceId(activeOccurrenceId); } - boolean primaryThread = false; - try { - primaryThread = Bukkit.isPrimaryThread(); - } catch (IllegalStateException | NullPointerException ignored) { - // Unit tests and early bootstrap have no primary server thread. - } - if (primaryThread) { - CompletableFuture dispatched = new CompletableFuture<>(); - try { - plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { - try { - CompletionStage result = reward.giveRewardAsync(user, options); - if (result == null) { - dispatched.completeExceptionally( - new IllegalStateException("Asynchronous reward dispatch returned no completion stage")); - } else { - result.whenComplete((ignored, failure) -> { - if (failure == null) dispatched.complete(null); - else dispatched.completeExceptionally(failure); - }); - } - } catch (Throwable failure) { - dispatched.completeExceptionally(failure); - } - }); - } catch (Throwable failure) { - dispatched.completeExceptionally(failure); - } - return dispatched; - } + // Reward setup fires Bukkit events and resolves live player state. Keep that + // work on the caller's owner thread; individual injectors explicitly hand + // off only the portions that are safe to execute asynchronously. return reward.giveRewardAsync(user, options); } 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 a809a7e2b7..9ecfc4c982 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 @@ -248,6 +248,52 @@ public void replayOverflowSaveFailureDropsOnlyReservedOverflowWithoutReplayingPa assertFalse(fixture.data.contains("FullInventory")); } + @Test + public void replayOverflowDoesNotAcknowledgeAnUnavailableFallback() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(false); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + doAnswer(invocation -> { + throw new IllegalStateException("disk unavailable"); + }).when(fixture.serverData).saveData(); + + AtomicReference initialDelivery = new AtomicReference<>(); + AtomicReference fallback = new AtomicReference<>(); + CountDownLatch fallbackScheduled = new CountDownLatch(1); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + if (initialDelivery.get() == null) initialDelivery.set(task); + else { + fallback.set(task); + fallbackScheduled.countDown(); + } + return null; + }).when(fixture.bukkitScheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(player)); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + initialDelivery.get().run(); + assertTrue(fallbackScheduled.await(2, TimeUnit.SECONDS)); + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + fallback.get().run(); + } + + assertFalse(delivery.toCompletableFuture().isDone()); + verify(inventory, times(1)).addItem(item); + assertFalse(fixture.handler.getItems().containsKey(uuid)); + assertFalse(fixture.data.contains("FullInventory")); + } + @Test public void giveItemAsyncFailsWithoutMutationWhenPlayerDisconnectsAfterEnqueue() throws Exception { Fixture fixture = createFixture(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 3f1e7e356d..f55294c05d 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -156,13 +156,10 @@ public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { } @Test - public void asyncRewardDispatchLeavesThePrimaryThreadAndRetainsCompletion() { + public void asyncRewardSetupStaysOnThePrimaryThreadAndRetainsCompletion() { Reward child = mock(Reward.class); CompletableFuture childResult = new CompletableFuture<>(); when(child.giveRewardAsync(eq(user), any(RewardOptions.class))).thenReturn(childResult); - BukkitScheduler scheduler = mock(BukkitScheduler.class); - when(plugin.getBukkitScheduler()).thenReturn(scheduler); - ArgumentCaptor dispatch = ArgumentCaptor.forClass(Runnable.class); RewardOptions options = new RewardOptions(); Reward.ReplayState replayState = Reward.replayStateFor(new RewardOptions()); @@ -175,14 +172,11 @@ public void asyncRewardDispatchLeavesThePrimaryThreadAndRetainsCompletion() { when(child.getRewardName()).thenReturn("child"); CompletionStage result = executor.giveRewardAsync(user, child, options); - verify(scheduler).runTaskAsynchronously(eq(plugin), dispatch.capture()); - verify(child, never()).giveRewardAsync(eq(user), any(RewardOptions.class)); + verify(child).giveRewardAsync(eq(user), any(RewardOptions.class)); assertSame(replayState, options.getAsyncReplayState()); assertEquals("parent/child", options.getAsyncReplayKey()); assertEquals("occurrence", options.getAsyncReplayOccurrenceId()); assertFalse(result.toCompletableFuture().isDone()); - dispatch.getValue().run(); - assertFalse(result.toCompletableFuture().isDone()); childResult.completeExceptionally(new IllegalStateException("child failed")); assertThrows(CompletionException.class, () -> result.toCompletableFuture().join()); } From 12bc1f5245c9d63cbc200d38259538ed51cf1015 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:02:22 -0600 Subject: [PATCH 34/78] Resume nested rewards on owner threads --- .../bencodez/advancedcore/api/rewards/Reward.java | 13 ++++++++++++- .../advancedcore/api/rewards/RewardExecutor.java | 3 ++- .../api/rewards/builtin/RewardAdvancedPriority.java | 3 ++- .../rewards/builtin/RewardAdvancedRandomReward.java | 3 ++- .../api/rewards/builtin/RewardAdvancedRewards.java | 10 +++++----- .../api/rewards/builtin/RewardAdvancedWorld.java | 9 +++++---- .../api/rewards/builtin/RewardChoices.java | 12 +++++++----- .../api/rewards/builtin/RewardJavascript.java | 3 ++- .../api/rewards/builtin/RewardLucky.java | 4 ++-- .../api/rewards/builtin/RewardPriority.java | 3 ++- .../api/rewards/builtin/RewardRandom.java | 6 ++++-- .../api/rewards/builtin/RewardRandomReward.java | 3 ++- .../api/rewards/builtin/RewardSpecialChance.java | 3 ++- .../tests/rewards/BuiltinRewardBehaviorTest.java | 10 ++++++++++ 14 files changed, 59 insertions(+), 26 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 9eee684230..636777fb46 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 @@ -969,6 +969,17 @@ private CompletionStage invokeInjectionAsync(RewardInject inject, Advanc private CompletionStage requestOnServerThread(AdvancedCoreUser user, Supplier> request) { + return requestOnServerThread(plugin, user, request, getServerThreadDispatchTimeoutMillis()); + } + + /** Continues nested reward setup on the owning Bukkit scheduler after async persistence. */ + public static CompletionStage continueOnServerThread(AdvancedCorePlugin plugin, AdvancedCoreUser user, + Supplier> request) { + return requestOnServerThread(plugin, user, request, TimeUnit.SECONDS.toMillis(30)); + } + + private static CompletionStage requestOnServerThread(AdvancedCorePlugin plugin, AdvancedCoreUser user, + Supplier> request, long timeoutMillis) { CompletableFuture> handoff = new CompletableFuture<>(); Runnable invocation = () -> { if (!plugin.isEnabled()) { @@ -1014,7 +1025,7 @@ private CompletionStage requestOnServerThread(AdvancedCoreUser user, } catch (Throwable failure) { handoff.completeExceptionally(failure); } - return handoff.orTimeout(getServerThreadDispatchTimeoutMillis(), TimeUnit.MILLISECONDS) + return handoff.orTimeout(timeoutMillis, TimeUnit.MILLISECONDS) .thenCompose(stage -> stage); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index d1d1562a2b..d1fcd99944 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -143,7 +143,8 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio RewardOptions nestedOptions = options.copyForNestedDispatch( stableParentReplayKey + "/" + nestedReward + ":" + nestedIndex); nestedOptions.setAsyncReplayState(replayState); - return giveRewardAsync(user, nestedReward, nestedOptions).handle((childResult, failure) -> { + return Reward.continueOnServerThread(plugin, user, + () -> giveRewardAsync(user, nestedReward, nestedOptions)).handle((childResult, failure) -> { // Child options stay isolated for normal placeholders. Replay // markers are shared lazily so an earlier child remains durable // when a later child fails and the list is restarted. diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java index 6cbd7e9eb9..c79b898b2c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java @@ -87,7 +87,8 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan if (selected == null) return CompletableFuture.failedFuture( new IllegalStateException("Selected advanced priority reward could not be resolved: " + selectedKey)); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> handler.giveRewardAsync(user, selected, childOptions)) + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, selected, childOptions))) .thenApply(ignored -> selected.getName()); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java index 35e30ebf3a..a8ec174c70 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java @@ -75,7 +75,8 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor Reward.currentReplayOccurrenceId()) .setPrefix(reward.getRewardName() + "_AdvancedRandomReward"); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> handler.giveRewardAsync(user, section, selected, childOptions)) + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, section, selected, childOptions))) .thenApply(ignored -> selected); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java index c0dbdce093..af1183f63d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java @@ -93,11 +93,11 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU for (int index = 0; index < rewards.size(); index++) { String rewardName = rewards.get(index); int childIndex = index; - sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, - section, rewardName, - Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), replayState, - parentReplayKey, rewardName + ":" + childIndex, parentOccurrenceId) - .setPrefix(reward.getRewardName() + "_AdvancedRewards"))); + sequence = sequence.thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, section, rewardName, + Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), replayState, + parentReplayKey, rewardName + ":" + childIndex, parentOccurrenceId) + .setPrefix(reward.getRewardName() + "_AdvancedRewards")))); } return sequence.thenApply(ignored -> (Object) null); }); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java index b46d5cdfd1..dd4c1eb8b5 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java @@ -76,10 +76,11 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan String key = worlds.get(index); int childIndex = index; section.set(key + ".Worlds", ArrayUtils.convert(new String[] { key })); - sequence = sequence.thenCompose(ignored -> handler.giveRewardAsync(user, section, key, - Reward.withReplayState(new RewardOptions().withPlaceHolder(placeholders), replayState, - parentReplayKey, key + ":" + childIndex, parentOccurrenceId) - .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld"))); + sequence = sequence.thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, section, key, + Reward.withReplayState(new RewardOptions().withPlaceHolder(placeholders), replayState, + parentReplayKey, key + ":" + childIndex, parentOccurrenceId) + .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld")))); } return sequence.thenApply(ignored -> (String) null); }); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java index 149a5fa3c5..c54d5a1df8 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java @@ -70,10 +70,11 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU String choice = Reward.replaySelection(placeholders, () -> value ? user.getChoicePreference(reward.getName()) : null); if (choice == null || choice.isEmpty() || choice.equalsIgnoreCase("none")) { - return Reward.persistReplayMetadataAsync(plugin, placeholders).thenApply(ignored -> { - user.addUnClaimedChoiceReward(reward.getName(), occurrenceId); - return null; - }); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, () -> { + user.addUnClaimedChoiceReward(reward.getName(), occurrenceId); + return CompletableFuture.completedFuture(null); + })); } RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), reward.getConfig().getChoicesRewardsPath(choice)).withPrefix(reward.getName()) @@ -81,7 +82,8 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), Reward.currentReplayKey(), "choice:" + choice, Reward.currentReplayOccurrenceId()); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> builder.sendAsync(user)).thenApply(ignored -> choice); + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> builder.sendAsync(user))).thenApply(ignored -> choice); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java index 1de4404e34..2caa5a1d50 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java @@ -99,7 +99,8 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), Reward.currentReplayKey(), "path:" + path, Reward.currentReplayOccurrenceId()); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> builder.sendAsync(user)).thenApply(ignored -> null); + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> builder.sendAsync(user))).thenApply(ignored -> null); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java index cbba924175..c30bdb146b 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java @@ -117,13 +117,13 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor int luckyIndex = 0; for (String path : choices.split("\\n")) { final int childIndex = luckyIndex++; - sequence = sequence.thenCompose(ignored -> { + sequence = sequence.thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, () -> { RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) .withPrefix(reward.getName()).withPlaceHolder(placeholders); Reward.withReplayState(builder.getRewardOptions(), replayState, parentReplayKey, path + ":" + childIndex, parentOccurrenceId); return builder.sendAsync(user); - }); + })); } return sequence.thenApply(ignored -> null); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java index d2b00f8a3a..6d9c2ab2ab 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java @@ -78,7 +78,8 @@ public CompletionStage onRewardRequestAsync(Reward source, AdvancedCoreU if (selected == null) return CompletableFuture.failedFuture( new IllegalStateException("Selected priority reward could not be resolved: " + selectedName)); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> handler.giveRewardAsync(user, selected, childOptions)) + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, selected, childOptions))) .thenApply(ignored -> selected.getName()); } }.asPlaceholder("Priority").addEditButton( diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java index f548b3f339..662c3d63c8 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java @@ -83,7 +83,8 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()); return selected.isEmpty() ? CompletableFuture.completedFuture(null) : Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> handler.giveRewardAsync(user, selected, childOptions)) + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, selected, childOptions))) .thenApply(ignored -> null); } String path = selection.equals("rewards") ? "Random.Rewards" : "Random.FallBack"; @@ -92,7 +93,8 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), Reward.currentReplayKey(), "path:" + path, Reward.currentReplayOccurrenceId()); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> builder.sendAsync(user)).thenApply(ignored -> null); + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> builder.sendAsync(user))).thenApply(ignored -> null); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java index 3b2bfabfe1..b7c39f6afe 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java @@ -63,7 +63,8 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU new RewardOptions().setPlaceholders(placeholders), Reward.currentReplayState(), Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> handler.giveRewardAsync(user, selected, childOptions)) + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, selected, childOptions))) .thenApply(ignored -> selected); } }.asPlaceholder("RandomReward").priority(20).addEditButton( diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java index 87cbb39505..428ff8d931 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java @@ -127,7 +127,8 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), Reward.currentReplayKey(), "path:" + entry.getValue(), Reward.currentReplayOccurrenceId()); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> builder.sendAsync(user)).thenApply(ignored -> null); + .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + () -> builder.sendAsync(user))).thenApply(ignored -> null); } } return CompletableFuture.failedFuture( diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 0a53e1e1bf..6b1122ecb8 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockConstruction; @@ -82,6 +83,7 @@ import com.bencodez.advancedcore.api.rewards.injected.RewardInjectString; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectStringList; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; public class BuiltinRewardBehaviorTest { @@ -666,6 +668,13 @@ public void choicesAsyncDispatchWaitsForSelectedReward() { RewardInjectBoolean choices = (RewardInjectBoolean) injects.get(0); when(user.getChoicePreference("SourceReward")).thenReturn("OptionA"); CompletableFuture child = new CompletableFuture<>(); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); try (MockedConstruction builders = mockConstruction(RewardBuilder.class, withSettings().defaultAnswer(Answers.RETURNS_SELF), @@ -675,6 +684,7 @@ public void choicesAsyncDispatchWaitsForSelectedReward() { child.complete(null); assertEquals("OptionA", result.toCompletableFuture().join()); verify(builders.constructed().get(0)).sendAsync(user); + verify(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); } } From e632ea5cdc13dd88cf15a4ef24f7f96d09895d45 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:03:29 -0600 Subject: [PATCH 35/78] Cover owner-thread nested list dispatch --- .../advancedcore/tests/rewards/RewardExecutorTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index f55294c05d..1e333978a1 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -62,9 +62,19 @@ public void setUp() { handler = mock(RewardHandler.class); user = mock(AdvancedCoreUser.class); executor = new RewardExecutor(handler, plugin); + BukkitScheduler scheduler = mock(BukkitScheduler.class); when(plugin.getLogger()).thenReturn(mock(Logger.class)); when(plugin.isEnabled()).thenReturn(true); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(org.bukkit.entity.Entity.class)); when(user.getPlayerName()).thenReturn("Ben"); when(user.getUUID()).thenReturn("uuid"); when(user.isOnline()).thenReturn(true); From 1a328e17478cf616f382e5b0d414ab2cd1a6e45a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:08:30 -0600 Subject: [PATCH 36/78] Reserve failed replay overflow drops --- .../api/item/FullInventoryHandler.java | 20 ++++++-- .../tests/item/FullInventoryHandlerTest.java | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) 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 1f0e203a37..3e93a1a0fd 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 @@ -522,8 +522,22 @@ private boolean giveItemOwnedPlayer(Player player, ItemStack[] item, String rese boolean dropItems = plugin.getOptions().isDropOnFullInv(); if (dropItems) { - for (ItemStack extra : excess.values()) { - player.getWorld().dropItem(player.getLocation(), extra); + if (reservationId == null) { + for (ItemStack extra : excess.values()) player.getWorld().dropItem(player.getLocation(), extra); + } else { + ReservedOverflow reservation = new ReservedOverflow(player.getUniqueId(), excess.values()); + replayOverflowReservations.put(reservationId, reservation); + ArrayList undropped = new ArrayList<>(); + for (ItemStack extra : reservation.items) { + try { + if (player.getWorld().dropItem(player.getLocation(), extra) == null) undropped.add(extra); + } catch (Throwable failure) { + undropped.add(extra); + } + } + if (undropped.isEmpty()) replayOverflowReservations.remove(reservationId, reservation); + else replayOverflowReservations.put(reservationId, + new ReservedOverflow(player.getUniqueId(), undropped)); } } else if (reservationId != null) { replayOverflowReservations.put(reservationId, @@ -536,7 +550,7 @@ private boolean giveItemOwnedPlayer(Player player, ItemStack[] item, String rese sendMessage(player); } player.updateInventory(); - return !dropItems; + return reservationId != null && replayOverflowReservations.containsKey(reservationId); } finally { deliveryLock.readLock().unlock(); } 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 9ecfc4c982..f52bd692d6 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 @@ -189,6 +189,53 @@ public void giveItemAsyncWaitsForOverflowToBePersisted() throws Exception { assertEquals(List.of(excess), fixture.handler.getItems().get(uuid)); } + @Test + public void replayDropFailurePersistsOnlyTheUndroppedOverflow() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + World world = mock(World.class); + Location location = mock(Location.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(true); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(location); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + doAnswer(invocation -> { + throw new IllegalStateException("world unavailable"); + }).when(world).dropItem(location, excess); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + CountDownLatch saveStarted = new CountDownLatch(1); + CountDownLatch releaseSave = new CountDownLatch(1); + doAnswer(invocation -> { + saveStarted.countDown(); + assertTrue(releaseSave.await(2, TimeUnit.SECONDS)); + return null; + }).when(fixture.serverData).saveData(); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + assertTrue(saveStarted.await(2, TimeUnit.SECONDS)); + assertFalse(delivery.toCompletableFuture().isDone()); + releaseSave.countDown(); + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + verify(inventory, times(1)).addItem(item); + verify(world).dropItem(location, excess); + assertEquals(List.of(excess), fixture.handler.getItems().get(uuid)); + assertEquals(excess, fixture.data.getItemStack("FullInventory." + uuid + ".Items.0")); + } + @Test public void replayOverflowSaveFailureDropsOnlyReservedOverflowWithoutReplayingPartialInsert() throws Exception { Fixture fixture = createFixture(); From affb770dcce6a00467dff7421fff1324a7027edc Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:16:28 -0600 Subject: [PATCH 37/78] Flush replay overflow before shutdown --- .../api/item/FullInventoryHandler.java | 32 +++++++++++--- .../rewards/builtin/RewardAdvancedWorld.java | 5 ++- .../tests/item/FullInventoryHandlerTest.java | 42 +++++++++++++++++++ 3 files changed, 71 insertions(+), 8 deletions(-) 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 3e93a1a0fd..96631d67e4 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 @@ -48,6 +48,7 @@ public class FullInventoryHandler { * from a delivery that has already happened and a replay can duplicate items. */ private final ConcurrentHashMap replayOverflowReservations = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> replayOverflowCompletions = new ConcurrentHashMap<>(); private final AdvancedCorePlugin plugin; private final ReentrantReadWriteLock deliveryLock = new ReentrantReadWriteLock(true); @@ -154,8 +155,9 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu boolean pendingOverflow = giveItemOwnedPlayer(player, itemsToGive, reservationId); if (completion != null) { if (pendingOverflow) { + replayOverflowCompletions.put(reservationId, completion); persistReservedOverflowAsync(reservationId).whenComplete((ignored, failure) -> { - if (failure == null) completion.complete(null); + if (failure == null) completeReplayOverflow(reservationId, completion); else completeStartedOverflowWithFallback(player, deliveryPlayerId, reservationId, completion); }); } else { @@ -225,6 +227,10 @@ public synchronized void loadTimer() { } public synchronized void shutdown() { + // Flush accepted replay reservations while their completion stages can still + // advance the outer reward checkpoint. Stopping the executor first can discard + // an accepted persistence task and later replay the already-inserted items. + saveDurably(); if (checkTask != null) { checkTask.cancel(false); checkTask = null; @@ -247,17 +253,23 @@ public void save() { /** Saves pending items and reports whether the disk snapshot completed. */ public boolean saveDurably() { + HashMap reservations; + boolean saved; deliveryLock.writeLock().lock(); try { - HashMap reservations = new HashMap<>(replayOverflowReservations); + reservations = new HashMap<>(replayOverflowReservations); if (!savePendingItemsLocked(reservations.values())) return false; for (Entry entry : reservations.entrySet()) { promoteReservedOverflowLocked(entry.getKey(), entry.getValue()); } - return true; + saved = true; } finally { deliveryLock.writeLock().unlock(); } + if (saved) { + for (String reservationId : reservations.keySet()) completeReplayOverflow(reservationId, null); + } + return true; } /** Persists one replay reservation before making it visible to ordinary pending checks. */ @@ -297,7 +309,9 @@ private boolean persistReservedOverflow(String reservationId) { private void completeStartedOverflowWithFallback(Player player, UUID playerId, String reservationId, CompletableFuture completion) { Runnable fallback = () -> { - if (dropReservedOverflowOwnedPlayer(player, playerId, reservationId)) completion.complete(null); + if (dropReservedOverflowOwnedPlayer(player, playerId, reservationId)) { + completeReplayOverflow(reservationId, completion); + } else scheduleReservedOverflowPersistenceRetry(reservationId, completion); }; try { @@ -335,12 +349,12 @@ private boolean dropReservedOverflowOwnedPlayer(Player player, UUID playerId, St private void scheduleReservedOverflowPersistenceRetry(String reservationId, CompletableFuture completion) { if (!replayOverflowReservations.containsKey(reservationId)) { - completion.complete(null); + completeReplayOverflow(reservationId, completion); return; } try { timer.schedule(() -> { - if (persistReservedOverflow(reservationId)) completion.complete(null); + if (persistReservedOverflow(reservationId)) completeReplayOverflow(reservationId, completion); else scheduleReservedOverflowPersistenceRetry(reservationId, completion); }, 30, TimeUnit.SECONDS); } catch (Throwable ignored) { @@ -349,6 +363,12 @@ private void scheduleReservedOverflowPersistenceRetry(String reservationId, Comp } } + private void completeReplayOverflow(String reservationId, CompletableFuture fallbackCompletion) { + CompletableFuture completion = replayOverflowCompletions.remove(reservationId); + if (completion == null) completion = fallbackCompletion; + if (completion != null) completion.complete(null); + } + /** Caller holds the write lock. Includes only the reservations in this durable handoff. */ private boolean savePendingItemsLocked(Collection reservedOverflows) { FileConfiguration data = null; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java index dd4c1eb8b5..e72bd679d1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java @@ -64,7 +64,8 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan String parentReplayKey = Reward.currentReplayKey(); String parentOccurrenceId = Reward.currentReplayOccurrenceId(); return Reward.replayNestedRewardSnapshot(plugin, placeholders, "advanced-world:" + getPath(), - new ArrayList<>(section.getKeys(false)), replayState, parentReplayKey).thenCompose(worlds -> { + new ArrayList<>(section.getKeys(false)), replayState, parentReplayKey) + .thenCompose(worlds -> Reward.continueOnServerThread(plugin, user, () -> { for (String key : worlds) { if (!section.contains(key, true)) { return CompletableFuture.failedFuture(new IllegalStateException( @@ -83,7 +84,7 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld")))); } return sequence.thenApply(ignored -> (String) null); - }); + })); } @Override 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 f52bd692d6..50045306b5 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 @@ -341,6 +341,48 @@ public void replayOverflowDoesNotAcknowledgeAnUnavailableFallback() throws Excep assertFalse(fixture.data.contains("FullInventory")); } + @Test + public void shutdownPersistsAcceptedOverflowBeforeDiscardingQueuedPersistence() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(false); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + CountDownLatch executorBlocked = new CountDownLatch(1); + CountDownLatch releaseExecutor = new CountDownLatch(1); + fixture.handler.getTimer().execute(() -> { + executorBlocked.countDown(); + try { + releaseExecutor.await(2, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }); + assertTrue(executorBlocked.await(2, TimeUnit.SECONDS)); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + assertFalse(delivery.toCompletableFuture().isDone()); + + fixture.handler.shutdown(); + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + releaseExecutor.countDown(); + assertEquals(excess, fixture.data.getItemStack("FullInventory." + uuid + ".Items.0")); + verify(inventory, times(1)).addItem(item); + } + @Test public void giveItemAsyncFailsWithoutMutationWhenPlayerDisconnectsAfterEnqueue() throws Exception { Fixture fixture = createFixture(); From 340d6aa3a64a1596cf8f4d73e97660f5ed4e142c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:22:14 -0600 Subject: [PATCH 38/78] Flush replay state before executor shutdown --- .../advancedcore/api/misc/MiscUtils.java | 4 ++-- .../bukkit/runtime/BukkitRuntimePlatform.java | 11 ++++------ .../tests/lifecycle/CoreRuntimeTest.java | 16 ++++++++++++++ .../tests/misc/MiscUtilsTest.java | 22 +++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) 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 b5f86060b0..cb3b67c69d 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 @@ -258,8 +258,8 @@ public CompletionStage executeConsoleCommandsAsync(String playerName, Stri expanded = PlaceholderUtils.replacePlaceHolder(expanded, placeholders); if (p != null) expanded = PlaceholderUtils.replacePlaceHolders(p, expanded); } - ArrayList commands = expanded == null || expanded.isEmpty() - ? new ArrayList<>() : new ArrayList<>(java.util.List.of(expanded)); + ArrayList commands = command == null || command.isEmpty() + ? new ArrayList<>() : new ArrayList<>(java.util.List.of(expanded == null ? "" : expanded)); return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, (cmd, ignoredIndex) -> { plugin.debug("Executing console command: " + cmd); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java index 5d54765d94..bfccf4023a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java @@ -29,6 +29,10 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { @Override public List beforeExecutorShutdown() { return List.of( + new Cleanup("full inventory handler", () -> { + FullInventoryHandler handler = plugin.getFullInventoryHandler(); + if (handler != null) handler.shutdown(); + }), new Cleanup("Javascript engine", () -> { if (plugin.getOptions() != null && plugin.getOptions().isJavascriptEngineEnabled()) { plugin.getLogger().info("Shutting down Javascript engine"); @@ -58,13 +62,6 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { new Cleanup("skull cache", () -> { if (plugin.getSkullCacheHandler() != null) plugin.getSkullCacheHandler().close(); }), - new Cleanup("full inventory handler", () -> { - FullInventoryHandler handler = plugin.getFullInventoryHandler(); - if (handler != null) { - handler.shutdown(); - handler.save(); - } - }), new Cleanup("hologram handler", () -> { if (plugin.getHologramHandler() != null) plugin.getHologramHandler().onShutDown(); }), diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java index cdd00efd6f..58ac1268f8 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform.Cleanup; @@ -107,4 +108,19 @@ private ScheduledExecutorService executor(String name, List events) thro assertNull(platform.getTimeTimer()); assertDoesNotThrow(() -> new AdvancedCoreLifecycle(null).shutdown()); } + + @Test void bukkitAdapterFlushesFullInventoryBeforeExecutorShutdown() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + FullInventoryHandler handler = mock(FullInventoryHandler.class); + when(plugin.getFullInventoryHandler()).thenReturn(handler); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + + platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("full inventory handler")) + .findFirst().orElseThrow().action().run(); + + verify(handler).shutdown(); + assertTrue(platform.afterExecutorShutdown().stream() + .noneMatch(cleanup -> cleanup.name().equals("full inventory handler"))); + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java index 191aa3b210..f3502baac9 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java @@ -112,6 +112,28 @@ public void asyncConsoleCommandCompletesOnlyAfterItsScheduledDispatch() { } } + @Test + public void asyncConsoleCommandPreservesAnEmptyPlaceholderExpansion() { + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + HashMap placeholders = new HashMap<>(); + placeholders.put("command", ""); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.getOfflinePlayer("Ben")).thenReturn(null); + + java.util.concurrent.CompletionStage completion = miscUtils.executeConsoleCommandsAsync("Ben", + "%command%", placeholders); + verify(scheduler).executeOrScheduleSync(eq(plugin), task.capture()); + task.getValue().run(); + completion.toCompletableFuture().join(); + verify(server).dispatchCommand(console, ""); + } + } + @Test public void asyncStaggeredCommandsCompleteOnlyAfterEveryScheduledDispatch() { Server server = mock(Server.class); From 9ec20030a670833837e306679165c7c198be7812 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:23:02 -0600 Subject: [PATCH 39/78] Update lifecycle shutdown expectation --- .../advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java index 5676c454af..aca904bb39 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java @@ -69,7 +69,6 @@ public void shutdownCoordinatesExecutorsAndInventoryPersistence() throws Excepti verify(timeTimer).awaitTermination(2, TimeUnit.SECONDS); verify(rewardHandler).shutdown(); verify(fullInventoryHandler).shutdown(); - verify(fullInventoryHandler).save(); verify(plugin).onUnLoad(); } } From 90ab196d84fc3ce02588bf5002a629f3fecbd927 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:32:31 -0600 Subject: [PATCH 40/78] Migrate legacy replay occurrences before delivery --- .../api/item/FullInventoryHandler.java | 11 ++++ .../api/user/AdvancedCoreUser.java | 60 ++++++++++++++++++- .../tests/item/FullInventoryHandlerTest.java | 26 ++++++++ .../tests/user/AdvancedCoreUserTest.java | 40 +++++++++++-- 4 files changed, 130 insertions(+), 7 deletions(-) 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 96631d67e4..0d9b3b796b 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 @@ -52,6 +52,7 @@ public class FullInventoryHandler { private final AdvancedCorePlugin plugin; private final ReentrantReadWriteLock deliveryLock = new ReentrantReadWriteLock(true); + private final AtomicBoolean shuttingDown = new AtomicBoolean(); @Getter private ScheduledExecutorService timer; @@ -129,6 +130,11 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu if (completion != null) completion.complete(null); return; } + if (completion != null && shuttingDown.get()) { + completion.completeExceptionally(AdvancedCoreUser.replayActionNotStarted( + "Full-inventory handler is shutting down before item delivery")); + return; + } UUID playerId = null; if (completion != null) { try { @@ -227,6 +233,7 @@ public synchronized void loadTimer() { } public synchronized void shutdown() { + shuttingDown.set(true); // Flush accepted replay reservations while their completion stages can still // advance the outer reward checkpoint. Stopping the executor first can discard // an accepted persistence task and later replay the already-inserted items. @@ -534,6 +541,10 @@ private void checkOwnedPlayer(Player player) { private boolean giveItemOwnedPlayer(Player player, ItemStack[] item, String reservationId) { deliveryLock.readLock().lock(); try { + if (reservationId != null && shuttingDown.get()) { + throw AdvancedCoreUser.replayActionNotStarted( + "Full-inventory handler shut down before queued item delivery began"); + } HashMap excess = player.getInventory().addItem(item); if (excess.isEmpty()) { player.updateInventory(); 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 e8bcd1d0d0..71d2e0d595 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 @@ -762,6 +762,49 @@ private static String queuedReference(QueuedReplay replay) { : ASYNC_OCCURRENCE_DELIMITER + replay.asyncReplayOccurrenceId); } + private static String withAsyncOccurrence(String rewardEntry, String occurrenceId) { + int placeholders = rewardEntry.indexOf("%placeholders%"); + String reference = placeholders < 0 ? rewardEntry : rewardEntry.substring(0, placeholders); + String suffix = placeholders < 0 ? "" : rewardEntry.substring(placeholders); + return stripAsyncOccurrenceMarker(reference) + ASYNC_OCCURRENCE_DELIMITER + occurrenceId + suffix; + } + + private String ensureOfflineRewardOccurrence(String rewardEntry) { + if (occurrenceId(rewardEntry) != null) return rewardEntry; + synchronized (plugin) { + ArrayList pending = getOfflineRewards(); + int index = pending.indexOf(rewardEntry); + if (index < 0) throw new IllegalStateException("Claimed offline reward disappeared before migration"); + String updated = withAsyncOccurrence(rewardEntry, UUID.randomUUID().toString()); + pending.set(index, updated); + setOfflineRewardsDurably(pending, updated); + ReplayClaims claims = replayClaims(); + int claimed = claims.offline.getOrDefault(rewardEntry, 0); + if (claimed <= 1) claims.offline.remove(rewardEntry); + else claims.offline.put(rewardEntry, claimed - 1); + claims.offline.put(updated, claims.offline.getOrDefault(updated, 0) + 1); + return updated; + } + } + + private String ensureTimedRewardOccurrence(String rewardEntry, long time) { + if (occurrenceId(stripTimedExecutionMarker(rewardEntry)) != null) return rewardEntry; + synchronized (plugin) { + HashMap pending = getTimedRewards(); + if (!Long.valueOf(time).equals(pending.get(rewardEntry))) { + throw new IllegalStateException("Claimed timed reward disappeared before migration"); + } + String updated = withAsyncOccurrence(rewardEntry, UUID.randomUUID().toString()); + pending.remove(rewardEntry); + pending.put(updated, time); + setTimedRewardsDurably(pending); + ReplayClaims claims = replayClaims(); + claims.timed.remove(rewardEntry); + claims.timed.add(updated); + return updated; + } + } + private static String encodeAsyncReplayProgress(Map progress) { if (progress.isEmpty()) return ""; StringBuilder encoded = new StringBuilder(); @@ -960,7 +1003,14 @@ public void checkDelayedTimedRewards() { if (time != 0) { Date timeDate = new Date(time); if (new Date().after(timeDate) && claimTimedReward(entry.getKey(), time)) { - String[] data = entry.getKey().split("%placeholders%", 2); + String migratedEntry; + try { + migratedEntry = ensureTimedRewardOccurrence(entry.getKey(), time); + } catch (Throwable failure) { + restoreTimedReward(entry.getKey(), time, failure); + continue; + } + String[] data = migratedEntry.split("%placeholders%", 2); QueuedReplay queuedReplay = parseQueuedReplay(stripTimedExecutionMarker(data[0])); String rewardReference = queuedReplay.rewardReference; String placeholders = ""; @@ -980,7 +1030,7 @@ public void checkDelayedTimedRewards() { // checkpoint can change its serialized key (for example by adding the // v2 progress marker), so completion and failure must follow this // reference rather than the original entry key. - AtomicReference currentEntry = new AtomicReference<>(entry.getKey()); + AtomicReference currentEntry = new AtomicReference<>(migratedEntry); replayOptions.setAsyncReplayCheckpointConsumer( checkpoint -> checkpointTimedReward(currentEntry, time, checkpoint)); enqueuePersistedReplay(() -> { @@ -1029,6 +1079,12 @@ private void dispatchOfflineRewards(boolean force) { continue; } if (!claimOfflineReward(rewardEntry)) continue; + try { + rewardEntry = ensureOfflineRewardOccurrence(rewardEntry); + } catch (Throwable failure) { + restoreOfflineReward(rewardEntry, failure); + continue; + } String[] parts = rewardEntry.split("%placeholders%", 2); QueuedReplay queuedReplay = parseQueuedReplay(parts[0]); 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 50045306b5..45a0ea00b0 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 @@ -383,6 +383,32 @@ public void shutdownPersistsAcceptedOverflowBeforeDiscardingQueuedPersistence() verify(inventory, times(1)).addItem(item); } + @Test + public void queuedReplayDeliveryDoesNotStartAfterShutdownSnapshot() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + fixture.handler.shutdown(); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + java.util.concurrent.ExecutionException failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> delivery.toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertTrue(AdvancedCoreUser.isReplayActionNotStarted(failure)); + verify(inventory, never()).addItem(item); + } + @Test public void giveItemAsyncFailsWithoutMutationWhenPlayerDisconnectsAfterEnqueue() throws Exception { Fixture fixture = createFixture(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 07e8bc6770..e611a2de6c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -211,6 +211,29 @@ void queuedOccurrenceIsForwardedBeforeAnyAsyncInjectorRuns() { assertEquals(occurrence, options.getValue().getAsyncReplayOccurrenceId()); } + @Test + void legacyQueuedOccurrenceIsPersistedBeforeAnyAsyncInjectorRuns() { + ArrayList persisted = new ArrayList<>( + List.of("VoteReward%placeholders%Server%pair%server-a")); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(new CompletableFuture<>()); + + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + user.checkOfflineRewards(); + + verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), options.capture()); + String occurrence = options.getValue().getAsyncReplayOccurrenceId(); + assertTrue(occurrence != null && !occurrence.isEmpty()); + assertTrue(persisted.get(0).contains("%asyncoccurrence%" + occurrence)); + } + @Test void deferredReplayRetainsOccurrenceAndCheckpoint() { ArrayList persisted = new ArrayList<>(); @@ -363,7 +386,13 @@ void failedAsyncTimedReplayIsRestoredAfterDueEntriesAreSaved() { String entry = "VoteReward%extime%1%placeholders%Server%pair%server-a"; ArrayList timed = new ArrayList<>(); timed.add(entry + "%ExecutionTime/%" + due); - when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)).thenReturn(new ArrayList<>(timed)); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(timed)); + org.mockito.Mockito.doAnswer(invocation -> { + timed.clear(); + timed.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), eq(false)); when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), any(RewardOptions.class))).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); @@ -441,11 +470,11 @@ void offlineCheckpointMigratesOnlyItsOwnDuplicateInFlightClaim() throws Exceptio assertEquals(2, persisted.size()); assertTrue(persisted.stream().anyMatch(value -> value.contains("%asyncprogress%v2-"))); - assertTrue(persisted.stream().anyMatch(value -> value.equals(entry))); + assertTrue(persisted.stream().allMatch(value -> value.contains("%asyncoccurrence%"))); user.checkOfflineRewards(); verify(rewardHandler).givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), any(RewardOptions.class)); - verify(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + verify(data, org.mockito.Mockito.atLeastOnce()).setStringList(eq("offlineRewardsPath"), any(), eq(false)); pending.complete(null); verify(rewardHandler, org.mockito.Mockito.times(2)).givePersistedQueueRewardAsync(eq(user), @@ -494,9 +523,10 @@ void timedAsyncCheckpointIsDurableAndMigratesTheExactEntryWhileInFlight() throws new HashMap<>(Map.of("Server", "server-a")))); assertEquals(1, persisted.size()); - assertTrue(persisted.get(0).startsWith("VoteReward%extime%12345%asyncprogress%v2-")); + assertTrue(persisted.get(0).startsWith("VoteReward%extime%12345%asyncoccurrence%")); + assertTrue(persisted.get(0).contains("%asyncprogress%v2-")); assertTrue(persisted.get(0).endsWith("%ExecutionTime/%" + due)); - verify(data).setStringList(eq("TimedRewards"), any(), eq(false)); + verify(data, org.mockito.Mockito.atLeastOnce()).setStringList(eq("TimedRewards"), any(), eq(false)); // The key has migrated, but its in-flight claim must migrate with it too. user.checkDelayedTimedRewards(); verify(rewardHandler, org.mockito.Mockito.times(1)).givePersistedQueueRewardAsync(eq(user), From ba7e622f3b59efee18cc3f423f247fa6e3b25bc9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:53:23 -0600 Subject: [PATCH 41/78] Preserve async reward replay failures --- .../api/rewards/RewardExecutor.java | 31 ++++++++++++++++--- .../rewards/builtin/RewardTempPermission.java | 14 +++++++++ .../rewards/BuiltinRewardBehaviorTest.java | 18 +++++++++++ .../tests/rewards/RewardExecutorTest.java | 8 ++++- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index d1fcd99944..71e928daf4 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -205,10 +205,33 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward rewar if (options.getAsyncReplayOccurrenceId() == null && activeOccurrenceId != null) { options.setAsyncReplayOccurrenceId(activeOccurrenceId); } - // Reward setup fires Bukkit events and resolves live player state. Keep that - // work on the caller's owner thread; individual injectors explicitly hand - // off only the portions that are safe to execute asynchronously. - return reward.giveRewardAsync(user, options); + // PlayerRewardEvent is explicitly asynchronous, so Bukkit rejects it when + // a nested reward reaches this dispatcher from an owner thread. Hand only + // reward setup to the async scheduler; injectors marshal their player/world + // work back to the appropriate owner thread and the flattened stage keeps + // the parent replay waiting for the complete child reward. + boolean primaryThread = false; + try { + primaryThread = Bukkit.isPrimaryThread(); + } catch (IllegalStateException | NullPointerException ignored) { + // Unit tests and early bootstrap can call this facade before Bukkit has + // installed a server. There is no primary server thread to leave then. + } + if (!primaryThread) return reward.giveRewardAsync(user, options); + + CompletableFuture> handoff = new CompletableFuture<>(); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + try { + handoff.complete(reward.giveRewardAsync(user, options)); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + return handoff.thenCompose(stage -> stage); } public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java index 01928de69b..d9de10929a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java @@ -24,6 +24,16 @@ private RewardTempPermission() { public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { handler.getInjectedRewards().add(new RewardInjectConfigurationSection("TempPermission") { + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public boolean requiresConfiguredDataForAsync() { + return true; + } + @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { @@ -31,6 +41,10 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat int time = section.getInt("Expiration"); if (!permission.isEmpty()) { if (time > 0) { + if (user.getPlayer() == null) { + throw new IllegalStateException( + "Player became unavailable before temporary permission delivery"); + } user.addPermission(permission, time); } else { extraDebug("Time is 0"); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 6b1122ecb8..78ac311c02 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -261,12 +261,30 @@ public void temporaryPermissionActuallyAddsPermission() { ConfigurationSection section = section("TempPermission"); section.set("Permission", "advancedcore.test"); section.set("Expiration", 90); + when(user.getPlayer()).thenReturn(mock(Player.class)); configInject(0).onRewardRequested(reward, user, section, placeholders); verify(user).addPermission("advancedcore.test", 90); } + @Test + public void temporaryPermissionFailurePropagatesDuringAsyncReplay() { + RewardTempPermission.register(handler, plugin); + ConfigurationSection data = new YamlConfiguration(); + ConfigurationSection section = data.createSection("TempPermission"); + section.set("Permission", "advancedcore.test"); + section.set("Expiration", 90); + when(user.getPlayer()).thenReturn(null); + + RewardInject inject = injects.get(0); + assertTrue(inject.supportsAsyncRequest()); + assertTrue(inject.requiresConfiguredDataForAsync()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> inject.onRewardRequestAsync(reward, user, data, placeholders).toCompletableFuture().join()); + verify(user, never()).addPermission(anyString(), org.mockito.ArgumentMatchers.anyLong()); + } + @Test public void commandsActuallyDispatchEveryCommandForm() { RewardCommands.register(handler, plugin); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 1e333978a1..52bdaf6769 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -166,12 +166,14 @@ public void asyncListDispatchUsesASeparateReplayContextForEveryEntry() { } @Test - public void asyncRewardSetupStaysOnThePrimaryThreadAndRetainsCompletion() { + public void asyncRewardSetupLeavesThePrimaryThreadAndRetainsCompletion() { Reward child = mock(Reward.class); CompletableFuture childResult = new CompletableFuture<>(); when(child.giveRewardAsync(eq(user), any(RewardOptions.class))).thenReturn(childResult); RewardOptions options = new RewardOptions(); Reward.ReplayState replayState = Reward.replayStateFor(new RewardOptions()); + BukkitScheduler scheduler = plugin.getBukkitScheduler(); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic replay = mockStatic(Reward.class)) { @@ -182,6 +184,10 @@ public void asyncRewardSetupStaysOnThePrimaryThreadAndRetainsCompletion() { when(child.getRewardName()).thenReturn("child"); CompletionStage result = executor.giveRewardAsync(user, child, options); + verify(scheduler).runTaskAsynchronously(eq(plugin), task.capture()); + verify(child, never()).giveRewardAsync(eq(user), any(RewardOptions.class)); + assertFalse(result.toCompletableFuture().isDone()); + task.getValue().run(); verify(child).giveRewardAsync(eq(user), any(RewardOptions.class)); assertSame(replayState, options.getAsyncReplayState()); assertEquals("parent/child", options.getAsyncReplayKey()); From 323471e713fde6d3fb01c390616c4d787051086b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:39:08 -0600 Subject: [PATCH 42/78] Retain failed legacy reward replays --- .../advancedcore/api/rewards/Reward.java | 10 ++++-- .../rewards/RewardAsyncInjectionTest.java | 32 +++++++++++++++++++ 2 files changed, 40 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 636777fb46..c59b796de6 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 @@ -891,6 +891,9 @@ private synchronized int highestCompletedCount() { private synchronized void setCheckpointConsumer(Consumer consumer) { checkpointConsumer = consumer; } + private synchronized boolean hasCheckpointConsumer() { + return checkpointConsumer != null; + } public CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, HashMap placeholders) { return persistCheckpointAsync(plugin, placeholders, 30, TimeUnit.SECONDS); @@ -1060,9 +1063,12 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan inject.onRewardRequest(this, user, getConfig().getConfigData(), placeholders)); } catch (Exception failure) { // Preserve the legacy per-injection isolation contract while allowing - // opted-in asynchronous injections to propagate durable failures. + // persisted replays to retain their queue entry when a player-bound + // legacy injection fails after an earlier asynchronous stage. failure.printStackTrace(); - result = CompletableFuture.completedFuture(null); + result = replayState.hasCheckpointConsumer() + ? CompletableFuture.failedFuture(failure) + : CompletableFuture.completedFuture(null); } } if (result == null) result = CompletableFuture.failedFuture( 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 30ab530845..8d59d5f2e2 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 @@ -1502,6 +1502,38 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, assertTrue(laterRan.get()); } + @Test + void failingLegacyInjectionRetainsPersistedReplay() throws Exception { + AtomicBoolean laterRan = new AtomicBoolean(); + handler.getInjectedRewards().add(new RewardInject("PlayerBoundLegacy") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + throw new NullPointerException("player disconnected"); + } + }); + handler.getInjectedRewards().add(asyncInjection("Later", CompletableFuture.completedFuture(null), laterRan)); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object replayState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertFalse(laterRan.get()); + } + @Test void synchronizedAsyncInjectionSerializesThroughCompletion() { List> completions = new ArrayList<>(); From 3e0c58410f8d78d02fc21eed3b96a1186679fac4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:51:23 -0600 Subject: [PATCH 43/78] Make reward deferral and shutdown durable --- .../api/user/AdvancedCoreUser.java | 23 +++++++++++++++-- .../bukkit/runtime/BukkitRuntimePlatform.java | 12 ++++----- .../tests/lifecycle/CoreRuntimeTest.java | 15 +++++++++++ .../tests/user/AdvancedCoreUserTest.java | 25 +++++++++++++++++++ 4 files changed, 67 insertions(+), 8 deletions(-) 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 71d2e0d595..8c9fb4eb17 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 @@ -616,8 +616,19 @@ public void addOfflineRewards(Reward reward, HashMap placeholder HashMap savedPlaceholders = placeholders == null ? new HashMap<>() : new HashMap<>(placeholders); if (options != null) savedPlaceholders.putAll(options.getPlaceholders()); - offlineRewards.add(queuedRewardReference(reward, options) + "%placeholders%" - + ArrayUtils.makeString(savedPlaceholders)); + String queued = queuedRewardReference(reward, options) + "%placeholders%" + + ArrayUtils.makeString(savedPlaceholders); + String occurrence = options == null ? null : options.getAsyncReplayOccurrenceId(); + int replacement = -1; + if (occurrence != null && !occurrence.isEmpty()) { + for (int index = offlineRewards.size() - 1; index >= 0; index--) { + if (!occurrence.equals(occurrenceId(offlineRewards.get(index)))) continue; + replacement = index; + offlineRewards.remove(index); + } + } + if (replacement < 0) offlineRewards.add(queued); + else offlineRewards.add(Math.min(replacement, offlineRewards.size()), queued); setOfflineRewards(offlineRewards); } } @@ -1178,6 +1189,14 @@ private void checkpointOfflineReward(AtomicReference currentEntry, Rewar private boolean claimOfflineReward(String rewardEntry) { synchronized (plugin) { ReplayClaims claims = replayClaims(); + String occurrence = occurrenceId(rewardEntry); + if (occurrence != null) { + boolean claimed = claims.offline.entrySet().stream().anyMatch(entry -> entry.getValue() > 0 + && occurrence.equals(occurrenceId(entry.getKey()))); + if (claimed) return false; + claims.offline.put(rewardEntry, 1); + return true; + } int occurrences = 0; for (String pending : getOfflineRewards()) if (rewardEntry.equals(pending)) occurrences++; int claimed = claims.offline.getOrDefault(rewardEntry, 0); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java index bfccf4023a..5ea6cc68f2 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java @@ -39,12 +39,6 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { JavascriptEngineHandler.getInstance().clearCachedEngine(); } }), - new Cleanup("MySQL", () -> { - if (plugin.isLoadUserData() && plugin.getOptions() != null - && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) && plugin.getMysql() != null) { - plugin.getMysql().close(); - } - }), new Cleanup("server data timestamp", () -> { if (plugin.getServerDataFile() != null) plugin.getServerDataFile().setLastUpdated(); })); @@ -58,6 +52,12 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { @Override public List afterExecutorShutdown() { return List.of( + new Cleanup("MySQL", () -> { + if (plugin.isLoadUserData() && plugin.getOptions() != null + && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) && plugin.getMysql() != null) { + plugin.getMysql().close(); + } + }), new Cleanup("plugin unload hook", plugin::onUnLoad), new Cleanup("skull cache", () -> { if (plugin.getSkullCacheHandler() != null) plugin.getSkullCacheHandler().close(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java index 58ac1268f8..6f1e2877a3 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java @@ -10,8 +10,11 @@ import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform.Cleanup; @@ -112,7 +115,13 @@ private ScheduledExecutorService executor(String name, List events) thro @Test void bukkitAdapterFlushesFullInventoryBeforeExecutorShutdown() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); FullInventoryHandler handler = mock(FullInventoryHandler.class); + MySQL mysql = mock(MySQL.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); when(plugin.getFullInventoryHandler()).thenReturn(handler); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(options.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); platform.beforeExecutorShutdown().stream() @@ -120,6 +129,12 @@ private ScheduledExecutorService executor(String name, List events) thro .findFirst().orElseThrow().action().run(); verify(handler).shutdown(); + assertTrue(platform.beforeExecutorShutdown().stream() + .noneMatch(cleanup -> cleanup.name().equals("MySQL"))); + platform.afterExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("MySQL")) + .findFirst().orElseThrow().action().run(); + verify(mysql).close(); assertTrue(platform.afterExecutorShutdown().stream() .noneMatch(cleanup -> cleanup.name().equals("full inventory handler"))); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index e611a2de6c..4fdc2981f7 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -260,6 +260,31 @@ void deferredReplayRetainsOccurrenceAndCheckpoint() { assertTrue(persisted.get(0).contains("Server%pair%server-a")); } + @Test + void deferredReplayAtomicallyReplacesItsExistingOccurrence() { + String occurrence = UUID.randomUUID().toString(); + ArrayList persisted = new ArrayList<>(List.of( + "old%asyncoccurrence%" + occurrence + "%placeholders%Server%pair%old", + "duplicate%asyncoccurrence%" + occurrence + "%placeholders%Server%pair%stale")); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); + when(reward.getRewardName()).thenReturn("VoteReward"); + RewardOptions options = new RewardOptions().addPlaceholder("Server", "server-a"); + options.setAsyncReplayOccurrenceId(occurrence); + + user.addOfflineRewards(reward, options.getPlaceholders(), options); + + assertEquals(1, persisted.size()); + assertTrue(persisted.get(0).contains("%asyncoccurrence%" + occurrence)); + assertTrue(persisted.get(0).contains("Server%pair%server-a")); + } + @Test void replayClaimCleanupRechecksSharedStateUnderItsLock() throws Exception { java.lang.reflect.Method replayClaims = AdvancedCoreUser.class.getDeclaredMethod("replayClaims"); From 091a339479f48648aa185e134e88b843ad5827d0 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:00:47 -0600 Subject: [PATCH 44/78] Keep replay deferral within its durable queue --- .../advancedcore/api/rewards/Reward.java | 14 +++++++--- .../api/rewards/RewardOptions.java | 6 +++++ .../api/user/AdvancedCoreUser.java | 1 + .../bukkit/runtime/BukkitRuntimePlatform.java | 5 ++++ .../core/runtime/AdvancedCoreRuntime.java | 4 +++ .../lifecycle/AdvancedCoreLifecycleTest.java | 3 ++- .../tests/lifecycle/CoreRuntimeTest.java | 26 +++++++++++++++++-- .../rewards/RewardAsyncInjectionTest.java | 21 +++++++++++++++ 8 files changed, 74 insertions(+), 6 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 c59b796de6..5600a4c7cd 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 @@ -1303,15 +1303,14 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption if (plugin.getOptions().isPauseRewards() || (plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) { checkRewardFile(); preserveReplayState(rewardOptions); - user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); - return CompletableFuture.completedFuture(null); + return deferRewardAsync(user, rewardOptions); } if (((((!rewardOptions.isOnline() || rewardOptions.getServer() != null) && !user.isOnline()) || allowOffline) && (!isForceOffline() && !rewardOptions.isForceOffline()))) { if (rewardOptions.isGiveOffline()) { checkRewardFile(); preserveReplayState(rewardOptions); - user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); + return deferRewardAsync(user, rewardOptions); } return CompletableFuture.completedFuture(null); } @@ -1331,6 +1330,15 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption return CompletableFuture.completedFuture(null); } + private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { + if (rewardOptions.isTimedQueueReplay()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Timed reward replay remains deferred in its original queue")); + } + user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); + return CompletableFuture.completedFuture(null); + } + /** * Give reward user. * 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 8a42f4c027..c034a74863 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 @@ -92,6 +92,11 @@ public class RewardOptions { @Getter @Setter private Consumer asyncReplayCheckpointConsumer; + + /** True while replaying an entry that must remain in the timed queue on deferral. */ + @Getter + @Setter + private boolean timedQueueReplay; public RewardOptions() { } @@ -247,6 +252,7 @@ RewardOptions copyForNestedDispatch(String replayKey) { copy.setAsyncReplayKey(replayKey); copy.setAsyncReplayOccurrenceId(asyncReplayOccurrenceId); copy.setAsyncReplayCheckpointConsumer(asyncReplayCheckpointConsumer); + copy.setTimedQueueReplay(timedQueueReplay); return copy; } 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 8c9fb4eb17..170ac26117 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 @@ -1035,6 +1035,7 @@ public void checkDelayedTimedRewards() { replayOptions.setAsyncReplayRegistryFingerprints(queuedReplay.asyncReplayRegistryFingerprints); replayOptions.setLegacyAsyncReplayCheckpoint(queuedReplay.legacyAsyncReplayCheckpoint); replayOptions.setAsyncReplayOccurrenceId(queuedReplay.asyncReplayOccurrenceId); + replayOptions.setTimedQueueReplay(true); replayOptions.addPlaceholder("date", "" + new SimpleDateFormat("EEE, d MMM yyyy HH:mm").format(new Date(time))); // Keep the due entry durable while asynchronous stages are running. A diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java index 5ea6cc68f2..c5b160cb61 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java @@ -55,6 +55,11 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { new Cleanup("MySQL", () -> { if (plugin.isLoadUserData() && plugin.getOptions() != null && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) && plugin.getMysql() != null) { + ScheduledExecutorService timer = plugin.getTimer(); + if (timer != null && !timer.isTerminated()) { + plugin.getLogger().warning("Leaving MySQL open because reward checkpoint tasks did not terminate"); + return; + } plugin.getMysql().close(); } }), diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java index 6eba089cec..134d284de2 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java @@ -61,6 +61,10 @@ public void shutdown() { shutdownNow(platform.getTimer()); shutdownNow(timeTimer); shutdownNow(platform.getInventoryTimer()); + await(platform.getLoginTimer(), 1, TimeUnit.SECONDS); + await(platform.getTimer(), 1, TimeUnit.SECONDS); + await(timeTimer, 1, TimeUnit.SECONDS); + await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); clean(platform.afterExecutorShutdown()); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java index aca904bb39..ffd567a4a0 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -65,7 +66,7 @@ public void shutdownCoordinatesExecutorsAndInventoryPersistence() throws Excepti verify(timeTimer).shutdown(); verify(timer).awaitTermination(2, TimeUnit.SECONDS); verify(loginTimer).awaitTermination(2, TimeUnit.SECONDS); - verify(inventoryTimer).awaitTermination(1, TimeUnit.SECONDS); + verify(inventoryTimer, times(2)).awaitTermination(1, TimeUnit.SECONDS); verify(timeTimer).awaitTermination(2, TimeUnit.SECONDS); verify(rewardHandler).shutdown(); verify(fullInventoryHandler).shutdown(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java index 6f1e2877a3..31a2cb53f1 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java @@ -55,11 +55,12 @@ private RuntimePlatform platform() { new AdvancedCoreRuntime(platform).shutdown(); assertEquals(List.of("pre", "login-stop", "timer-stop", "time-stop", "inventory-stop", "wait-log", "login-wait", "timer-wait", "time-wait", "inventory-wait", "rewards", - "login-force", "timer-force", "time-force", "inventory-force", "post"), events); + "login-force", "timer-force", "time-force", "inventory-force", + "login-wait", "timer-wait", "time-wait", "inventory-wait", "post"), events); verify(login).awaitTermination(2, TimeUnit.SECONDS); verify(timer).awaitTermination(2, TimeUnit.SECONDS); verify(time).awaitTermination(2, TimeUnit.SECONDS); - verify(inventory).awaitTermination(1, TimeUnit.SECONDS); + verify(inventory, times(2)).awaitTermination(1, TimeUnit.SECONDS); verify(platform, times(1)).getTimeTimer(); } @@ -138,4 +139,25 @@ private ScheduledExecutorService executor(String name, List events) thro assertTrue(platform.afterExecutorShutdown().stream() .noneMatch(cleanup -> cleanup.name().equals("full inventory handler"))); } + + @Test void bukkitAdapterDoesNotCloseMysqlWhileCheckpointTasksRemainActive() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(options.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + when(plugin.getTimer()).thenReturn(timer); + when(timer.isTerminated()).thenReturn(false); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + + platform.afterExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("MySQL")) + .findFirst().orElseThrow().action().run(); + + verify(mysql, never()).close(); + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index 8d59d5f2e2..4f3f9f6792 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 @@ -512,6 +512,27 @@ void offlineRequeueRetainsReplayStateAndLegacyActionMarkers() { .get("__advancedcore_replay_legacy_actions_marker")); } + @Test + void timedReplayDeferralRemainsInTheTimedQueue() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.getPlayerName()).thenReturn("Offline"); + RewardOptions options = new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true); + options.setTimedQueueReplay(true); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveRewardAsync(user, options).toCompletableFuture().join()); + } + + verify(user, never()).addOfflineRewards(any(), any(), any()); + } + @Test void legacySchedulerRejectionFailsAsyncRewardClosed() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From 48f13562e6134ccb02e7ada31891d7401e97028b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:12:01 -0600 Subject: [PATCH 45/78] Retain disconnected player reward outputs --- .../advancedcore/api/rewards/Reward.java | 5 ++- .../api/rewards/builtin/RewardActionBar.java | 2 +- .../api/rewards/builtin/RewardBossBar.java | 2 +- .../api/rewards/builtin/RewardEffect.java | 2 +- .../api/rewards/builtin/RewardExp.java | 8 ++--- .../api/rewards/builtin/RewardFirework.java | 2 +- .../api/rewards/builtin/RewardItems.java | 6 ++-- .../api/rewards/builtin/RewardMessages.java | 24 ++++++------- .../api/rewards/builtin/RewardPotions.java | 2 +- .../api/rewards/builtin/RewardSound.java | 2 +- .../rewards/builtin/RewardTempPermission.java | 2 +- .../api/rewards/builtin/RewardTitle.java | 2 +- .../api/rewards/injected/RewardInject.java | 17 +++++++--- .../rewards/RewardAsyncInjectionTest.java | 34 +++++++++++++++++++ 14 files changed, 78 insertions(+), 32 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 5600a4c7cd..51bae28d28 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 @@ -1055,7 +1055,10 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan CompletionStage result = CompletableFuture.failedFuture( new IllegalStateException("Reward injection did not produce a result")); try { - if (inject.supportsAsyncRequest()) { + if (replayState.hasCheckpointConsumer() && inject.isPlayerRequired() && user.getPlayer() == null) { + result = CompletableFuture.failedFuture( + new IllegalStateException("Player became unavailable before " + inject.getPath() + " delivery")); + } else if (inject.supportsAsyncRequest()) { result = inject.onRewardRequestAsync(this, user, getConfig().getConfigData(), placeholders); } else { try { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java index bbba30eb1b..31195d331d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java @@ -34,7 +34,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat section.getInt("Delay", 30)); return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("ActionBar") { + }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("ActionBar") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java index 1c959ab3cc..573fe4f9e4 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java @@ -34,7 +34,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder("DRAGON_HEAD"), new EditGUIValueInventory("BossBar") { + }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder("DRAGON_HEAD"), new EditGUIValueInventory("BossBar") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java index 3927491463..e7d8b15122 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java @@ -33,7 +33,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.DIAMOND), new EditGUIValueInventory("Effect") { + }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.DIAMOND), new EditGUIValueInventory("Effect") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java index 8e7fdf0e0f..ae446cea10 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java @@ -34,7 +34,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, user.giveExp(num); return null; } - }.asPlaceholder("EXP").priority(100).addEditButton( + }.requiresPlayer().asPlaceholder("EXP").priority(100).addEditButton( new EditGUIButton(new ItemBuilder("EXPERIENCE_BOTTLE"), new EditGUIValueInventory("EXP") { @Override public void openInventory(ClickEvent clickEvent) { @@ -57,7 +57,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, user.giveExpLevels(num); return null; } - }.asPlaceholder("EXP").priority(100).addEditButton( + }.requiresPlayer().asPlaceholder("EXP").priority(100).addEditButton( new EditGUIButton(new ItemBuilder("EXPERIENCE_BOTTLE"), new EditGUIValueInventory("EXPLevels") { @Override public void openInventory(ClickEvent clickEvent) { @@ -83,7 +83,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat user.giveExp(value); return "" + value; } - }.asPlaceholder("EXP").priority(100).validator(rangeValidator("EXP"))); + }.requiresPlayer().asPlaceholder("EXP").priority(100).validator(rangeValidator("EXP"))); handler.getInjectedRewards().add(new RewardInjectConfigurationSection("EXPLevels") { @Override @@ -95,7 +95,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat user.giveExpLevels(value); return "" + value; } - }.asPlaceholder("EXP").priority(100).validator(rangeValidator("EXPLevels"))); + }.requiresPlayer().asPlaceholder("EXP").priority(100).validator(rangeValidator("EXPLevels"))); } private static RewardInjectValidator zeroValidator(String message) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java index 4d24d7097a..30b6fc0377 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java @@ -40,7 +40,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder("FIREWORK_ROCKET"), new EditGUIValueInventory("Firework") { + }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder("FIREWORK_ROCKET"), new EditGUIValueInventory("Firework") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java index 55ad2ea430..549c0e3653 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java @@ -48,7 +48,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat user.giveItem(builder); return null; } - }.validator(itemValidator())); + }.requiresPlayer().validator(itemValidator())); } public static void registerRandomItem(RewardHandler handler, AdvancedCorePlugin plugin) { @@ -65,7 +65,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set value, HashMap placeholders) { user.sendMessage(value, placeholders); - return null; - } - }); + return null; + } + }.requiresPlayer()); - handler.getInjectedRewards().add(new RewardInjectStringList("Message") { + handler.getInjectedRewards().add(new RewardInjectStringList("Message") { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList value, HashMap placeholders) { user.sendMessage(value, placeholders); - return null; - } - }); + return null; + } + }.requiresPlayer()); handler.getInjectedRewards().add(new RewardInjectStringList("RandomMessage") { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList value, HashMap placeholders) { user.sendMessage(value.get(ThreadLocalRandom.current().nextInt(0, value.size())), placeholders); - return null; - } - }); + return null; + } + }.requiresPlayer()); handler.getInjectedRewards().add(new RewardInjectString("Messages.Player") { @Override @@ -85,7 +85,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, String value user.sendMessage(value, placeholders); return null; } - }.validator(new RewardInjectValidator() { + }.requiresPlayer().validator(new RewardInjectValidator() { @Override public void onValidate(Reward reward, RewardInject inject, ConfigurationSection data) { if (data.isString(inject.getPath()) && data.getString(inject.getPath()).isEmpty()) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java index d51f07ebad..7cbe84c787 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java @@ -33,7 +33,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Potions") { + }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Potions") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java index a2e9f4e7b4..588a42fc7d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java @@ -37,7 +37,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.NOTE_BLOCK), new EditGUIValueInventory("Sound") { + }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.NOTE_BLOCK), new EditGUIValueInventory("Sound") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java index d9de10929a..254a12dd6c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java @@ -54,7 +54,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("TempPermission") { + }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("TempPermission") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java index 0af6995d95..85bddc9a6e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java @@ -35,7 +35,7 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Title") { + }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Title") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java index e8e15f70c5..835d53a96b 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java @@ -44,8 +44,11 @@ public abstract class RewardInject extends Inject { @Setter private boolean synchronize = false; - @Getter - private boolean alwaysValid = false; + @Getter + private boolean alwaysValid = false; + + @Getter + private boolean playerRequired; @Getter private RewardInjectValidator validate; @@ -196,10 +199,16 @@ public synchronized CompletionStage runSynchronizedAsync(Supplier ignoredPlaceholders) { + invoked.set(true); + return null; + } + }.requiresPlayer(); + handler.getInjectedRewards().add(playerOutput); + AdvancedCoreUser disconnected = mock(AdvancedCoreUser.class); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object replayState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, disconnected, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertFalse(invoked.get()); + } + @Test void synchronizedAsyncInjectionSerializesThroughCompletion() { List> completions = new ArrayList<>(); From f4aad08a2cff7050dcb4f6276ffffa7bcb50e67e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:46:26 -0600 Subject: [PATCH 46/78] Skip absent player-only replay stages --- .../advancedcore/api/rewards/Reward.java | 4 +- .../rewards/RewardAsyncInjectionTest.java | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) 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 51bae28d28..3fb1e705f6 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 @@ -1055,7 +1055,9 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan CompletionStage result = CompletableFuture.failedFuture( new IllegalStateException("Reward injection did not produce a result")); try { - if (replayState.hasCheckpointConsumer() && inject.isPlayerRequired() && user.getPlayer() == null) { + if (replayState.hasCheckpointConsumer() && inject.isPlayerRequired() + && (isRewardInjectionApplicable(inject) || inject.hasPendingReplayWork(placeholders)) + && user.getPlayer() == null) { result = CompletableFuture.failedFuture( new IllegalStateException("Player became unavailable before " + inject.getPath() + " delivery")); } else if (inject.supportsAsyncRequest()) { 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 0a38828425..d6019753f4 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 @@ -1558,6 +1558,7 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, @Test void silentPlayerBoundLegacyInjectionRetainsPersistedReplay() throws Exception { AtomicBoolean invoked = new AtomicBoolean(); + data.set("Sound", "configured"); RewardInject playerOutput = new RewardInject("Sound") { @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, @@ -1589,6 +1590,44 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, assertFalse(invoked.get()); } + @Test + void absentPlayerBoundInjectionDoesNotBlockOfflineSafeReplay() throws Exception { + AtomicBoolean playerOutputRan = new AtomicBoolean(); + AtomicBoolean offlineSafeOutputRan = new AtomicBoolean(); + when(plugin.getTimer()).thenReturn(handler.getDelayedTimer()); + handler.getInjectedRewards().add(new RewardInjectString("Sound") { + @Override + public String onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, String ignoredValue, + HashMap ignoredPlaceholders) { + playerOutputRan.set(true); + return null; + } + }.requiresPlayer()); + handler.getInjectedRewards().add( + asyncInjection("OfflineSafe", CompletableFuture.completedFuture(null), offlineSafeOutputRan)); + AdvancedCoreUser disconnected = mock(AdvancedCoreUser.class); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object replayState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, disconnected, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + result.toCompletableFuture().join(); + assertFalse(playerOutputRan.get()); + assertTrue(offlineSafeOutputRan.get()); + } + @Test void synchronizedAsyncInjectionSerializesThroughCompletion() { List> completions = new ArrayList<>(); From e721ccf33d7244bf3394799984342fda61e6f0f9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:56:41 -0600 Subject: [PATCH 47/78] Gate replay on effective player output --- .../advancedcore/api/rewards/Reward.java | 4 +-- .../api/rewards/builtin/RewardBossBar.java | 3 +- .../api/rewards/builtin/RewardEffect.java | 3 +- .../api/rewards/builtin/RewardFirework.java | 3 +- .../api/rewards/builtin/RewardItems.java | 8 +++-- .../api/rewards/builtin/RewardMessages.java | 12 ++++--- .../api/rewards/builtin/RewardPotions.java | 4 ++- .../api/rewards/builtin/RewardSound.java | 3 +- .../rewards/builtin/RewardTempPermission.java | 4 ++- .../api/rewards/builtin/RewardTitle.java | 3 +- .../api/rewards/injected/RewardInject.java | 23 ++++++++++++++ .../rewards/RewardAsyncInjectionTest.java | 31 +++++++++++++++++++ 12 files changed, 85 insertions(+), 16 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 3fb1e705f6..5fa369e6c2 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 @@ -1055,8 +1055,8 @@ private CompletionStage requestInjectionAsync(RewardInject inject, Advan CompletionStage result = CompletableFuture.failedFuture( new IllegalStateException("Reward injection did not produce a result")); try { - if (replayState.hasCheckpointConsumer() && inject.isPlayerRequired() - && (isRewardInjectionApplicable(inject) || inject.hasPendingReplayWork(placeholders)) + if (replayState.hasCheckpointConsumer() + && inject.isPlayerRequiredFor(getConfig().getConfigData(), placeholders) && user.getPlayer() == null) { result = CompletableFuture.failedFuture( new IllegalStateException("Player became unavailable before " + inject.getPath() + " delivery")); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java index 573fe4f9e4..92ae4017f3 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardBossBar.java @@ -34,7 +34,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder("DRAGON_HEAD"), new EditGUIValueInventory("BossBar") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("BossBar.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder("DRAGON_HEAD"), new EditGUIValueInventory("BossBar") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java index e7d8b15122..fb65895d31 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardEffect.java @@ -33,7 +33,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.DIAMOND), new EditGUIValueInventory("Effect") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("Effect.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.DIAMOND), new EditGUIValueInventory("Effect") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java index 30b6fc0377..38469887b1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardFirework.java @@ -40,7 +40,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder("FIREWORK_ROCKET"), new EditGUIValueInventory("Firework") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("Firework.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder("FIREWORK_ROCKET"), new EditGUIValueInventory("Firework") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java index 549c0e3653..9ca8766b7d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java @@ -65,7 +65,9 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set data.isConfigurationSection("RandomItem") + && !data.getConfigurationSection("RandomItem").getKeys(false).isEmpty()) + .asPlaceholder("RandomItem").priority(90).validator(randomItemValidator())); } public static void registerItems(RewardHandler handler, AdvancedCorePlugin plugin) { @@ -87,7 +89,9 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set data.isConfigurationSection("Items") + && !data.getConfigurationSection("Items").getKeys(false).isEmpty()) + .priority(90).asPlaceholder("Item").validator(itemsValidator().addPath("OnlyOneItemChance")) .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("Items") { @Override public void openInventory(ClickEvent clickEvent) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java index 66b4663e5d..cd4fb85b16 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMessages.java @@ -36,7 +36,8 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, String value user.sendMessage(value, placeholders); return null; } - }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder("OAK_SIGN"), new EditGUIValueInventory("Messages") { + }.requiresPlayerWhen((data, placeholders) -> !data.getString("Message", "").isEmpty()) + .addEditButton(new EditGUIButton(new ItemBuilder("OAK_SIGN"), new EditGUIValueInventory("Messages") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); @@ -58,7 +59,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList !data.getStringList("Messages.Player").isEmpty())); handler.getInjectedRewards().add(new RewardInjectStringList("Message") { @Override @@ -67,7 +68,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList !data.getStringList("Message").isEmpty())); handler.getInjectedRewards().add(new RewardInjectStringList("RandomMessage") { @Override @@ -76,7 +77,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList !data.getStringList("RandomMessage").isEmpty())); handler.getInjectedRewards().add(new RewardInjectString("Messages.Player") { @Override @@ -85,7 +86,8 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, String value user.sendMessage(value, placeholders); return null; } - }.requiresPlayer().validator(new RewardInjectValidator() { + }.requiresPlayerWhen((data, placeholders) -> !data.getString("Messages.Player", "").isEmpty()) + .validator(new RewardInjectValidator() { @Override public void onValidate(Reward reward, RewardInject inject, ConfigurationSection data) { if (data.isString(inject.getPath()) && data.getString(inject.getPath()).isEmpty()) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java index 7cbe84c787..86df65ff0a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPotions.java @@ -33,7 +33,9 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Potions") { + }.requiresPlayerWhen((data, placeholders) -> data.isConfigurationSection("Potions") + && !data.getConfigurationSection("Potions").getKeys(false).isEmpty()) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Potions") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java index 588a42fc7d..98ea368872 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSound.java @@ -37,7 +37,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.NOTE_BLOCK), new EditGUIValueInventory("Sound") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("Sound.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.NOTE_BLOCK), new EditGUIValueInventory("Sound") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java index 254a12dd6c..7e43379583 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTempPermission.java @@ -54,7 +54,9 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("TempPermission") { + }.requiresPlayerWhen((data, placeholders) -> !data.getString("TempPermission.Permission", "").isEmpty() + && data.getInt("TempPermission.Expiration") > 0) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("TempPermission") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java index 85bddc9a6e..8ac292c56d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardTitle.java @@ -35,7 +35,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat } return null; } - }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Title") { + }.requiresPlayerWhen((data, placeholders) -> data.getBoolean("Title.Enabled")) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAINTING), new EditGUIValueInventory("Title") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java index 835d53a96b..9c43766adf 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java @@ -4,6 +4,7 @@ import java.util.HashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.function.BiPredicate; import java.util.function.Supplier; import org.bukkit.configuration.ConfigurationSection; @@ -49,6 +50,8 @@ public abstract class RewardInject extends Inject { @Getter private boolean playerRequired; + private BiPredicate> playerRequiredWhen = + (data, placeholders) -> true; @Getter private RewardInjectValidator validate; @@ -209,6 +212,26 @@ public RewardInject requiresPlayer() { playerRequired = true; return this; } + + /** + * Marks a player-bound injection and limits the requirement to payloads that + * actually produce player output. Pending replay work remains player-bound even + * if the current configuration no longer enables that output. + */ + public RewardInject requiresPlayerWhen( + BiPredicate> playerRequiredWhen) { + this.playerRequired = true; + this.playerRequiredWhen = java.util.Objects.requireNonNull(playerRequiredWhen, "playerRequiredWhen"); + return this; + } + + /** Whether this configured or pending payload currently needs a live player. */ + public boolean isPlayerRequiredFor(ConfigurationSection data, HashMap placeholders) { + if (!playerRequired) return false; + if (hasPendingReplayWork(placeholders)) return true; + if (!isAlwaysForceNoData() && !data.contains(getPath(), true)) return false; + return playerRequiredWhen.test(data, placeholders); + } public RewardInject priority(int priority) { setPriority(priority); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index d6019753f4..3e19e90b68 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 @@ -64,6 +64,7 @@ import com.bencodez.advancedcore.api.rewards.builtin.RewardExp; import com.bencodez.advancedcore.api.rewards.builtin.RewardItems; import com.bencodez.advancedcore.api.rewards.builtin.RewardPotions; +import com.bencodez.advancedcore.api.rewards.builtin.RewardSound; import com.bencodez.advancedcore.api.rewards.injected.RewardInject; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectInt; import com.bencodez.advancedcore.api.rewards.injected.RewardInjectString; @@ -1628,6 +1629,36 @@ public String onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, Stri assertTrue(offlineSafeOutputRan.get()); } + @Test + void disabledPlayerBoundInjectionDoesNotBlockOfflineSafeReplay() throws Exception { + AtomicBoolean offlineSafeOutputRan = new AtomicBoolean(); + when(plugin.getTimer()).thenReturn(handler.getDelayedTimer()); + data.set("Sound.Enabled", false); + RewardSound.register(handler, plugin); + handler.getInjectedRewards().add( + asyncInjection("OfflineSafe", CompletableFuture.completedFuture(null), offlineSafeOutputRan)); + AdvancedCoreUser disconnected = mock(AdvancedCoreUser.class); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor state = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); + state.setAccessible(true); + Object replayState = state.newInstance(new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, disconnected, new HashMap<>(), 0, + replayState, "AsyncReward", "occurrence"); + + result.toCompletableFuture().join(); + assertTrue(offlineSafeOutputRan.get()); + } + @Test void synchronizedAsyncInjectionSerializesThroughCompletion() { List> completions = new ArrayList<>(); From 5b6f81ce657725e8ee63a5a57ad11bf7a9fbebdc Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:11:24 -0600 Subject: [PATCH 48/78] Await fresh persisted reward delivery --- .../advancedcore/api/rewards/Reward.java | 29 +++++++++++-- .../api/user/AdvancedCoreUser.java | 7 ++++ .../rewards/RewardAsyncInjectionTest.java | 31 ++++++++++++++ .../tests/user/AdvancedCoreUserTest.java | 42 +++++++++++++++++++ 4 files changed, 106 insertions(+), 3 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 5fa369e6c2..4ae62f9557 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 @@ -436,6 +436,22 @@ private IncompatibleReplayCheckpointException(String replayKey) { } } + /** Internal completion signal for a persisted offline occurrence that was re-queued. */ + private static final class OfflineReplayDeferredException extends IllegalStateException { + private static final long serialVersionUID = 1L; + private OfflineReplayDeferredException() { + super("Persisted offline reward remains deferred"); + } + } + + /** Whether a completion failure means the offline occurrence was durably re-queued. */ + public static boolean isOfflineReplayDeferred(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof OfflineReplayDeferredException) return true; + } + return false; + } + private static RewardReplayFailure findReplayFailure(Throwable failure) { for (Throwable current = failure; current != null; current = current.getCause()) { if (current instanceof RewardReplayFailure) return (RewardReplayFailure) current; @@ -1322,7 +1338,7 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption if (canGive || isForceOffline() || rewardOptions.isForceOffline()) { plugin.debug(name + ": Passed requirements, attempting to give to " + user.getPlayerName() + "/" + user.getUUID()); - if (hasAsyncRewardInjection() || hasPersistedReplayCheckpoint(rewardOptions)) { + if (requiresAwaitedDelivery(rewardOptions)) { return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions); } try { @@ -1341,7 +1357,9 @@ private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOpti new IllegalStateException("Timed reward replay remains deferred in its original queue")); } user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); - return CompletableFuture.completedFuture(null); + return rewardOptions.getAsyncReplayCheckpointConsumer() == null + ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(new OfflineReplayDeferredException()); } /** @@ -1353,7 +1371,7 @@ private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOpti */ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { RewardOptions effectiveOptions = rewardOptions == null ? new RewardOptions() : rewardOptions; - if (hasAsyncRewardInjection() || hasPersistedReplayCheckpoint(effectiveOptions)) { + if (requiresAwaitedDelivery(effectiveOptions)) { giveRewardUserAsync(user, phs, effectiveOptions).exceptionally(failure -> { logRewardUserFailure(failure); return null; @@ -1424,6 +1442,11 @@ private static boolean hasPersistedReplayCheckpoint(RewardOptions options) { || (options.getAsyncReplayState() != null && options.getAsyncReplayState().hasPersistedCheckpoint()); } + private boolean requiresAwaitedDelivery(RewardOptions options) { + return options.getAsyncReplayCheckpointConsumer() != null || hasAsyncRewardInjection() + || hasPersistedReplayCheckpoint(options); + } + private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap phs) { Player player = user.getPlayer(); 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 170ac26117..94d5cd6f68 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 @@ -1126,6 +1126,7 @@ private void dispatchOfflineRewards(boolean force) { } return replay.handle((ignored, failure) -> { if (failure == null) completeOfflineReward(currentEntry.get()); + else if (Reward.isOfflineReplayDeferred(failure)) releaseDeferredOfflineReward(currentEntry.get()); else restoreOfflineReward(currentEntry.get(), failure); return null; }); @@ -1216,6 +1217,12 @@ private void completeOfflineReward(String rewardEntry) { } } + private void releaseDeferredOfflineReward(String rewardEntry) { + synchronized (plugin) { + releaseOfflineReward(rewardEntry); + } + } + /** Retains and updates a queue entry only after an asynchronous replay fails. */ private void restoreOfflineReward(String rewardEntry, Throwable failure) { // Match addOfflineRewards' lock so a newly queued reward cannot be lost 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 3e19e90b68..d1014b0fec 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 @@ -30,6 +30,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -2046,6 +2047,36 @@ void persistedReplayFailsWhenItsPlayerDisappearsBeforePreparation() { } } + @Test + void freshPersistedReplayUsesAwaitedLegacyInjectionChain() throws Exception { + Player player = mock(Player.class); + when(user.getPlayer()).thenReturn(player); + when(user.getPlayerName()).thenReturn("Queued"); + when(user.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(plugin.getTimer()).thenReturn(handler.getDelayedTimer()); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + AtomicBoolean injected = new AtomicBoolean(); + handler.getInjectedRewards().add(new RewardInject("LegacyItem") { + @Override + public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + injected.set(true); + return null; + } + }); + CountDownLatch checkpointed = new CountDownLatch(1); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> checkpointed.countDown()); + + reward.giveRewardUser(user, new HashMap<>(), options); + + assertTrue(checkpointed.await(2, TimeUnit.SECONDS)); + assertTrue(injected.get()); + } + @Test void replayCheckpointSkipsAlreadyAppliedInjectionAfterLaterFailure() throws Exception { AtomicInteger alreadyApplied = new AtomicInteger(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 4fdc2981f7..48e0ca0e02 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -26,6 +27,8 @@ import java.util.logging.Logger; import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -405,6 +408,45 @@ void failedAsyncOfflineReplayIsRestoredForRetry() { assertTrue(saved.getAllValues().stream().allMatch(value -> value.size() == initial.size())); } + @Test + void identicalOfflineRedeferralSurvivesClaimCompletion() throws Exception { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any()); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any(), eq(false)); + when(plugin.getOptions().isPauseRewards()).thenReturn(true); + when(rewardHandler.getPlaceholders()).thenReturn(new CopyOnWriteArrayList<>()); + when(rewardHandler.getInjectedRequirements()).thenReturn(new CopyOnWriteArrayList<>()); + Reward queuedReward = new Reward("VoteReward", new YamlConfiguration().createSection("Reward")); + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(queuedReward, plugin); + RewardOptions queuedOptions = new RewardOptions(); + queuedOptions.setAsyncReplayOccurrenceId(UUID.randomUUID().toString()); + queuedOptions.addPlaceholder("ExecDate", "123"); + queuedOptions.addPlaceholder("date", "stored-date"); + user.addOfflineRewards(queuedReward, queuedOptions.getPlaceholders(), queuedOptions); + String original = stored.get().get(0); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenAnswer(invocation -> queuedReward.giveRewardAsync(user, + invocation.getArgument(2, RewardOptions.class))); + PluginManager pluginManager = mock(PluginManager.class); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + user.checkOfflineRewards(); + } + + assertEquals(List.of(original), stored.get()); + } + @Test void failedAsyncTimedReplayIsRestoredAfterDueEntriesAreSaved() { long due = System.currentTimeMillis() - 1_000; From ea041593393e9202a0b7865bd1d8e73dea4fe4f0 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:19:13 -0600 Subject: [PATCH 49/78] Preserve non-staggered command timing --- .../advancedcore/api/misc/MiscUtils.java | 2 +- .../tests/misc/MiscUtilsTest.java | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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 cb3b67c69d..0ee4b1a930 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java @@ -291,7 +291,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, true); + stagger && index > 0, stagger || index == 0); }); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java index f3502baac9..034d8d664f 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/MiscUtilsTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -167,4 +168,36 @@ public void asyncStaggeredCommandsCompleteOnlyAfterEveryScheduledDispatch() { verify(server).dispatchCommand(console, "say three"); } } + + @Test + public void asyncNonStaggeredCommandsStayOnTheInitialServerTick() { + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + ArgumentCaptor initial = ArgumentCaptor.forClass(Runnable.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.getOfflinePlayer("Ben")).thenReturn(null); + + java.util.concurrent.CompletionStage completion = miscUtils.executeConsoleCommandsAsync("Ben", + new ArrayList<>(List.of("say one", "say two", "say three")), new HashMap<>(), false); + verify(scheduler).runTask(eq(plugin), initial.capture()); + assertFalse(completion.toCompletableFuture().isDone()); + + initial.getValue().run(); + completion.toCompletableFuture().join(); + + verify(scheduler).runTask(eq(plugin), any(Runnable.class)); + verify(scheduler, times(2)).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + verify(scheduler, never()).runTaskLater(eq(plugin), any(Runnable.class), anyLong()); + verify(server).dispatchCommand(console, "say one"); + verify(server).dispatchCommand(console, "say two"); + verify(server).dispatchCommand(console, "say three"); + } + } } From c439becf55905891d7939973e37c70afc964ce9c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:29:14 -0600 Subject: [PATCH 50/78] Checkpoint completed nested reward children --- .../advancedcore/api/rewards/Reward.java | 54 +++++++++++++++++++ .../api/rewards/RewardExecutor.java | 13 +---- .../rewards/RewardAsyncInjectionTest.java | 43 +++++++++++++++ 3 files changed, 99 insertions(+), 11 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 4ae62f9557..7942bcb169 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 @@ -671,6 +671,60 @@ public static CompletionStage> replayNestedRewardSnapshot(AdvancedC return replayState.persistCheckpointAsync(plugin, placeholders).thenApply(ignored -> snapshot); } + /** + * Executes a frozen nested reward list sequentially and checkpoints each + * completed child. The parent cursor is consulted before dispatch so a child + * that disappeared from the reward registry after completing is never + * resolved again during replay. + */ + public static CompletionStage replayNestedRewardSequence(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, List configuredRewards, + ReplayState replayState, String activeKey, + BiFunction> dispatch) { + if (replayState == null || activeKey == null) { + CompletionStage sequence = CompletableFuture.completedFuture(null); + List rewards = configuredRewards == null ? List.of() : configuredRewards; + for (int index = 0; index < rewards.size(); index++) { + String rewardName = rewards.get(index); + int rewardIndex = index; + sequence = sequence.thenCompose(ignored -> dispatch.apply(rewardName, rewardIndex)); + } + return sequence; + } + String stableLane = lane == null ? "nested" : lane; + String storageKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, stableLane); + return replayNestedRewardSnapshot(plugin, placeholders, stableLane, configuredRewards, replayState, activeKey) + .thenCompose(rewards -> { + String storedProgress = replayMetadata(placeholders, replayState, storageKey); + int completed; + try { + completed = storedProgress == null ? 0 : Integer.parseInt(storedProgress); + } catch (NumberFormatException failure) { + return CompletableFuture.failedFuture( + new IllegalStateException("Malformed nested reward replay progress", failure)); + } + if (storedProgress != null) replayState.recordReplayMetadata(storageKey, storedProgress); + if (completed < 0 || completed > rewards.size()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Nested reward replay progress exceeds snapshot")); + } + CompletionStage sequence = CompletableFuture.completedFuture(null); + for (int index = completed; index < rewards.size(); index++) { + String rewardName = rewards.get(index); + int rewardIndex = index; + int completedCount = index + 1; + sequence = sequence.thenCompose(ignored -> dispatch.apply(rewardName, rewardIndex)) + .thenCompose(ignored -> { + String progress = String.valueOf(completedCount); + placeholders.put(storageKey, progress); + replayState.recordReplayMetadata(storageKey, progress); + return replayState.persistCheckpointAsync(plugin, placeholders); + }); + } + return sequence; + }); + } + private static String replaySequenceKey(String prefix, String activeKey, String lane) { return prefix + digest(activeKey.length() + ":" + activeKey + lane.length() + ":" + lane); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index 71e928daf4..fb09bc9043 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -128,15 +128,9 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio || Reward.hasReplayNestedRewardSnapshot(options.getPlaceholders(), nestedLane, replayState, parentReplayKey)) { final String stableParentReplayKey = parentReplayKey; - return Reward.replayNestedRewardSnapshot(plugin, options.getPlaceholders(), nestedLane, + return Reward.replayNestedRewardSequence(plugin, options.getPlaceholders(), nestedLane, data.isList(path) ? new ArrayList<>(data.getStringList(path)) : java.util.List.of(), replayState, - stableParentReplayKey) - .thenCompose(rewards -> { - CompletionStage sequence = CompletableFuture.completedFuture(null); - for (int index = 0; index < rewards.size(); index++) { - String nestedReward = rewards.get(index); - int nestedIndex = index; - sequence = sequence.thenCompose(ignored -> { + stableParentReplayKey, (nestedReward, nestedIndex) -> { // Clone only when this child is actually reached. This lets // metadata from earlier children be merged before a later // child receives its isolated ordinary placeholders. @@ -153,9 +147,6 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio return childResult; }); }); - } - return sequence; - }); } if (data.isConfigurationSection(path)) { return giveSectionRewardAsync(user, data, path, context); 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 d1014b0fec..861223cda4 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 @@ -1817,6 +1817,49 @@ void removedSubRewardsSectionCannotDropItsPendingNestedSnapshot() throws Excepti assertThrows(java.util.concurrent.CompletionException.class, () -> resumed.toCompletableFuture().join()); } + @Test + void completedNestedChildIsSkippedBeforeRegistryResolutionOnRetry() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance(new HashMap<>(), + new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + List checkpoints = new ArrayList<>(); + setConsumer.invoke(replayState, + (java.util.function.Consumer) checkpoints::add); + HashMap placeholders = new HashMap<>(); + List firstAttempt = new ArrayList<>(); + + CompletionStage failed = Reward.replayNestedRewardSequence(plugin, placeholders, + "nested-list:Rewards", List.of("removed", "remaining"), replayState, "AsyncReward/0", + (rewardName, index) -> { + firstAttempt.add(rewardName); + return index == 0 ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(new IllegalStateException("later child failed")); + }); + assertThrows(java.util.concurrent.CompletionException.class, () -> failed.toCompletableFuture().join()); + assertEquals(List.of("removed", "remaining"), firstAttempt); + assertFalse(checkpoints.isEmpty()); + + List retried = new ArrayList<>(); + Reward.replayNestedRewardSequence(plugin, placeholders, "nested-list:Rewards", List.of(), replayState, + "AsyncReward/0", (rewardName, index) -> { + retried.add(rewardName); + return CompletableFuture.completedFuture(null); + }).toCompletableFuture().join(); + assertEquals(List.of("remaining"), retried); + } + @Test void everyNestedRewardInjectorWaitsForItsSelectedChild() { handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); From 3a7964616986d2c4609a009771487f874dcc24fa Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:35:34 -0600 Subject: [PATCH 51/78] Checkpoint built-in nested reward sequences --- .../builtin/RewardAdvancedRewards.java | 18 ++++-------- .../rewards/builtin/RewardAdvancedWorld.java | 28 +++++++------------ .../api/rewards/builtin/RewardLucky.java | 12 +++----- 3 files changed, 19 insertions(+), 39 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java index af1183f63d..c4fe365a14 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java @@ -81,26 +81,18 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU String parentOccurrenceId = Reward.currentReplayOccurrenceId(); ConfigurationSection section = data.getConfigurationSection(getPath()); ArrayList configured = ArrayUtils.convert(section.getKeys(false)); - return Reward.replayNestedRewardSnapshot(plugin, placeholders, "advanced-rewards:" + getPath(), - configured, replayState, parentReplayKey).thenCompose(rewards -> { - for (String rewardName : rewards) { + return Reward.replayNestedRewardSequence(plugin, placeholders, "advanced-rewards:" + getPath(), + configured, replayState, parentReplayKey, (rewardName, childIndex) -> { if (!section.contains(rewardName, true)) { return CompletableFuture.failedFuture(new IllegalStateException( "Pending nested reward configuration is missing: " + rewardName)); } - } - CompletionStage sequence = CompletableFuture.completedFuture(null); - for (int index = 0; index < rewards.size(); index++) { - String rewardName = rewards.get(index); - int childIndex = index; - sequence = sequence.thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, + return Reward.continueOnServerThread(plugin, user, () -> handler.giveRewardAsync(user, section, rewardName, Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), replayState, parentReplayKey, rewardName + ":" + childIndex, parentOccurrenceId) - .setPrefix(reward.getRewardName() + "_AdvancedRewards")))); - } - return sequence.thenApply(ignored -> (Object) null); - }); + .setPrefix(reward.getRewardName() + "_AdvancedRewards"))); + }).thenApply(ignored -> (Object) null); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java index e72bd679d1..0b2784d8a4 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java @@ -63,28 +63,20 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); String parentReplayKey = Reward.currentReplayKey(); String parentOccurrenceId = Reward.currentReplayOccurrenceId(); - return Reward.replayNestedRewardSnapshot(plugin, placeholders, "advanced-world:" + getPath(), - new ArrayList<>(section.getKeys(false)), replayState, parentReplayKey) - .thenCompose(worlds -> Reward.continueOnServerThread(plugin, user, () -> { - for (String key : worlds) { + return Reward.replayNestedRewardSequence(plugin, placeholders, "advanced-world:" + getPath(), + new ArrayList<>(section.getKeys(false)), replayState, parentReplayKey, (key, childIndex) -> { if (!section.contains(key, true)) { return CompletableFuture.failedFuture(new IllegalStateException( "Pending nested reward configuration is missing: " + key)); } - } - CompletionStage sequence = CompletableFuture.completedFuture(null); - for (int index = 0; index < worlds.size(); index++) { - String key = worlds.get(index); - int childIndex = index; - section.set(key + ".Worlds", ArrayUtils.convert(new String[] { key })); - sequence = sequence.thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> handler.giveRewardAsync(user, section, key, - Reward.withReplayState(new RewardOptions().withPlaceHolder(placeholders), replayState, - parentReplayKey, key + ":" + childIndex, parentOccurrenceId) - .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld")))); - } - return sequence.thenApply(ignored -> (String) null); - })); + return Reward.continueOnServerThread(plugin, user, () -> { + section.set(key + ".Worlds", ArrayUtils.convert(new String[] { key })); + return handler.giveRewardAsync(user, section, key, + Reward.withReplayState(new RewardOptions().withPlaceHolder(placeholders), replayState, + parentReplayKey, key + ":" + childIndex, parentOccurrenceId) + .setPrefix(sourceReward.getRewardName() + "_AdvancedWorld")); + }); + }).thenApply(ignored -> (String) null); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java index c30bdb146b..9b2344508c 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java @@ -110,22 +110,18 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor return paths.isEmpty() ? null : String.join("\n", paths); }); if (choices == null) return CompletableFuture.completedFuture(null); - CompletionStage sequence = Reward.persistReplayMetadataAsync(plugin, placeholders); com.bencodez.advancedcore.api.rewards.Reward.ReplayState replayState = Reward.currentReplayState(); String parentReplayKey = Reward.currentReplayKey(); String parentOccurrenceId = Reward.currentReplayOccurrenceId(); - int luckyIndex = 0; - for (String path : choices.split("\\n")) { - final int childIndex = luckyIndex++; - sequence = sequence.thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, () -> { + return Reward.replayNestedRewardSequence(plugin, placeholders, "lucky:" + getPath(), + java.util.List.of(choices.split("\\n")), replayState, parentReplayKey, (path, childIndex) -> + Reward.continueOnServerThread(plugin, user, () -> { RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) .withPrefix(reward.getName()).withPlaceHolder(placeholders); Reward.withReplayState(builder.getRewardOptions(), replayState, parentReplayKey, path + ":" + childIndex, parentOccurrenceId); return builder.sendAsync(user); - })); - } - return sequence.thenApply(ignored -> null); + })).thenApply(ignored -> null); } @Override From 40d5325cf6af4f0fddf08c835c94a339af577ee1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:48:32 -0600 Subject: [PATCH 52/78] Scope replay side effects to nested paths --- .../advancedcore/api/rewards/Reward.java | 14 ++++++++++++++ .../api/rewards/builtin/RewardChoices.java | 2 +- .../tests/rewards/RewardAsyncInjectionTest.java | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) 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 7942bcb169..106d5b973f 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 @@ -468,6 +468,20 @@ private static RewardReplayFailure findReplayFailure(Throwable failure) { /** Returns the unique logical reward occurrence currently invoking an injector. */ public static String currentReplayOccurrenceId() { return ACTIVE_REPLAY_OCCURRENCE_ID.get(); } + /** + * Returns a stable side-effect identity scoped to the current replay path. + * Repeated nested occurrences share a top-level occurrence id but have + * distinct replay keys, so both parts are required for deduplication. + */ + public static String currentReplaySideEffectOccurrenceId() { + String occurrenceId = currentReplayOccurrenceId(); + String replayKey = currentReplayKey(); + if (occurrenceId == null || occurrenceId.isEmpty() || replayKey == null || replayKey.isEmpty()) { + return occurrenceId; + } + return occurrenceId + ":" + digest(replayKey); + } + /** Obtains one shared replay state for a group of nested dispatches. */ public static ReplayState replayStateFor(RewardOptions options) { ReplayState replayState = options.getAsyncReplayState(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java index c54d5a1df8..078434b7b9 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java @@ -66,7 +66,7 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, boolean valu public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreUser user, boolean value, HashMap placeholders) { if (!value && !hasPendingReplayWork(placeholders)) return CompletableFuture.completedFuture(null); - String occurrenceId = Reward.currentReplayOccurrenceId(); + String occurrenceId = Reward.currentReplaySideEffectOccurrenceId(); String choice = Reward.replaySelection(placeholders, () -> value ? user.getChoicePreference(reward.getName()) : null); if (choice == null || choice.isEmpty() || choice.equalsIgnoreCase("none")) { 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 861223cda4..0dd02b88c7 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 @@ -1974,6 +1974,22 @@ void unclaimedChoiceIsAddedOnlyAfterItsSelectionIsDurable() throws Exception { result.toCompletableFuture().join(); } + @Test + void replaySideEffectOccurrenceDistinguishesRepeatedNestedChildren() { + try (org.mockito.MockedStatic replay = org.mockito.Mockito.mockStatic(Reward.class, + org.mockito.Mockito.CALLS_REAL_METHODS)) { + replay.when(Reward::currentReplayOccurrenceId).thenReturn("parent-occurrence"); + replay.when(Reward::currentReplayKey).thenReturn("parent/Choice:0/0"); + String first = Reward.currentReplaySideEffectOccurrenceId(); + replay.when(Reward::currentReplayKey).thenReturn("parent/Choice:1/0"); + String second = Reward.currentReplaySideEffectOccurrenceId(); + + assertNotEquals(first, second); + assertTrue(first.startsWith("parent-occurrence:")); + assertTrue(second.startsWith("parent-occurrence:")); + } + } + @Test void javascriptChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { ConfigurationSection javascript = data.createSection("Javascript"); From 2aa72e97158b9daa55b9db8988443188488993ea Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:56:30 -0600 Subject: [PATCH 53/78] Preserve legacy missing reward behavior --- .../advancedcore/api/rewards/RewardExecutor.java | 2 +- .../tests/rewards/RewardExecutorTest.java | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index fb09bc9043..bd48ab6aa7 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -251,7 +251,7 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, String rewar user.getPlayerName(), command, context.getPlaceholders())); } Reward resolved = handler.getReward(reward); - if (resolved == null && context.getOptions().getAsyncReplayState() != null) { + if (resolved == null && context.getOptions().getAsyncReplayCheckpointConsumer() != null) { return CompletableFuture.failedFuture( new IllegalStateException("Nested replay reward could not be resolved: " + reward)); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 52bdaf6769..dbc90efa6b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -298,6 +298,20 @@ public void snapshottedListFailsWhenANamedChildDisappears() { verify(missingReward).giveRewardAsync(eq(user), any(RewardOptions.class)); } + @Test + public void freshAsyncListSkipsUnknownChildAndContinues() { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("Missing", "Valid"))); + Reward valid = mock(Reward.class); + when(handler.getReward("Valid")).thenReturn(valid); + when(valid.giveRewardAsync(eq(user), any(RewardOptions.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + + executor.giveRewardAsync(user, data, "Rewards", new RewardOptions()).toCompletableFuture().join(); + + verify(valid).giveRewardAsync(eq(user), any(RewardOptions.class)); + } + @Test public void stringRewardDispatchesNamedReward() { YamlConfiguration data = new YamlConfiguration(); From 9d35eaf326442b1368c63f20f1b38779a0b6c8ef Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:04:09 -0600 Subject: [PATCH 54/78] Distinguish durable reward replay failures --- .../advancedcore/api/rewards/Reward.java | 8 +++++++ .../api/rewards/RewardExecutor.java | 8 ++++--- .../tests/rewards/RewardExecutorTest.java | 24 ++++++++++++++++++- 3 files changed, 36 insertions(+), 4 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 106d5b973f..752c77b918 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 @@ -505,6 +505,14 @@ public static ReplayState replayStateFor(RewardOptions options) { return replayState; } + /** Returns whether these options participate in durable queued replay. */ + public static boolean isDurableReplay(RewardOptions options) { + if (options == null) return false; + if (options.getAsyncReplayCheckpointConsumer() != null) return true; + ReplayState replayState = options.getAsyncReplayState(); + return replayState != null && replayState.hasCheckpointConsumer(); + } + /** * Copies the durable portion of an in-flight replay into options before the * reward is deferred. Pause/vanish handling happens before the async diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index bd48ab6aa7..2135a2dda0 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -153,8 +153,10 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio } String nestedReward = data.getString(path, ""); if (nestedReward.isEmpty()) { - return CompletableFuture.failedFuture( - new IllegalStateException("Nested replay configuration could not be resolved: " + path)); + return Reward.isDurableReplay(options) + ? CompletableFuture.failedFuture( + new IllegalStateException("Nested replay configuration could not be resolved: " + path)) + : CompletableFuture.completedFuture(null); } return giveRewardAsync(user, nestedReward, options); } @@ -251,7 +253,7 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, String rewar user.getPlayerName(), command, context.getPlaceholders())); } Reward resolved = handler.getReward(reward); - if (resolved == null && context.getOptions().getAsyncReplayCheckpointConsumer() != null) { + if (resolved == null && Reward.isDurableReplay(context.getOptions())) { return CompletableFuture.failedFuture( new IllegalStateException("Nested replay reward could not be resolved: " + reward)); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index dbc90efa6b..7ba522ad60 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -312,6 +312,26 @@ public void freshAsyncListSkipsUnknownChildAndContinues() { verify(valid).giveRewardAsync(eq(user), any(RewardOptions.class)); } + @Test + public void freshMissingConfigurationPathIsANoOp() { + YamlConfiguration data = new YamlConfiguration(); + + executor.giveRewardAsync(user, data, "Optional.Fallback", new RewardOptions()) + .toCompletableFuture().join(); + } + + @Test + public void inheritedDurableStateFailsWhenSelectedRewardDisappears() { + RewardOptions queued = new RewardOptions(); + queued.setAsyncReplayCheckpointConsumer(ignored -> { }); + Reward.ReplayState durableState = Reward.replayStateFor(queued); + RewardOptions child = new RewardOptions(); + child.setAsyncReplayState(durableState); + + assertThrows(CompletionException.class, + () -> executor.giveRewardAsync(user, "Missing", child).toCompletableFuture().join()); + } + @Test public void stringRewardDispatchesNamedReward() { YamlConfiguration data = new YamlConfiguration(); @@ -465,9 +485,11 @@ public void unresolvedPersistedRewardFailsSoTheQueueCanRetainIt() { @Test public void unresolvedNestedConfigurationFailsSoReplayCannotDropIt() { YamlConfiguration data = new YamlConfiguration(); + RewardOptions replay = new RewardOptions(); + replay.setAsyncReplayCheckpointConsumer(ignored -> { }); assertThrows(CompletionException.class, - () -> executor.giveRewardAsync(user, data, "Removed", new RewardOptions()) + () -> executor.giveRewardAsync(user, data, "Removed", replay) .toCompletableFuture().join()); } From 3b98b9c2eaefcccb31854febf42455ea7b6c3ee5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:12:04 -0600 Subject: [PATCH 55/78] Keep durable child deferral with parent queue --- .../advancedcore/api/rewards/Reward.java | 5 +++- .../api/rewards/RewardOptions.java | 4 ++- .../rewards/RewardAsyncInjectionTest.java | 25 +++++++++++++++++++ 3 files changed, 32 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 752c77b918..843e6d1e68 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 @@ -1432,6 +1432,9 @@ private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOpti return CompletableFuture.failedFuture( new IllegalStateException("Timed reward replay remains deferred in its original queue")); } + if (isDurableReplay(rewardOptions) && rewardOptions.getAsyncReplayCheckpointConsumer() == null) { + return CompletableFuture.failedFuture(new OfflineReplayDeferredException()); + } user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions); return rewardOptions.getAsyncReplayCheckpointConsumer() == null ? CompletableFuture.completedFuture(null) @@ -1501,7 +1504,7 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< return CompletableFuture.failedFuture(throwable); } if (placeholders == null) { - if (rewardOptions.getAsyncReplayCheckpointConsumer() != null || hasPersistedReplayCheckpoint(rewardOptions)) { + if (isDurableReplay(rewardOptions) || hasPersistedReplayCheckpoint(rewardOptions)) { return CompletableFuture.failedFuture( new IllegalStateException("Player became unavailable before persisted reward replay")); } 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 c034a74863..bcda82489a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java @@ -251,7 +251,9 @@ RewardOptions copyForNestedDispatch(String replayKey) { copy.setLegacyAsyncReplayCheckpoint(legacyAsyncReplayCheckpoint); copy.setAsyncReplayKey(replayKey); copy.setAsyncReplayOccurrenceId(asyncReplayOccurrenceId); - copy.setAsyncReplayCheckpointConsumer(asyncReplayCheckpointConsumer); + // The checkpoint consumer belongs to the queued parent occurrence. Nested + // children share its ReplayState but must never replace or complete that + // queue entry independently. copy.setTimedQueueReplay(timedQueueReplay); return copy; } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index 0dd02b88c7..3baee194a6 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 @@ -535,6 +535,31 @@ void timedReplayDeferralRemainsInTheTimedQueue() { verify(user, never()).addOfflineRewards(any(), any(), any()); } + @Test + void inheritedDurableChildDefersToItsParentQueue() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.getPlayerName()).thenReturn("Offline"); + RewardOptions owner = new RewardOptions(); + owner.setAsyncReplayCheckpointConsumer(ignored -> { }); + RewardOptions child = new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true); + child.setAsyncReplayState(Reward.replayStateFor(owner)); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + java.util.concurrent.CompletionException failure = assertThrows( + java.util.concurrent.CompletionException.class, + () -> reward.giveRewardAsync(user, child).toCompletableFuture().join()); + assertTrue(Reward.isOfflineReplayDeferred(failure)); + } + + verify(user, never()).addOfflineRewards(any(), any(), any()); + } + @Test void legacySchedulerRejectionFailsAsyncRewardClosed() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From 57170f2de59aa8201cc6fa10af66de7370bc08d5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:25:07 -0600 Subject: [PATCH 56/78] Isolate fresh replay metadata from callers --- .../advancedcore/api/rewards/Reward.java | 39 ++++++++++++------- .../api/rewards/RewardExecutor.java | 4 +- .../rewards/RewardAsyncInjectionTest.java | 6 ++- .../tests/rewards/RewardExecutorTest.java | 34 ++++++++++++++-- 4 files changed, 62 insertions(+), 21 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 843e6d1e68..bb070371d2 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 @@ -535,18 +535,16 @@ public static String replaySelection(HashMap placeholders, Suppl String activeKey = ACTIVE_REPLAY_KEY.get(); String storageKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding() .encodeToString((activeKey == null ? "root" : activeKey).getBytes(StandardCharsets.UTF_8)); - if (placeholders.containsKey(storageKey)) { - String stored = placeholders.get(storageKey); - ReplayState replayState = ACTIVE_REPLAY_STATE.get(); + ReplayState replayState = ACTIVE_REPLAY_STATE.get(); + String stored = replayMetadata(placeholders, replayState, storageKey); + if (stored != null) { if (replayState != null) replayState.recordReplayMetadata(storageKey, stored); return stored.isEmpty() ? null : new String(Base64.getUrlDecoder().decode(stored), StandardCharsets.UTF_8); } String selected = selector.get(); String encoded = selected == null ? "" : Base64.getUrlEncoder().withoutPadding() .encodeToString(selected.getBytes(StandardCharsets.UTF_8)); - placeholders.put(storageKey, encoded); - ReplayState replayState = ACTIVE_REPLAY_STATE.get(); - if (replayState != null) replayState.recordReplayMetadata(storageKey, encoded); + recordReplayMetadata(placeholders, replayState, storageKey, encoded); return selected; } @@ -624,8 +622,7 @@ static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, } commands = new ArrayList<>(expansions); String encodedSnapshot = encodeCommandSnapshot(commands); - placeholders.put(snapshotKey, encodedSnapshot); - replayState.recordReplayMetadata(snapshotKey, encodedSnapshot); + recordReplayMetadata(placeholders, replayState, snapshotKey, encodedSnapshot); } else { replayState.recordReplayMetadata(snapshotKey, storedSnapshot); try { @@ -657,8 +654,7 @@ static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, int completedCount = index + 1; int commandIndex = index; sequence = sequence.thenCompose(ignored -> dispatch.apply(command, commandIndex)).thenCompose(ignored -> { - placeholders.put(storageKey, String.valueOf(completedCount)); - replayState.recordReplayMetadata(storageKey, String.valueOf(completedCount)); + recordReplayMetadata(placeholders, replayState, storageKey, String.valueOf(completedCount)); return replayState == null ? CompletableFuture.completedFuture(null) : replayState.persistCheckpointAsync(plugin, placeholders); }); @@ -688,8 +684,7 @@ public static CompletionStage> replayNestedRewardSnapshot(AdvancedC } List snapshot = configuredRewards == null ? List.of() : new ArrayList<>(configuredRewards); String encodedSnapshot = encodeCommandSnapshot(snapshot); - placeholders.put(snapshotKey, encodedSnapshot); - replayState.recordReplayMetadata(snapshotKey, encodedSnapshot); + recordReplayMetadata(placeholders, replayState, snapshotKey, encodedSnapshot); return replayState.persistCheckpointAsync(plugin, placeholders).thenApply(ignored -> snapshot); } @@ -738,8 +733,7 @@ public static CompletionStage replayNestedRewardSequence(AdvancedCorePlugi sequence = sequence.thenCompose(ignored -> dispatch.apply(rewardName, rewardIndex)) .thenCompose(ignored -> { String progress = String.valueOf(completedCount); - placeholders.put(storageKey, progress); - replayState.recordReplayMetadata(storageKey, progress); + recordReplayMetadata(placeholders, replayState, storageKey, progress); return replayState.persistCheckpointAsync(plugin, placeholders); }); } @@ -776,10 +770,19 @@ public static boolean hasReplayNestedRewardSnapshot(HashMap plac } private static String replayMetadata(HashMap placeholders, ReplayState replayState, String key) { - String value = placeholders == null ? null : placeholders.get(key); + String value = placeholders == null || (replayState != null && !replayState.acceptsPersistedMetadata()) + ? null : placeholders.get(key); return value == null && replayState != null ? replayState.replayMetadata(key) : value; } + private static void recordReplayMetadata(HashMap placeholders, ReplayState replayState, + String key, String value) { + if (replayState != null) replayState.recordReplayMetadata(key, value); + if (placeholders != null && (replayState == null || replayState.hasCheckpointConsumer())) { + placeholders.put(key, value); + } + } + static CompletionStage replayCommandSequence(AdvancedCorePlugin plugin, HashMap placeholders, String lane, List commands, ReplayState replayState, String activeKey, @@ -927,6 +930,7 @@ public static final class ReplayState { private final HashMap registryFingerprints = new HashMap<>(); private final HashMap replayMetadata = new HashMap<>(); private final boolean legacyCheckpoint; + private final boolean restoredCheckpoint; private Consumer checkpointConsumer; private ReplayState(Map initial) { this(initial, null, false); } private ReplayState(Map initial, Map initialFingerprints, @@ -934,6 +938,8 @@ private ReplayState(Map initial, Map initialFin if (initial != null) completed.putAll(initial); if (initialFingerprints != null) registryFingerprints.putAll(initialFingerprints); this.legacyCheckpoint = legacyCheckpoint; + this.restoredCheckpoint = legacyCheckpoint || (initial != null && !initial.isEmpty()) + || (initialFingerprints != null && !initialFingerprints.isEmpty()); } private synchronized int getCompleted(String rewardName, int fallback) { return completed.getOrDefault(rewardName, fallback); @@ -986,6 +992,9 @@ private synchronized void setCheckpointConsumer(Consumer consu private synchronized boolean hasCheckpointConsumer() { return checkpointConsumer != null; } + private synchronized boolean acceptsPersistedMetadata() { + return restoredCheckpoint || checkpointConsumer != null; + } public CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, HashMap placeholders) { return persistCheckpointAsync(plugin, placeholders, 30, TimeUnit.SECONDS); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index 2135a2dda0..2dfcd3ab40 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -142,7 +142,9 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Configuratio // Child options stay isolated for normal placeholders. Replay // markers are shared lazily so an earlier child remains durable // when a later child fails and the list is restarted. - replayState.mergeReplayMetadataInto(options.getPlaceholders()); + if (Reward.isDurableReplay(options)) { + replayState.mergeReplayMetadataInto(options.getPlaceholders()); + } if (failure != null) throw new CompletionException(failure); return childResult; }); 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 3baee194a6..d17e5f7bec 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 @@ -2042,7 +2042,8 @@ void javascriptChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { assertNotNull(nestedOptions.getAsyncReplayState()); assertTrue(nestedOptions.getAsyncReplayKey().endsWith("/path:TrueRewards")); - assertFalse(nestedOptions.getPlaceholders().isEmpty()); + assertTrue(nestedOptions.getPlaceholders().isEmpty(), + "fresh replay metadata must stay in the shared state, not caller placeholders"); } @Test @@ -2113,7 +2114,8 @@ void specialChanceChildCarriesSelectionAndReplayStateIntoItsCheckpoint() { assertNotNull(nestedOptions.getAsyncReplayState()); assertTrue(nestedOptions.getAsyncReplayKey().endsWith("/path:1")); - assertFalse(nestedOptions.getPlaceholders().isEmpty()); + assertTrue(nestedOptions.getPlaceholders().isEmpty(), + "fresh replay metadata must stay in the shared state, not caller placeholders"); } @Test diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 7ba522ad60..85f1992ee0 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -199,7 +199,7 @@ public void asyncRewardSetupLeavesThePrimaryThreadAndRetainsCompletion() { } @Test - public void asyncListLazilyClonesAndCarriesOnlyReplayMetadata() { + public void asyncListLazilyClonesAndSharesReplayMetadataThroughState() { YamlConfiguration data = new YamlConfiguration(); data.set("Rewards", new ArrayList<>(List.of("First", "Second"))); Reward first = mock(Reward.class); @@ -222,8 +222,10 @@ public void asyncListLazilyClonesAndCarriesOnlyReplayMetadata() { ArgumentCaptor captured = ArgumentCaptor.forClass(RewardOptions.class); verify(second).giveRewardAsync(eq(user), captured.capture()); assertEquals("parent", captured.getValue().getPlaceholders().get("ordinary")); - assertEquals("v1:c2", captured.getValue().getPlaceholders() - .get("__advancedcore_replay_commands_test_snapshot")); + assertEquals("v1:c2", captured.getValue().getAsyncReplayState() + .replayMetadata("__advancedcore_replay_commands_test_snapshot")); + assertFalse(captured.getValue().getPlaceholders() + .containsKey("__advancedcore_replay_commands_test_snapshot")); } @Test @@ -264,6 +266,32 @@ public void asyncListReplayDoesNotRepeatCompletedDirectCommands() { verify(misc, never()).executeConsoleCommandsAsync(eq("Ben"), eq("/inserted"), any()); } + @Test + public void reusedFreshOptionsDoNotRetainCommandReplayCursor() throws Exception { + YamlConfiguration data = new YamlConfiguration(); + data.set("Rewards", new ArrayList<>(List.of("/once-per-send"))); + MiscUtils misc = mock(MiscUtils.class); + when(misc.executeConsoleCommandsAsync(eq("Ben"), eq("/once-per-send"), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + RewardOptions reused = new RewardOptions(); + for (String fieldName : List.of("ACTIVE_REPLAY_STATE", "ACTIVE_REPLAY_KEY", "ACTIVE_REPLAY_OCCURRENCE_ID")) { + java.lang.reflect.Field field = Reward.class.getDeclaredField(fieldName); + field.setAccessible(true); + ((ThreadLocal) field.get(null)).remove(); + } + + try (MockedStatic miscStatic = mockStatic(MiscUtils.class)) { + miscStatic.when(MiscUtils::getInstance).thenReturn(misc); + executor.giveRewardAsync(user, data, "Rewards", reused).toCompletableFuture().join(); + executor.giveRewardAsync(user, data, "Rewards", reused).toCompletableFuture().join(); + } + + verify(misc, org.mockito.Mockito.times(2)) + .executeConsoleCommandsAsync(eq("Ben"), eq("/once-per-send"), any()); + assertTrue(reused.getPlaceholders().keySet().stream() + .noneMatch(key -> key.startsWith("__advancedcore_replay_"))); + } + @Test public void snapshottedListFailsWhenANamedChildDisappears() { YamlConfiguration data = new YamlConfiguration(); From 8b337274f285a1ff72420344fca317747e07fffa Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:35:44 -0600 Subject: [PATCH 57/78] Gate ActionBar player requirement --- .../api/rewards/builtin/RewardActionBar.java | 3 ++- .../tests/rewards/BuiltinRewardBehaviorTest.java | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java index 31195d331d..763f34d2c6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java @@ -34,7 +34,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat section.getInt("Delay", 30)); return null; } - }.requiresPlayer().addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("ActionBar") { + }.requiresPlayerWhen((data, placeholders) -> !data.getString("ActionBar.Message", "").isEmpty()) + .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("ActionBar") { @Override public void openInventory(ClickEvent clickEvent) { RewardEditData reward = (RewardEditData) getInv().getData("Reward"); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 78ac311c02..5cc4e8737e 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -176,6 +176,18 @@ public void actionBarActuallySendsConfiguredMessage() { verify(user).sendActionBar("Hello Ben", 42); } + @Test + public void emptyActionBarDoesNotRequireAnOnlinePlayer() { + RewardActionBar.register(handler, plugin); + YamlConfiguration data = new YamlConfiguration(); + data.createSection("ActionBar"); + RewardInject inject = injects.get(0); + + assertFalse(inject.isPlayerRequiredFor(data, placeholders)); + data.set("ActionBar.Message", "Visible"); + assertTrue(inject.isPlayerRequiredFor(data, placeholders)); + } + @Test public void bossBarActuallySendsConfiguredBossBar() { RewardBossBar.register(handler, plugin); From e3037490b540970807be864ef31cbcd39e933150 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:47:04 -0600 Subject: [PATCH 58/78] Retain consumed choice replay tombstones --- .../advancedcore/api/rewards/Reward.java | 7 +- .../api/rewards/builtin/RewardChoices.java | 10 +++ .../api/user/AdvancedCoreUser.java | 74 ++++++++++++++++--- .../rewards/BuiltinRewardBehaviorTest.java | 19 +++++ .../tests/user/AdvancedCoreUserTest.java | 46 ++++++++++++ 5 files changed, 145 insertions(+), 11 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 bb070371d2..77258b192a 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 @@ -474,8 +474,11 @@ private static RewardReplayFailure findReplayFailure(Throwable failure) { * distinct replay keys, so both parts are required for deduplication. */ public static String currentReplaySideEffectOccurrenceId() { - String occurrenceId = currentReplayOccurrenceId(); - String replayKey = currentReplayKey(); + return replaySideEffectOccurrenceId(currentReplayOccurrenceId(), currentReplayKey()); + } + + /** Builds the stable side-effect identity for an explicitly captured replay path. */ + public static String replaySideEffectOccurrenceId(String occurrenceId, String replayKey) { if (occurrenceId == null || occurrenceId.isEmpty() || replayKey == null || replayKey.isEmpty()) { return occurrenceId; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java index 078434b7b9..003b162a91 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java @@ -86,6 +86,16 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU () -> builder.sendAsync(user))).thenApply(ignored -> choice); } + @Override + public CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, + String occurrenceId, String injectionKey) { + String choiceOccurrence = Reward.replaySideEffectOccurrenceId(occurrenceId, injectionKey); + return Reward.continueOnServerThread(plugin, user, () -> { + user.checkpointUnClaimedChoiceReward(choiceOccurrence); + return CompletableFuture.completedFuture(null); + }); + } + @Override public ArrayList subRewards(DefinedReward direct) { ArrayList subs = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 94d5cd6f68..a78a7d7a22 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 @@ -72,6 +72,8 @@ public class AdvancedCoreUser { private static final String ASYNC_RETRY_DELIMITER = "%asyncretry%"; private static final String ASYNC_OCCURRENCE_DELIMITER = "%asyncoccurrence%"; private static final String CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/1/"; + private static final String CHECKPOINTED_CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/2/"; + private static final String CONSUMED_CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/3/"; private static final Object REPLAY_CLAIMS_LOCK = new Object(); private static final WeakHashMap> REPLAY_CLAIMS = new WeakHashMap<>(); private static final ThreadLocal ASYNC_ACTION_COLLECTION = new ThreadLocal<>(); @@ -969,12 +971,35 @@ public synchronized void addUnClaimedChoiceReward(String name, String occurrence return; } ArrayList stored = getStoredUnClaimedChoices(); - String entry = encodeUnclaimedChoice(name, occurrenceId); - if (stored.contains(entry)) return; + if (stored.stream().anyMatch(entry -> occurrenceId.equals(decodeChoiceOccurrence(entry)))) return; + String entry = encodeUnclaimedChoice(CHOICE_OCCURRENCE_PREFIX, name, occurrenceId); stored.add(entry); getData().setStringList("UnClaimedChoices", stored); } + /** + * Marks a choice occurrence safe to remove after its owning reward checkpoint + * has been persisted. If it was claimed before that checkpoint, its tombstone + * is retired now; otherwise a later claim can remove it immediately. + * + * @param occurrenceId stable choice side-effect occurrence + */ + public synchronized void checkpointUnClaimedChoiceReward(String occurrenceId) { + if (occurrenceId == null || occurrenceId.isEmpty()) return; + ArrayList stored = getStoredUnClaimedChoices(); + for (int index = 0; index < stored.size(); index++) { + String entry = stored.get(index); + if (!occurrenceId.equals(decodeChoiceOccurrence(entry))) continue; + if (entry.startsWith(CONSUMED_CHOICE_OCCURRENCE_PREFIX)) stored.remove(index); + else if (entry.startsWith(CHOICE_OCCURRENCE_PREFIX)) { + stored.set(index, CHECKPOINTED_CHOICE_OCCURRENCE_PREFIX + + entry.substring(CHOICE_OCCURRENCE_PREFIX.length())); + } else return; + getData().setStringList("UnClaimedChoices", stored); + return; + } + } + /** * Caches the user data. */ @@ -1621,7 +1646,9 @@ public HashMap getTimedRewards() { */ public synchronized ArrayList getUnClaimedChoices() { ArrayList choices = new ArrayList<>(); - for (String stored : getStoredUnClaimedChoices()) choices.add(decodeUnclaimedChoice(stored)); + for (String stored : getStoredUnClaimedChoices()) { + if (!stored.startsWith(CONSUMED_CHOICE_OCCURRENCE_PREFIX)) choices.add(decodeUnclaimedChoice(stored)); + } return choices; } @@ -2475,8 +2502,13 @@ public void removePermission(String permission) { public synchronized void removeUnClaimedChoiceReward(String name) { ArrayList choices = getStoredUnClaimedChoices(); for (int index = 0; index < choices.size(); index++) { - if (java.util.Objects.equals(name, decodeUnclaimedChoice(choices.get(index)))) { - choices.remove(index); + String entry = choices.get(index); + if (entry.startsWith(CONSUMED_CHOICE_OCCURRENCE_PREFIX)) continue; + if (java.util.Objects.equals(name, decodeUnclaimedChoice(entry))) { + if (entry.startsWith(CHOICE_OCCURRENCE_PREFIX)) { + choices.set(index, CONSUMED_CHOICE_OCCURRENCE_PREFIX + + entry.substring(CHOICE_OCCURRENCE_PREFIX.length())); + } else choices.remove(index); break; } } @@ -2861,16 +2893,16 @@ private ArrayList getStoredUnClaimedChoices() { return getData().getStringList("UnClaimedChoices", userDataFetchMode); } - private static String encodeUnclaimedChoice(String name, String occurrenceId) { + private static String encodeUnclaimedChoice(String prefix, String name, String occurrenceId) { Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); - return CHOICE_OCCURRENCE_PREFIX + return prefix + encoder.encodeToString(occurrenceId.getBytes(StandardCharsets.UTF_8)) + "." + encoder.encodeToString((name == null ? "" : name).getBytes(StandardCharsets.UTF_8)); } private static String decodeUnclaimedChoice(String stored) { - if (stored == null || !stored.startsWith(CHOICE_OCCURRENCE_PREFIX)) return stored; - String encoded = stored.substring(CHOICE_OCCURRENCE_PREFIX.length()); + String encoded = encodedChoice(stored); + if (encoded == null) return stored; int separator = encoded.indexOf('.'); if (separator < 1) return stored; try { @@ -2881,6 +2913,30 @@ private static String decodeUnclaimedChoice(String stored) { } } + private static String decodeChoiceOccurrence(String stored) { + String encoded = encodedChoice(stored); + if (encoded == null) return null; + int separator = encoded.indexOf('.'); + if (separator < 1) return null; + try { + return new String(Base64.getUrlDecoder().decode(encoded.substring(0, separator)), StandardCharsets.UTF_8); + } catch (IllegalArgumentException failure) { + return null; + } + } + + private static String encodedChoice(String stored) { + if (stored == null) return null; + if (stored.startsWith(CHOICE_OCCURRENCE_PREFIX)) return stored.substring(CHOICE_OCCURRENCE_PREFIX.length()); + if (stored.startsWith(CHECKPOINTED_CHOICE_OCCURRENCE_PREFIX)) { + return stored.substring(CHECKPOINTED_CHOICE_OCCURRENCE_PREFIX.length()); + } + if (stored.startsWith(CONSUMED_CHOICE_OCCURRENCE_PREFIX)) { + return stored.substring(CONSUMED_CHOICE_OCCURRENCE_PREFIX.length()); + } + return null; + } + /** * Sets the user to not cache data. */ diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 5cc4e8737e..8a9d6af1d2 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -718,6 +718,25 @@ public void choicesAsyncDispatchWaitsForSelectedReward() { } } + @Test + public void choicesRetireConsumedOccurrenceAfterOwningCheckpointPersists() { + RewardChoices.register(handler, plugin); + RewardInjectBoolean choices = (RewardInjectBoolean) injects.get(0); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + + choices.onReplayCheckpointPersisted(reward, user, "occurrence-one", "SourceReward/0") + .toCompletableFuture().join(); + + verify(user).checkpointUnClaimedChoiceReward( + Reward.replaySideEffectOccurrenceId("occurrence-one", "SourceReward/0")); + } + @Test public void javascriptActuallyExecutesScriptsAndTrueBranch() { RewardJavascript.register(handler, plugin); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 48e0ca0e02..52eb27d3c1 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -114,6 +114,8 @@ void claimingOneOccurrencePreservesAnotherIdenticalChoiceReward() { }).when(data).setStringList(eq("UnClaimedChoices"), any()); user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-two"); + user.checkpointUnClaimedChoiceReward("occurrence-one"); + user.checkpointUnClaimedChoiceReward("occurrence-two"); user.removeUnClaimedChoiceReward("ChoiceReward"); @@ -121,6 +123,50 @@ void claimingOneOccurrencePreservesAnotherIdenticalChoiceReward() { assertEquals(1, stored.get().size()); } + @Test + void claimedChoiceRemainsDeduplicatedUntilOwningCheckpointPersists() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-two"); + user.removeUnClaimedChoiceReward("ChoiceReward"); + assertEquals(List.of("ChoiceReward"), user.getUnClaimedChoices()); + user.removeUnClaimedChoiceReward("ChoiceReward"); + assertTrue(user.getUnClaimedChoices().isEmpty()); + + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + assertTrue(user.getUnClaimedChoices().isEmpty()); + assertEquals(2, stored.get().size()); + + user.checkpointUnClaimedChoiceReward("occurrence-one"); + user.checkpointUnClaimedChoiceReward("occurrence-two"); + assertTrue(stored.get().isEmpty()); + } + + @Test + void choiceClaimAfterOwningCheckpointRemovesOccurrenceImmediately() { + AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); + when(data.getStringList("UnClaimedChoices", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(stored.get())); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(new ArrayList<>(invocation.getArgument(1))); + return null; + }).when(data).setStringList(eq("UnClaimedChoices"), any()); + + user.addUnClaimedChoiceReward("ChoiceReward", "occurrence-one"); + user.checkpointUnClaimedChoiceReward("occurrence-one"); + assertEquals(List.of("ChoiceReward"), user.getUnClaimedChoices()); + + user.removeUnClaimedChoiceReward("ChoiceReward"); + assertTrue(stored.get().isEmpty()); + } + @Test void legacyChoiceAdditionPreservesModernOccurrenceIdentity() { AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); From a6eede5b26a81e9516f40cb9d1dcb6f4c2ac6714 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:59:33 -0600 Subject: [PATCH 59/78] Checkpoint selected child completion --- .../advancedcore/api/rewards/Reward.java | 41 ++++++++++++++++++ .../builtin/RewardAdvancedPriority.java | 26 +++++++----- .../builtin/RewardAdvancedRandomReward.java | 10 +++-- .../api/rewards/builtin/RewardChoices.java | 19 +++++---- .../api/rewards/builtin/RewardJavascript.java | 17 +++++--- .../api/rewards/builtin/RewardPriority.java | 25 ++++++----- .../api/rewards/builtin/RewardRandom.java | 27 +++++++----- .../rewards/builtin/RewardRandomReward.java | 12 ++++-- .../rewards/builtin/RewardSpecialChance.java | 33 ++++++++------- .../rewards/RewardAsyncInjectionTest.java | 42 +++++++++++++++++++ 10 files changed, 186 insertions(+), 66 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 77258b192a..307db81d29 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 @@ -55,6 +55,7 @@ public class Reward { private static final String REPLAY_SELECTION_PREFIX = "__advancedcore_replay_selection_"; private static final String REPLAY_COMMAND_PREFIX = "__advancedcore_replay_commands_"; private static final String REPLAY_NESTED_LIST_PREFIX = "__advancedcore_replay_nested_list_"; + private static final String REPLAY_SINGLE_CHILD_PREFIX = "__advancedcore_replay_single_child_"; private static final String REPLAY_LEGACY_ACTION_PREFIX = "__advancedcore_replay_legacy_actions_"; @Getter @@ -744,6 +745,46 @@ public static CompletionStage replayNestedRewardSequence(AdvancedCorePlugi }); } + /** + * Dispatches one nested child and persists its completion before the parent + * injector can complete. A retry consults this marker before resolving the + * child, so a completed child may safely disappear from configuration. + */ + public static CompletionStage replaySingleNestedReward(AdvancedCorePlugin plugin, + HashMap placeholders, String lane, ReplayState replayState, String activeKey, + Supplier> dispatch) { + if (replayState == null || activeKey == null) { + try { + CompletionStage result = dispatch.get(); + return result == null ? CompletableFuture.failedFuture( + new IllegalStateException("Nested reward dispatch returned no completion stage")) : result; + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + String storageKey = replaySequenceKey(REPLAY_SINGLE_CHILD_PREFIX, activeKey, + lane == null ? "selected" : lane); + String stored = replayMetadata(placeholders, replayState, storageKey); + if (stored != null) { + if (!"1".equals(stored)) return CompletableFuture.failedFuture( + new IllegalStateException("Malformed nested reward completion marker")); + replayState.recordReplayMetadata(storageKey, stored); + return CompletableFuture.completedFuture(null); + } + CompletionStage result; + try { + result = dispatch.get(); + if (result == null) return CompletableFuture.failedFuture( + new IllegalStateException("Nested reward dispatch returned no completion stage")); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + return result.thenCompose(ignored -> { + recordReplayMetadata(placeholders, replayState, storageKey, "1"); + return replayState.persistCheckpointAsync(plugin, placeholders); + }); + } + private static String replaySequenceKey(String prefix, String activeKey, String lane) { return prefix + digest(activeKey.length() + ":" + activeKey + lane.length() + ":" + lane); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java index c79b898b2c..09560aaea0 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java @@ -78,18 +78,22 @@ public CompletionStage onRewardRequestedAsync(Reward sourceReward, Advan return null; }); if (selectedKey == null) return CompletableFuture.completedFuture(null); - Reward selected = handler.getReward(section, selectedKey, new RewardOptions() - .setPrefix(sourceReward.getName() + "_AdvancedPriority")); - RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setIgnoreChance(true) - .setIgnoreRequirements(true).setPrefix(sourceReward.getName() + "_AdvancedPriority") - .withPlaceHolder(placeholders), Reward.currentReplayState(), Reward.currentReplayKey(), - "selected:" + selectedKey, Reward.currentReplayOccurrenceId()); - if (selected == null) return CompletableFuture.failedFuture( - new IllegalStateException("Selected advanced priority reward could not be resolved: " + selectedKey)); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> handler.giveRewardAsync(user, selected, childOptions))) - .thenApply(ignored -> selected.getName()); + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + Reward selected = handler.getReward(section, selectedKey, new RewardOptions() + .setPrefix(sourceReward.getName() + "_AdvancedPriority")); + if (selected == null) return CompletableFuture.failedFuture(new IllegalStateException( + "Selected advanced priority reward could not be resolved: " + selectedKey)); + RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setIgnoreChance(true) + .setIgnoreRequirements(true).setPrefix(sourceReward.getName() + "_AdvancedPriority") + .withPlaceHolder(placeholders), replayState, replayKey, + "selected:" + selectedKey, occurrenceId); + return handler.giveRewardAsync(user, selected, childOptions); + }))).thenApply(ignored -> selectedKey); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java index a8ec174c70..3059ac1ac3 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java @@ -70,13 +70,17 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor } String selected = Reward.replaySelection(placeholders, () -> rewards.get(ThreadLocalRandom.current().nextInt(rewards.size()))); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders), - Reward.currentReplayState(), Reward.currentReplayKey(), "selected:" + selected, + replayState, replayKey, "selected:" + selected, Reward.currentReplayOccurrenceId()) .setPrefix(reward.getRewardName() + "_AdvancedRandomReward"); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> handler.giveRewardAsync(user, section, selected, childOptions))) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, + () -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, section, selected, childOptions)))) .thenApply(ignored -> selected); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java index 003b162a91..9fcef0a893 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardChoices.java @@ -76,14 +76,19 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU return CompletableFuture.completedFuture(null); })); } - RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), - reward.getConfig().getChoicesRewardsPath(choice)).withPrefix(reward.getName()) - .withPlaceHolder(placeholders).withPlaceHolder("choice", choice); - Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), - Reward.currentReplayKey(), "choice:" + choice, Reward.currentReplayOccurrenceId()); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String replayOccurrence = Reward.currentReplayOccurrenceId(); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> builder.sendAsync(user))).thenApply(ignored -> choice); + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), + reward.getConfig().getChoicesRewardsPath(choice)).withPrefix(reward.getName()) + .withPlaceHolder(placeholders).withPlaceHolder("choice", choice); + Reward.withReplayState(builder.getRewardOptions(), replayState, replayKey, + "choice:" + choice, replayOccurrence); + return builder.sendAsync(user); + }))).thenApply(ignored -> choice); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java index 2caa5a1d50..04e908f58d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java @@ -94,13 +94,18 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor String path = Reward.replaySelection(placeholders, () -> new JavascriptEngine().addPlayer(user.getOfflinePlayer()).addPlaceholders(placeholders) .getBooleanValue(section.getString("Expression")) ? "TrueRewards" : "FalseRewards"); - RewardBuilder builder = new RewardBuilder(section, path).withPrefix(reward.getName() + ".Javascript") - .withPlaceHolder(placeholders); - Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), - Reward.currentReplayKey(), "path:" + path, Reward.currentReplayOccurrenceId()); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> builder.sendAsync(user))).thenApply(ignored -> null); + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + RewardBuilder builder = new RewardBuilder(section, path) + .withPrefix(reward.getName() + ".Javascript").withPlaceHolder(placeholders); + Reward.withReplayState(builder.getRewardOptions(), replayState, + replayKey, "path:" + path, occurrenceId); + return builder.sendAsync(user); + }))).thenApply(ignored -> null); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java index 6d9c2ab2ab..fe33f6f7b8 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardPriority.java @@ -69,18 +69,21 @@ public CompletionStage onRewardRequestAsync(Reward source, AdvancedCoreU return null; }); if (selectedName == null) return CompletableFuture.completedFuture(null); - Reward selected = handler.getReward(selectedName); - RewardOptions childOptions = Reward.withReplayState( - new RewardOptions().withPlaceHolder(placeholders).setIgnoreChance(true) - .setIgnoreRequirements(true), - Reward.currentReplayState(), Reward.currentReplayKey(), "selected:" + selectedName, - Reward.currentReplayOccurrenceId()); - if (selected == null) return CompletableFuture.failedFuture( - new IllegalStateException("Selected priority reward could not be resolved: " + selectedName)); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> handler.giveRewardAsync(user, selected, childOptions))) - .thenApply(ignored -> selected.getName()); + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + Reward selected = handler.getReward(selectedName); + if (selected == null) return CompletableFuture.failedFuture(new IllegalStateException( + "Selected priority reward could not be resolved: " + selectedName)); + RewardOptions childOptions = Reward.withReplayState( + new RewardOptions().withPlaceHolder(placeholders).setIgnoreChance(true) + .setIgnoreRequirements(true), replayState, replayKey, + "selected:" + selectedName, occurrenceId); + return handler.giveRewardAsync(user, selected, childOptions); + }))).thenApply(ignored -> selectedName); } }.asPlaceholder("Priority").addEditButton( new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("Priority", null) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java index 662c3d63c8..73be4f7744 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandom.java @@ -76,25 +76,32 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor : "pick:" + rewards.get(ThreadLocalRandom.current().nextInt(rewards.size())); }); if (selection == null || selection.equals("none")) return CompletableFuture.completedFuture(null); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); if (selection.startsWith("pick:")) { String selected = selection.substring("pick:".length()); RewardOptions childOptions = Reward.withReplayState( - new RewardOptions().setPlaceholders(placeholders), Reward.currentReplayState(), - Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()); + new RewardOptions().setPlaceholders(placeholders), replayState, + replayKey, "selected:" + selected, occurrenceId); return selected.isEmpty() ? CompletableFuture.completedFuture(null) : Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> handler.giveRewardAsync(user, selected, childOptions))) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, + () -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, selected, childOptions)))) .thenApply(ignored -> null); } String path = selection.equals("rewards") ? "Random.Rewards" : "Random.FallBack"; - RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) - .withPrefix(reward.getName()).withPlaceHolder(placeholders); - Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), - Reward.currentReplayKey(), "path:" + path, Reward.currentReplayOccurrenceId()); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> builder.sendAsync(user))).thenApply(ignored -> null); + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + RewardBuilder builder = new RewardBuilder(reward.getConfig().getConfigData(), path) + .withPrefix(reward.getName()).withPlaceHolder(placeholders); + Reward.withReplayState(builder.getRewardOptions(), replayState, + replayKey, "path:" + path, occurrenceId); + return builder.sendAsync(user); + }))).thenApply(ignored -> null); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java index b7c39f6afe..4b866b0467 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardRandomReward.java @@ -59,12 +59,16 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU } String selected = Reward.replaySelection(placeholders, () -> list.get(ThreadLocalRandom.current().nextInt(list.size()))); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); RewardOptions childOptions = Reward.withReplayState( - new RewardOptions().setPlaceholders(placeholders), Reward.currentReplayState(), - Reward.currentReplayKey(), "selected:" + selected, Reward.currentReplayOccurrenceId()); + new RewardOptions().setPlaceholders(placeholders), replayState, + replayKey, "selected:" + selected, Reward.currentReplayOccurrenceId()); return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> handler.giveRewardAsync(user, selected, childOptions))) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, + () -> Reward.continueOnServerThread(plugin, user, + () -> handler.giveRewardAsync(user, selected, childOptions)))) .thenApply(ignored -> selected); } }.asPlaceholder("RandomReward").priority(20).addEditButton( diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java index 428ff8d931..9f8c2c03cf 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSpecialChance.java @@ -119,20 +119,25 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor if (selection == null) return CompletableFuture.completedFuture(null); String[] selected = selection.split("\n", 2); if (selected.length != 2) return CompletableFuture.completedFuture(null); - for (Entry entry : map.entrySet()) { - if (entry.getValue().equals(selected[0])) { - RewardBuilder builder = new RewardBuilder(section, entry.getValue()) - .withPrefix(reward.getName() + "_SpecialChance").withPlaceHolder(placeholders) - .withPlaceHolder("chance", selected[1]); - Reward.withReplayState(builder.getRewardOptions(), Reward.currentReplayState(), - Reward.currentReplayKey(), "path:" + entry.getValue(), Reward.currentReplayOccurrenceId()); - return Reward.persistReplayMetadataAsync(plugin, placeholders) - .thenCompose(ignored -> Reward.continueOnServerThread(plugin, user, - () -> builder.sendAsync(user))).thenApply(ignored -> null); - } - } - return CompletableFuture.failedFuture( - new IllegalStateException("Selected special-chance reward could not be resolved: " + selected[0])); + Reward.ReplayState replayState = Reward.currentReplayState(); + String replayKey = Reward.currentReplayKey(); + String occurrenceId = Reward.currentReplayOccurrenceId(); + return Reward.persistReplayMetadataAsync(plugin, placeholders) + .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders, + "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> { + for (Entry entry : map.entrySet()) { + if (entry.getValue().equals(selected[0])) { + RewardBuilder builder = new RewardBuilder(section, entry.getValue()) + .withPrefix(reward.getName() + "_SpecialChance").withPlaceHolder(placeholders) + .withPlaceHolder("chance", selected[1]); + Reward.withReplayState(builder.getRewardOptions(), replayState, replayKey, + "path:" + entry.getValue(), occurrenceId); + return builder.sendAsync(user); + } + } + return CompletableFuture.failedFuture(new IllegalStateException( + "Selected special-chance reward could not be resolved: " + selected[0])); + }))).thenApply(ignored -> null); } @Override 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 d17e5f7bec..c5899d7f03 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 @@ -1885,6 +1885,48 @@ void completedNestedChildIsSkippedBeforeRegistryResolutionOnRetry() throws Excep assertEquals(List.of("remaining"), retried); } + @Test + void completedSingleNestedRewardIsNotResolvedAgainAfterParentCheckpointFailure() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + Map.of("AsyncReward", 0), Map.of("AsyncReward", "registry"), false); + List checkpoints = new ArrayList<>(); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) checkpoints::add); + AtomicInteger dispatches = new AtomicInteger(); + HashMap placeholders = new HashMap<>(); + + CompletionStage first = Reward.replaySingleNestedReward(plugin, placeholders, "selected", + replayState, "AsyncReward/0", () -> { + dispatches.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }); + ArgumentCaptor writes = ArgumentCaptor.forClass(Runnable.class); + verify(storageExecutor).execute(writes.capture()); + assertFalse(first.toCompletableFuture().isDone()); + writes.getValue().run(); + first.toCompletableFuture().join(); + assertEquals(1, checkpoints.size()); + + Reward.ReplayState restored = (Reward.ReplayState) constructor.newInstance( + checkpoints.get(0).getReplayProgress(), checkpoints.get(0).getReplayRegistryFingerprints(), false); + Reward.replaySingleNestedReward(plugin, checkpoints.get(0).getPlaceholders(), "selected", restored, + "AsyncReward/0", () -> { + dispatches.incrementAndGet(); + return CompletableFuture.failedFuture(new IllegalStateException("selected reward was removed")); + }).toCompletableFuture().join(); + + assertEquals(1, dispatches.get(), "completed child must be skipped before it is resolved again"); + } + @Test void everyNestedRewardInjectorWaitsForItsSelectedChild() { handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); From 4d62f1b45f463f1af636f9a590591507ac762496 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:09:07 -0600 Subject: [PATCH 60/78] Skip completed missing reward sections --- .../advancedcore/api/rewards/Reward.java | 13 ++++++ .../RewardInjectConfigurationSection.java | 8 ++-- .../rewards/RewardAsyncInjectionTest.java | 43 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 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 307db81d29..5c7c782157 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 @@ -785,6 +785,19 @@ public static CompletionStage replaySingleNestedReward(AdvancedCorePlugin }); } + /** Returns whether the active single nested child is already durably complete. */ + public static boolean hasCompletedSingleNestedReward(HashMap placeholders, String lane) { + ReplayState replayState = currentReplayState(); + String activeKey = currentReplayKey(); + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_SINGLE_CHILD_PREFIX, activeKey, + lane == null ? "selected" : lane); + String stored = replayMetadata(placeholders, replayState, storageKey); + if (!"1".equals(stored)) return false; + replayState.recordReplayMetadata(storageKey, stored); + return true; + } + private static String replaySequenceKey(String prefix, String activeKey, String lane) { return prefix + digest(activeKey.length() + ":" + activeKey + lane.length() + ":" + lane); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java index 21dd617979..9b3f87a4fd 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInjectConfigurationSection.java @@ -52,9 +52,11 @@ public CompletionStage onRewardRequestAsync(Reward reward, AdvancedCoreU ConfigurationSection data, HashMap placeholders) { if (!data.isConfigurationSection(getPath()) && !(isAlwaysForce() && data.contains(getPath(), true)) && !isAlwaysForceNoData()) { - return hasPendingReplayWork(placeholders) - ? onMissingConfiguredDataAsync(reward, user, placeholders) - : CompletableFuture.completedFuture(null); + if (!hasPendingReplayWork(placeholders) + || Reward.hasCompletedSingleNestedReward(placeholders, "selected")) { + return CompletableFuture.completedFuture(null); + } + return onMissingConfiguredDataAsync(reward, user, placeholders); } return onRewardRequestedAsync(reward, user, data.getConfigurationSection(getPath()), placeholders) .thenApply(result -> result); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index c5899d7f03..b8f48b0ca0 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 @@ -1927,6 +1927,49 @@ void completedSingleNestedRewardIsNotResolvedAgainAfterParentCheckpointFailure() assertEquals(1, dispatches.get(), "completed child must be skipped before it is resolved again"); } + @Test + void missingConfigurationSectionIsAllowedAfterItsSingleChildCompleted() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + HashMap placeholders = new HashMap<>(); + Reward.replaySingleNestedReward(plugin, placeholders, "selected", replayState, "AsyncReward/0", + () -> CompletableFuture.completedFuture(null)).toCompletableFuture().join(); + + handler.getInjectedRewards().add(new com.bencodez.advancedcore.api.rewards.injected.RewardInjectConfigurationSection( + "RemovedSection") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override public boolean hasPendingReplayWork(HashMap ignored) { return true; } + @Override public String onRewardRequested(Reward ignoredReward, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredSection, HashMap ignoredPlaceholders) { + throw new AssertionError("completed missing section must not be resolved"); + } + }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, placeholders, 0, + replayState, "AsyncReward", "occurrence"); + + resumed.toCompletableFuture().join(); + } + @Test void everyNestedRewardInjectorWaitsForItsSelectedChild() { handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); From cc4f71d54e09a9c5075f3a346dc167acb4976675 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:17:23 -0600 Subject: [PATCH 61/78] Freeze inventory checks before shutdown save --- .../api/item/FullInventoryHandler.java | 14 +++++++---- .../tests/item/FullInventoryHandlerTest.java | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) 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 0d9b3b796b..86806b7fa1 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 @@ -95,6 +95,7 @@ public void add(UUID uuid, ItemStack item) { } public void check() { + if (shuttingDown.get()) return; if (!Bukkit.isPrimaryThread()) { plugin.getBukkitScheduler().runTask(plugin, this::schedulePendingPlayerChecks); return; @@ -103,7 +104,7 @@ public void check() { } public void check(Player player) { - if (player == null) { + if (player == null || shuttingDown.get()) { return; } plugin.getBukkitScheduler().runTask(plugin, () -> checkOwnedPlayer(player), player); @@ -234,14 +235,14 @@ public synchronized void loadTimer() { public synchronized void shutdown() { shuttingDown.set(true); - // Flush accepted replay reservations while their completion stages can still - // advance the outer reward checkpoint. Stopping the executor first can discard - // an accepted persistence task and later replay the already-inserted items. - saveDurably(); if (checkTask != null) { checkTask.cancel(false); checkTask = null; } + // Flush accepted replay reservations while their completion stages can still + // advance the outer reward checkpoint. Stopping the executor first can discard + // an accepted persistence task and later replay the already-inserted items. + saveDurably(); if (timer != null) { timer.shutdownNow(); } @@ -516,6 +517,7 @@ private void addItems(UUID uuid, Collection itemsToAdd) { private void checkOwnedPlayer(Player player) { deliveryLock.readLock().lock(); try { + if (shuttingDown.get()) return; UUID uuid = player.getUniqueId(); ArrayList pending = items.remove(uuid); if (pending == null || pending.isEmpty()) { @@ -588,8 +590,10 @@ private boolean giveItemOwnedPlayer(Player player, ItemStack[] item, String rese } private void schedulePendingPlayerChecks() { + if (shuttingDown.get()) return; long now = System.currentTimeMillis(); for (UUID uuid : new ArrayList<>(items.keySet())) { + if (shuttingDown.get()) return; Player player = Bukkit.getPlayer(uuid); if (player != null) { plugin.getBukkitScheduler().runTask(plugin, () -> checkOwnedPlayer(player), player); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java index 45a0ea00b0..6d30af4bc4 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 @@ -409,6 +409,29 @@ public void queuedReplayDeliveryDoesNotStartAfterShutdownSnapshot() throws Excep verify(inventory, never()).addItem(item); } + @Test + public void queuedPendingCheckCannotMutateItemsAfterShutdownSnapshot() { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.getInventory()).thenReturn(inventory); + fixture.handler.add(uuid, item); + + fixture.handler.check(player); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + fixture.handler.shutdown(); + assertEquals(item, fixture.data.getItemStack("FullInventory." + uuid + ".Items.0")); + + task.getValue().run(); + + verify(inventory, never()).addItem(any(ItemStack[].class)); + assertEquals(List.of(item), fixture.handler.getItems().get(uuid)); + } + @Test public void giveItemAsyncFailsWithoutMutationWhenPlayerDisconnectsAfterEnqueue() throws Exception { Fixture fixture = createFixture(); From d1c6421706cadebf4114a4a670d4d81ed31f7516 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:27:10 -0600 Subject: [PATCH 62/78] Await legacy actions in async rewards --- .../advancedcore/api/rewards/Reward.java | 10 +------ .../rewards/RewardAsyncInjectionTest.java | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 9 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 5c7c782157..a68fb0d423 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 @@ -1480,15 +1480,7 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption if (canGive || isForceOffline() || rewardOptions.isForceOffline()) { plugin.debug(name + ": Passed requirements, attempting to give to " + user.getPlayerName() + "/" + user.getUUID()); - if (requiresAwaitedDelivery(rewardOptions)) { - return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions); - } - try { - giveRewardUser(user, rewardOptions.getPlaceholders(), rewardOptions); - return CompletableFuture.completedFuture(null); - } catch (Throwable failure) { - return CompletableFuture.failedFuture(failure); - } + return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions); } return CompletableFuture.completedFuture(null); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index b8f48b0ca0..50a2e1ae5d 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 @@ -560,6 +560,33 @@ void inheritedDurableChildDefersToItsParentQueue() { verify(user, never()).addOfflineRewards(any(), any(), any()); } + @Test + void freshAsyncDispatchAlwaysUsesCompletionAwareRewardUserPath() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.isOnline()).thenReturn(true); + when(user.getPlayer()).thenReturn(mock(Player.class)); + Reward spyReward = org.mockito.Mockito.spy(reward); + CompletableFuture legacyActions = new CompletableFuture<>(); + doReturn(legacyActions).when(spyReward).giveRewardUserAsync(eq(user), any(HashMap.class), + any(RewardOptions.class)); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + CompletionStage result; + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + result = spyReward.giveRewardAsync(user, + new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true)); + } + + assertFalse(result.toCompletableFuture().isDone()); + legacyActions.complete(null); + result.toCompletableFuture().join(); + verify(spyReward).giveRewardUserAsync(eq(user), any(HashMap.class), any(RewardOptions.class)); + } + @Test void legacySchedulerRejectionFailsAsyncRewardClosed() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From 799769999d5b347425c2356e9ff42c0e7e7eacf0 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:38:31 -0600 Subject: [PATCH 63/78] Preserve durable replay edge cases --- .../advancedcore/api/rewards/Reward.java | 34 +++++- .../api/rewards/builtin/RewardCommands.java | 14 ++- .../rewards/RewardAsyncInjectionTest.java | 111 ++++++++++++++++++ 3 files changed, 152 insertions(+), 7 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 a68fb0d423..38448d660a 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 @@ -812,6 +812,34 @@ public static boolean hasReplayCommandSnapshot(HashMap placehold return replayMetadata(placeholders, replayState, storageKey + "_snapshot") != null; } + /** Returns whether a frozen command lane still has an undispatched command. */ + public static boolean hasPendingReplayCommandWork(HashMap placeholders, String lane) { + ReplayState replayState = currentReplayState(); + String activeKey = currentReplayKey(); + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_COMMAND_PREFIX, activeKey, + lane == null ? "commands" : lane); + String storedSnapshot = replayMetadata(placeholders, replayState, storageKey + "_snapshot"); + if (storedSnapshot == null) return false; + List commands; + try { + commands = decodeCommandSnapshot(storedSnapshot); + } catch (IllegalArgumentException failure) { + throw new IllegalStateException("Malformed command replay snapshot", failure); + } + String storedProgress = replayMetadata(placeholders, replayState, storageKey); + int completed; + try { + completed = storedProgress == null ? 0 : Integer.parseInt(storedProgress); + } catch (NumberFormatException failure) { + throw new IllegalStateException("Malformed command replay progress", failure); + } + if (completed < 0 || completed > commands.size()) { + throw new IllegalStateException("Command replay progress exceeds snapshot"); + } + return completed < commands.size(); + } + /** Returns whether the active replay already froze this nested reward lane. */ public static boolean hasReplayNestedRewardSnapshot(HashMap placeholders, String lane) { return hasReplayNestedRewardSnapshot(placeholders, lane, currentReplayState(), currentReplayKey()); @@ -910,7 +938,7 @@ public static void mergeReplayMetadata(HashMap target, Map giveRewardAsync(AdvancedCoreUser user, RewardOption } 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; } } 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 322272d208..46882a7ece 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java @@ -246,13 +246,15 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat @Override public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { - ArrayList consoleCommands = (ArrayList) section.getList("Console", new ArrayList<>()); - ArrayList userCommands = (ArrayList) section.getList("Player", new ArrayList<>()); + ArrayList consoleCommands = (ArrayList) section.getList("Console", new ArrayList<>()); + ArrayList userCommands = (ArrayList) section.getList("Player", new ArrayList<>()); boolean stagger = section.getBoolean("Stagger", true); - CompletionStage availability = userCommands.isEmpty() - && !Reward.hasReplayCommandSnapshot(placeholders, "player") - ? CompletableFuture.completedFuture(null) - : user.validatePlayerCommandAvailabilityAsync(); + boolean playerCommandsPending = Reward.hasReplayCommandSnapshot(placeholders, "player") + ? Reward.hasPendingReplayCommandWork(placeholders, "player") + : !userCommands.isEmpty(); + CompletionStage availability = playerCommandsPending + ? user.validatePlayerCommandAvailabilityAsync() + : CompletableFuture.completedFuture(null); // Validate the player before any mixed-section side effect, then preserve // the established console-before-player command order. return availability.thenCompose(ignored -> consoleCommands.isEmpty() 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 50a2e1ae5d..38b669bb2d 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 @@ -73,9 +73,11 @@ import com.bencodez.advancedcore.api.rewards.builtin.RewardRandomReward; import com.bencodez.advancedcore.api.rewards.builtin.RewardJavascript; import com.bencodez.advancedcore.api.rewards.builtin.RewardChoices; +import com.bencodez.advancedcore.api.rewards.builtin.RewardCommands; import com.bencodez.advancedcore.api.rewards.builtin.RewardSpecialChance; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject; import com.bencodez.advancedcore.thread.FileThread; import net.milkbowl.vault.economy.Economy; @@ -587,6 +589,37 @@ void freshAsyncDispatchAlwaysUsesCompletionAwareRewardUserPath() { verify(spyReward).giveRewardUserAsync(eq(user), any(HashMap.class), any(RewardOptions.class)); } + @Test + void durableReplayRetainsOccurrenceWhenRequirementEvaluationThrows() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.isOnline()).thenReturn(true); + when(user.getPlayer()).thenReturn(mock(Player.class)); + AtomicBoolean injected = new AtomicBoolean(); + handler.getInjectedRequirements().add(new RequirementInject("TransientRequirement") { + @Override + public boolean onRequirementRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, RewardOptions ignoredOptions) { + throw new IllegalStateException("requirement service unavailable"); + } + }); + handler.getInjectedRewards().add( + asyncInjection("MustNotRun", CompletableFuture.completedFuture(null), injected)); + RewardOptions options = new RewardOptions().setCheckTimed(false); + options.setAsyncReplayCheckpointConsumer(ignored -> { }); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + assertThrows(CompletionException.class, + () -> reward.giveRewardAsync(user, options).toCompletableFuture().join()); + } + + assertFalse(injected.get()); + } + @Test void legacySchedulerRejectionFailsAsyncRewardClosed() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); @@ -1954,6 +1987,84 @@ void completedSingleNestedRewardIsNotResolvedAgainAfterParentCheckpointFailure() assertEquals(1, dispatches.get(), "completed child must be skipped before it is resolved again"); } + @Test + void copiedNestedPlaceholdersMergeSingleChildCompletionIntoParentState() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + HashMap parentPlaceholders = new HashMap<>(); + HashMap childPlaceholders = new HashMap<>(parentPlaceholders); + + Reward.replaySingleNestedReward(plugin, childPlaceholders, "selected", replayState, "AsyncReward/0", + () -> CompletableFuture.completedFuture(null)).toCompletableFuture().join(); + replayState.mergeReplayMetadataInto(parentPlaceholders); + + assertTrue(parentPlaceholders.keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_single_child_"))); + } + + @Test + void completedPlayerCommandSnapshotDoesNotRequirePlayerAgain() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + HashMap placeholders = new HashMap<>(); + java.lang.reflect.Method commandReplay = Reward.class.getDeclaredMethod("replayCommandSequence", + AdvancedCorePlugin.class, HashMap.class, String.class, List.class, stateType, String.class, + java.util.function.BiFunction.class); + commandReplay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage completedCommands = (CompletionStage) commandReplay.invoke(null, plugin, + placeholders, "player", List.of("already-ran"), replayState, "AsyncReward/1", + (java.util.function.BiFunction>) (command, index) -> + CompletableFuture.completedFuture(null)); + completedCommands.toCompletableFuture().join(); + data.set("Commands.Player", new ArrayList<>(List.of("changed-after-checkpoint"))); + RewardCommands.registerCommands(handler, plugin); + when(user.preformCommandAsync(any(ArrayList.class), eq(placeholders))).thenAnswer(invocation -> + Reward.replayCommandSequence(plugin, placeholders, "player", + invocation.getArgument(0), (command, index) -> CompletableFuture.failedFuture( + new AssertionError("completed player command must not run again")))); + + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage result = (CompletionStage) replay.invoke(reward, user, placeholders, 0, + replayState, "AsyncReward", "occurrence"); + result.toCompletableFuture().join(); + + verify(user, never()).validatePlayerCommandAvailabilityAsync(); + } + @Test void missingConfigurationSectionIsAllowedAfterItsSingleChildCompleted() throws Exception { ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); From 8ebdbcf29a32d237c9ebfd3e1f45c6f0ff91d061 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:45:23 -0600 Subject: [PATCH 64/78] Make direct async rewards scheduler safe --- .../advancedcore/api/rewards/Reward.java | 25 ++++++++++++++++++ .../rewards/RewardAsyncInjectionTest.java | 26 +++++++++++++++++++ 2 files changed, 51 insertions(+) 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 38448d660a..16c252c82c 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 @@ -1439,6 +1439,31 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { * @return completion stage for the complete reward injection chain */ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { + boolean primaryThread = false; + try { + primaryThread = Bukkit.isPrimaryThread(); + } catch (IllegalStateException | NullPointerException ignored) { + // Early bootstrap and unit tests may not have an installed Bukkit server. + } + if (!primaryThread) return giveRewardAsyncOffPrimary(user, rewardOptions); + + CompletableFuture> handoff = new CompletableFuture<>(); + try { + RewardOptions requestedOptions = rewardOptions; + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + try { + handoff.complete(giveRewardAsyncOffPrimary(user, requestedOptions)); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + return handoff.thenCompose(stage -> stage); + } + + private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, RewardOptions rewardOptions) { if (!plugin.getOptions().isProcessRewards()) { plugin.debug("Processing rewards is disabled"); return CompletableFuture.completedFuture(null); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index 38b669bb2d..c6c1212af6 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 @@ -589,6 +589,32 @@ void freshAsyncDispatchAlwaysUsesCompletionAwareRewardUserPath() { verify(spyReward).giveRewardUserAsync(eq(user), any(HashMap.class), any(RewardOptions.class)); } + @Test + void directAsyncDispatchLeavesBukkitPrimaryThreadBeforeFiringEvent() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.isOnline()).thenReturn(true); + when(user.getPlayer()).thenReturn(mock(Player.class)); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + ArgumentCaptor asyncTask = ArgumentCaptor.forClass(Runnable.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + CompletionStage result = reward.giveRewardAsync(user, + new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true)); + + verify(scheduler).runTaskAsynchronously(eq(plugin), asyncTask.capture()); + verify(pluginManager, never()).callEvent(any()); + assertFalse(result.toCompletableFuture().isDone()); + asyncTask.getValue().run(); + result.toCompletableFuture().join(); + verify(pluginManager).callEvent(any()); + } + } + @Test void durableReplayRetainsOccurrenceWhenRequirementEvaluationThrows() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From e75605add366aa23b9bed6452aee6681c410506c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:57:11 -0600 Subject: [PATCH 65/78] Skip empty legacy action checkpoints --- .../api/user/AdvancedCoreUser.java | 2 +- .../rewards/RewardAsyncInjectionTest.java | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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 a78a7d7a22..8cce45ae8d 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 @@ -140,7 +140,7 @@ private synchronized CompletionStage closeAndAwait() { return CompletableFuture.failedFuture(new IllegalStateException( "Cannot safely resume legacy reward actions from an ordinal-only or malformed checkpoint", failure)); } - boolean snapshotChanged = persistedSnapshot.isEmpty(); + boolean snapshotChanged = persistedSnapshot.isEmpty() && !currentSnapshot.isEmpty(); if (snapshotChanged) persistedSnapshot = new HashMap<>(currentSnapshot); else if (!persistedSnapshot.equals(currentSnapshot)) { for (Entry persisted : persistedSnapshot.entrySet()) { 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 c6c1212af6..e9a1c969fe 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 @@ -1033,6 +1033,29 @@ void legacyActionsCapturedByAsyncContextAreAwaited() { verify(economy).depositPlayer(offlinePlayer, 2); } + @Test + void emptyLegacyActionCollectionDoesNotCreateItsOwnCheckpoint() { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + AtomicInteger checkpoints = new AtomicInteger(); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> checkpoints.incrementAndGet()); + HashMap placeholders = new HashMap<>(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + + AdvancedCoreUser.AsyncActionCollection collection = realUser.beginAsyncActionCollection( + Reward.replayStateFor(options), placeholders, "AsyncReward/0"); + realUser.endAsyncActionCollection(collection).toCompletableFuture().join(); + + assertEquals(0, checkpoints.get()); + assertFalse(placeholders.keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_legacy_actions_"))); + } + @Test void serializedPersistedReplaysDoNotClaimUnscopedContinuationActions() throws Exception { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From 6da6565c87b0bdb11122d69a87668033db1e88f5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:08:50 -0600 Subject: [PATCH 66/78] Complete missing nested replay sections --- .../advancedcore/api/rewards/Reward.java | 28 +++++++++++ .../builtin/RewardAdvancedRewards.java | 4 +- .../rewards/builtin/RewardAdvancedWorld.java | 4 +- .../api/rewards/builtin/RewardLucky.java | 3 +- .../api/rewards/builtin/RewardSubRewards.java | 4 +- .../rewards/RewardAsyncInjectionTest.java | 48 +++++++++++++++++++ 6 files changed, 87 insertions(+), 4 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 16c252c82c..044b02f427 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 @@ -854,6 +854,34 @@ public static boolean hasReplayNestedRewardSnapshot(HashMap plac return replayMetadata(placeholders, replayState, storageKey + "_snapshot") != null; } + /** Returns whether every child in a frozen nested-reward lane is complete. */ + public static boolean hasCompletedNestedRewardSequence(HashMap placeholders, String lane) { + ReplayState replayState = currentReplayState(); + String activeKey = currentReplayKey(); + if (replayState == null || activeKey == null) return false; + String storageKey = replaySequenceKey(REPLAY_NESTED_LIST_PREFIX, activeKey, + lane == null ? "nested" : lane); + String storedSnapshot = replayMetadata(placeholders, replayState, storageKey + "_snapshot"); + if (storedSnapshot == null) return false; + List rewards; + try { + rewards = decodeCommandSnapshot(storedSnapshot); + } catch (IllegalArgumentException failure) { + throw new IllegalStateException("Malformed nested reward replay snapshot", failure); + } + String storedProgress = replayMetadata(placeholders, replayState, storageKey); + int completed; + try { + completed = storedProgress == null ? 0 : Integer.parseInt(storedProgress); + } catch (NumberFormatException failure) { + throw new IllegalStateException("Malformed nested reward replay progress", failure); + } + if (completed < 0 || completed > rewards.size()) { + throw new IllegalStateException("Nested reward replay progress exceeds snapshot"); + } + return completed == rewards.size(); + } + private static String replayMetadata(HashMap placeholders, ReplayState replayState, String key) { String value = placeholders == null || (replayState != null && !replayState.acceptsPersistedMetadata()) ? null : placeholders.get(key); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java index c4fe365a14..9268ac270f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java @@ -44,7 +44,9 @@ public boolean requiresConfiguredDataForAsync() { @Override public boolean hasPendingReplayWork(HashMap placeholders) { - return Reward.hasReplayNestedRewardSnapshot(placeholders, "advanced-rewards:" + getPath()); + String lane = "advanced-rewards:" + getPath(); + return Reward.hasReplayNestedRewardSnapshot(placeholders, lane) + && !Reward.hasCompletedNestedRewardSequence(placeholders, lane); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java index 0b2784d8a4..f403545e35 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedWorld.java @@ -39,7 +39,9 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean hasPendingReplayWork(HashMap placeholders) { - return Reward.hasReplayNestedRewardSnapshot(placeholders, "advanced-world:" + getPath()); + String lane = "advanced-world:" + getPath(); + return Reward.hasReplayNestedRewardSnapshot(placeholders, lane) + && !Reward.hasCompletedNestedRewardSequence(placeholders, lane); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java index 9b2344508c..d8e5f86688 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardLucky.java @@ -45,7 +45,8 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean hasPendingReplayWork(HashMap placeholders) { - return Reward.hasReplaySelection(placeholders); + return Reward.hasReplaySelection(placeholders) + && !Reward.hasCompletedNestedRewardSequence(placeholders, "lucky:" + getPath()); } @Override diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java index 606bf81732..0f070584f1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardSubRewards.java @@ -40,7 +40,9 @@ public boolean requiresConfiguredDataForAsync() { @Override public boolean hasPendingReplayWork(HashMap placeholders) { - return Reward.hasReplayNestedRewardSnapshot(placeholders, "nested-list:" + getPath()); + String lane = "nested-list:" + getPath(); + return Reward.hasReplayNestedRewardSnapshot(placeholders, lane) + && !Reward.hasCompletedNestedRewardSequence(placeholders, lane); } @Override 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 e9a1c969fe..e109e5faa2 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 @@ -2157,6 +2157,54 @@ void missingConfigurationSectionIsAllowedAfterItsSingleChildCompleted() throws E resumed.toCompletableFuture().join(); } + @Test + void missingConfigurationSectionIsAllowedAfterItsNestedListCompleted() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + java.lang.reflect.Constructor constructor = stateType.getDeclaredConstructor(Map.class, Map.class, + boolean.class); + constructor.setAccessible(true); + Reward.ReplayState replayState = (Reward.ReplayState) constructor.newInstance( + new HashMap<>(), new HashMap<>(), false); + java.lang.reflect.Method setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", + java.util.function.Consumer.class); + setConsumer.setAccessible(true); + setConsumer.invoke(replayState, + (java.util.function.Consumer) ignored -> { }); + HashMap placeholders = new HashMap<>(); + Reward.replayNestedRewardSequence(plugin, placeholders, "nested-list:RemovedSection", + List.of("first", "second"), replayState, "AsyncReward/0", + (rewardName, index) -> CompletableFuture.completedFuture(null)).toCompletableFuture().join(); + + handler.getInjectedRewards().add(new com.bencodez.advancedcore.api.rewards.injected.RewardInjectConfigurationSection( + "RemovedSection") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public boolean requiresConfiguredDataForAsync() { return true; } + @Override public boolean hasPendingReplayWork(HashMap currentPlaceholders) { + String lane = "nested-list:" + getPath(); + return Reward.hasReplayNestedRewardSnapshot(currentPlaceholders, lane) + && !Reward.hasCompletedNestedRewardSequence(currentPlaceholders, lane); + } + @Override public String onRewardRequested(Reward ignoredReward, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredSection, HashMap ignoredPlaceholders) { + throw new AssertionError("completed missing list section must not be resolved"); + } + }); + java.lang.reflect.Method replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", + AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); + replay.setAccessible(true); + @SuppressWarnings("unchecked") + CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, placeholders, 0, + replayState, "AsyncReward", "occurrence"); + + resumed.toCompletableFuture().join(); + } + @Test void everyNestedRewardInjectorWaitsForItsSelectedChild() { handler = org.mockito.Mockito.spy(new RewardHandler(plugin)); From d4a62f27b75a4b3b742981f5853adf10d37370aa Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:24:54 -0600 Subject: [PATCH 67/78] Fix replay completion race handling --- .../api/item/FullInventoryHandler.java | 8 +++- .../advancedcore/api/rewards/Reward.java | 6 ++- .../api/user/AdvancedCoreUser.java | 6 +-- .../tests/item/FullInventoryHandlerTest.java | 37 ++++++++++++++++++ .../rewards/RewardAsyncInjectionTest.java | 38 +++++++++++++++++++ 5 files changed, 87 insertions(+), 8 deletions(-) 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 86806b7fa1..ac62ad6c24 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 @@ -159,20 +159,24 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu if (!deliveryClaimed.compareAndSet(false, true)) return; try { if (completion != null) validateReplayDeliveryTarget(player, deliveryPlayerId); + if (completion != null) replayOverflowCompletions.put(reservationId, completion); boolean pendingOverflow = giveItemOwnedPlayer(player, itemsToGive, reservationId); if (completion != null) { if (pendingOverflow) { - replayOverflowCompletions.put(reservationId, completion); persistReservedOverflowAsync(reservationId).whenComplete((ignored, failure) -> { if (failure == null) completeReplayOverflow(reservationId, completion); else completeStartedOverflowWithFallback(player, deliveryPlayerId, reservationId, completion); }); } else { + replayOverflowCompletions.remove(reservationId, completion); completion.complete(null); } } } catch (Throwable failure) { - if (completion != null) completion.completeExceptionally(failure); + if (completion != null) { + replayOverflowCompletions.remove(reservationId, completion); + completion.completeExceptionally(failure); + } else rethrowDeliveryFailure(failure); } }; 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 044b02f427..8edda7187b 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 @@ -882,13 +882,15 @@ public static boolean hasCompletedNestedRewardSequence(HashMap p return completed == rewards.size(); } - private static String replayMetadata(HashMap placeholders, ReplayState replayState, String key) { + /** Reads reserved replay metadata without accepting stale caller values for a fresh execution. */ + public static String replayMetadata(HashMap placeholders, ReplayState replayState, String key) { String value = placeholders == null || (replayState != null && !replayState.acceptsPersistedMetadata()) ? null : placeholders.get(key); return value == null && replayState != null ? replayState.replayMetadata(key) : value; } - private static void recordReplayMetadata(HashMap placeholders, ReplayState replayState, + /** Records reserved replay metadata in the execution-local state and only durable caller placeholders. */ + public static void recordReplayMetadata(HashMap placeholders, ReplayState replayState, String key, String value) { if (replayState != null) replayState.recordReplayMetadata(key, value); if (placeholders != null && (replayState == null || replayState.hasCheckpointConsumer())) { 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 8cce45ae8d..c55c2cc27a 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 @@ -205,13 +205,11 @@ private static Throwable unwrapCompletionFailure(Throwable failure) { } private String readReplayValue(String key) { - String value = placeholders == null ? null : placeholders.get(key); - return value == null && replayState != null ? replayState.replayMetadata(key) : value; + return Reward.replayMetadata(placeholders, replayState, key); } private void recordReplayValue(String key, String value) { - if (placeholders != null) placeholders.put(key, value); - if (replayState != null) replayState.recordReplayMetadata(key, value); + Reward.recordReplayMetadata(placeholders, replayState, key, value); } private CompletionStage checkpoint() { 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 6d30af4bc4..b1ee6f61f1 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 @@ -27,6 +27,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.ConcurrentHashMap; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -189,6 +190,42 @@ public void giveItemAsyncWaitsForOverflowToBePersisted() throws Exception { assertEquals(List.of(excess), fixture.handler.getItems().get(uuid)); } + @Test + public void replayCompletionIsRegisteredBeforeOverflowReservationCanRaceShutdown() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + doAnswer(invocation -> { + java.lang.reflect.Field completionsField = FullInventoryHandler.class + .getDeclaredField("replayOverflowCompletions"); + completionsField.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap> completions = + (ConcurrentHashMap>) completionsField + .get(fixture.handler); + assertEquals(1, completions.size(), + "shutdown must be able to find the completion before it can snapshot a reservation"); + return new HashMap<>(java.util.Map.of(0, excess)); + }).when(inventory).addItem(item); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + delivery.toCompletableFuture().get(2, TimeUnit.SECONDS); + } + @Test public void replayDropFailurePersistsOnlyTheUndroppedOverflow() throws Exception { Fixture fixture = createFixture(); 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 e109e5faa2..b58f4a1956 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 @@ -171,6 +171,44 @@ void reusedTopLevelOptionsDoNotShareCompletedReplayState() { assertNull(options.getAsyncReplayOccurrenceId()); } + @Test + void reusableFreshPlaceholdersDoNotRetainLegacyActionCompletionMarkers() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + VaultHandler vault = mock(VaultHandler.class); + Economy economy = mock(Economy.class); + when(vault.getEcon()).thenReturn(economy); + when(plugin.getVaultHandler()).thenReturn(vault); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, UUID.randomUUID(), false, false); + realUser.setPlayerName("Reusable"); + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + handler.getInjectedRewards().add(new RewardInject("Legacy") { + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + realUser.giveMoney(2); + return null; + } + }); + HashMap placeholders = new HashMap<>(); + UUID uuid = UUID.fromString(realUser.getUUID()); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(null); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offlinePlayer); + reward.giveInjectedRewardsAsync(realUser, placeholders).toCompletableFuture().join(); + reward.giveInjectedRewardsAsync(realUser, placeholders).toCompletableFuture().join(); + } + + verify(economy, times(2)).depositPlayer(offlinePlayer, 2); + assertFalse(placeholders.keySet().stream() + .anyMatch(key -> key.startsWith("__advancedcore_replay_legacy_actions_"))); + } + @Test void legacyScheduledRewardsWaitForQueuedBukkitActions() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From e6b1a80e6ce8c3a35c0c9f133dc409ff415e44e6 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:13:15 -0600 Subject: [PATCH 68/78] Preserve claimed rewards during queue trimming --- .../api/user/AdvancedCoreUser.java | 75 ++++++++++++++----- .../tests/user/AdvancedCoreUserTest.java | 34 +++++++++ 2 files changed, 89 insertions(+), 20 deletions(-) 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 c55c2cc27a..94c940ebd1 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 @@ -2794,29 +2794,64 @@ private void setOfflineRewards(ArrayList offlineRewards, boolean queue) } private void setOfflineRewards(ArrayList offlineRewards, boolean queue, String protectedEntry) { - // MySQL TEXT max length is 65535 bytes - int maxLength = 65535; - String str = String.join("%line%", offlineRewards); - - // Remove oldest rewards until within limit - while (str.getBytes().length > maxLength && !offlineRewards.isEmpty()) { - int removalIndex = 0; - while (removalIndex < offlineRewards.size() - && protectedEntry != null && protectedEntry.equals(offlineRewards.get(removalIndex))) { - removalIndex++; - } - if (removalIndex >= offlineRewards.size()) { - // Never trade a durable in-flight checkpoint for queue-size compliance. - // The storage write must retain the only record that prevents already - // completed non-idempotent stages from running again. - break; + synchronized (plugin) { + // MySQL TEXT max length is 65535 bytes + int maxLength = 65535; + String str = String.join("%line%", offlineRewards); + ReplayClaims claims = existingReplayClaims(); + HashMap removableLegacyOccurrences = new HashMap<>(); + HashSet claimedOccurrenceIds = new HashSet<>(); + for (String rewardEntry : offlineRewards) { + if (occurrenceId(rewardEntry) == null) { + removableLegacyOccurrences.merge(rewardEntry, 1, Integer::sum); + } + } + if (claims != null) { + for (Entry claim : claims.offline.entrySet()) { + if (claim.getValue() == null || claim.getValue() <= 0) continue; + String occurrence = occurrenceId(claim.getKey()); + if (occurrence != null) claimedOccurrenceIds.add(occurrence); + else removableLegacyOccurrences.computeIfPresent(claim.getKey(), + (ignored, count) -> Math.max(0, count - claim.getValue())); + } + } + + // Remove oldest unclaimed rewards until within limit. Claimed entries may + // be waiting in the serial replay backlog and remain the only durable copy. + while (str.getBytes().length > maxLength && !offlineRewards.isEmpty()) { + int removalIndex = 0; + while (removalIndex < offlineRewards.size()) { + String candidate = offlineRewards.get(removalIndex); + String occurrence = occurrenceId(candidate); + boolean explicitlyProtected = protectedEntry != null && protectedEntry.equals(candidate); + boolean claimed = occurrence != null ? claimedOccurrenceIds.contains(occurrence) + : removableLegacyOccurrences.getOrDefault(candidate, 0) <= 0; + if (!explicitlyProtected && !claimed) break; + removalIndex++; + } + if (removalIndex >= offlineRewards.size()) { + // Never trade a durable in-flight checkpoint for queue-size compliance. + break; + } + String removed = offlineRewards.remove(removalIndex); + if (occurrenceId(removed) == null) { + removableLegacyOccurrences.computeIfPresent(removed, + (ignored, count) -> Math.max(0, count - 1)); + } + str = String.join("%line%", offlineRewards); } - offlineRewards.remove(removalIndex); - str = String.join("%line%", offlineRewards); + + if (queue) data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards); + else data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards, false); } + } - if (queue) data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards); - else data.setStringList(plugin.getUserManager().getOfflineRewardsPath(), offlineRewards, false); + /** Returns shared replay state without creating an empty entry for ordinary writes. */ + private ReplayClaims existingReplayClaims() { + synchronized (REPLAY_CLAIMS_LOCK) { + HashMap byUser = REPLAY_CLAIMS.get(plugin); + return byUser == null ? null : byUser.get(getUUID()); + } } /** diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 52eb27d3c1..789bbe3469 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -438,6 +438,40 @@ void durableCheckpointTrimmingPreservesTheActiveEntry() throws Exception { verify(data).setStringList("offlineRewardsPath", rewards, false); } + @Test + void queueTrimmingPreservesEveryClaimedBacklogOccurrence() throws Exception { + String claimed = "claimed%asyncoccurrence%" + UUID.randomUUID(); + String oversizedUnclaimed = "unclaimed-" + "x".repeat(70_000); + ArrayList rewards = new ArrayList<>(List.of(claimed, oversizedUnclaimed)); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenReturn(new ArrayList<>(rewards)); + java.lang.reflect.Method claim = AdvancedCoreUser.class.getDeclaredMethod("claimOfflineReward", String.class); + claim.setAccessible(true); + + assertTrue((boolean) claim.invoke(user, claimed)); + user.setOfflineRewards(rewards); + + assertEquals(List.of(claimed), rewards); + verify(data).setStringList("offlineRewardsPath", rewards); + } + + @Test + void queueTrimmingRetainsClaimedCountOfIdenticalLegacyEntries() throws Exception { + String legacy = "legacy-" + "x".repeat(30_000); + ArrayList rewards = new ArrayList<>(List.of(legacy, legacy, legacy)); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenReturn(new ArrayList<>(rewards)); + java.lang.reflect.Method claim = AdvancedCoreUser.class.getDeclaredMethod("claimOfflineReward", String.class); + claim.setAccessible(true); + + assertTrue((boolean) claim.invoke(user, legacy)); + assertTrue((boolean) claim.invoke(user, legacy)); + user.setOfflineRewards(rewards); + + assertEquals(List.of(legacy, legacy), rewards); + verify(data).setStringList("offlineRewardsPath", rewards); + } + @Test void failedAsyncOfflineReplayIsRestoredForRetry() { ArrayList initial = new ArrayList<>(); From cfcc5426a5d50cec4ad27c4fb8bc83143a1c8768 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:24:15 -0600 Subject: [PATCH 69/78] Make item completion atomic with shutdown --- .../api/item/FullInventoryHandler.java | 7 +++ .../tests/item/FullInventoryHandlerTest.java | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+) 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 ac62ad6c24..a46ba56337 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java @@ -157,7 +157,12 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu AtomicBoolean deliveryClaimed = new AtomicBoolean(); Runnable delivery = () -> { if (!deliveryClaimed.compareAndSet(false, true)) return; + boolean replayDeliveryLocked = false; try { + if (completion != null) { + deliveryLock.readLock().lock(); + replayDeliveryLocked = true; + } if (completion != null) validateReplayDeliveryTarget(player, deliveryPlayerId); if (completion != null) replayOverflowCompletions.put(reservationId, completion); boolean pendingOverflow = giveItemOwnedPlayer(player, itemsToGive, reservationId); @@ -178,6 +183,8 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu completion.completeExceptionally(failure); } else rethrowDeliveryFailure(failure); + } finally { + if (replayDeliveryLocked) deliveryLock.readLock().unlock(); } }; try { 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 b1ee6f61f1..247d4b9502 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java @@ -154,6 +154,53 @@ public void giveItemAsyncCompletesOnlyAfterOwnedInventoryDelivery() { verify(inventory).addItem(item); } + @Test + public void noOverflowCompletionPrecedesShutdownSnapshot() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>()); + CountDownLatch saveStarted = new CountDownLatch(1); + doAnswer(invocation -> { + saveStarted.countDown(); + return null; + }).when(fixture.serverData).saveData(); + + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + AtomicReference shutdownThread = new AtomicReference<>(); + CompletionStage completionObserved = delivery.thenRun(() -> { + Thread shutdown = new Thread(fixture.handler::shutdown, "full-inventory-shutdown-race"); + shutdownThread.set(shutdown); + shutdown.start(); + try { + assertFalse(saveStarted.await(100, TimeUnit.MILLISECONDS), + "shutdown snapshot overtook the successful delivery completion"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + }); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + + completionObserved.toCompletableFuture().get(2, TimeUnit.SECONDS); + Thread shutdown = shutdownThread.get(); + assertNotNull(shutdown); + shutdown.join(TimeUnit.SECONDS.toMillis(2)); + assertFalse(shutdown.isAlive()); + assertEquals(0, saveStarted.getCount()); + verify(inventory).addItem(item); + } + @Test public void giveItemAsyncWaitsForOverflowToBePersisted() throws Exception { Fixture fixture = createFixture(); From 9a29a43b17d2d9ffd26edbfaea7fad5aad256bd4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:07:20 -0600 Subject: [PATCH 70/78] Bound async replay recovery handoffs --- .../advancedcore/api/rewards/Reward.java | 20 +++++++++++-- .../api/user/AdvancedCoreUser.java | 23 ++++++++++++--- .../rewards/RewardAsyncInjectionTest.java | 27 +++++++++++++++++ .../tests/user/AdvancedCoreUserTest.java | 29 +++++++++++++++++++ 4 files changed, 92 insertions(+), 7 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 8edda7187b..d88e190acd 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 @@ -1481,16 +1481,30 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption try { RewardOptions requestedOptions = rewardOptions; plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + CompletableFuture result = new CompletableFuture<>(); + // Claim before invoking reward code. A timed-out task that Bukkit later + // runs must not produce side effects for a caller that already retries. + if (!handoff.complete(result)) return; try { - handoff.complete(giveRewardAsyncOffPrimary(user, requestedOptions)); + CompletionStage stage = giveRewardAsyncOffPrimary(user, requestedOptions); + if (stage == null) { + result.completeExceptionally(new IllegalStateException( + "Reward dispatch returned no completion stage")); + return; + } + stage.whenComplete((ignored, failure) -> { + if (failure == null) result.complete(null); + else result.completeExceptionally(failure); + }); } catch (Throwable failure) { - handoff.completeExceptionally(failure); + result.completeExceptionally(failure); } }); } catch (Throwable failure) { handoff.completeExceptionally(failure); } - return handoff.thenCompose(stage -> stage); + return handoff.orTimeout(getServerThreadDispatchTimeoutMillis(), TimeUnit.MILLISECONDS) + .thenCompose(stage -> stage); } private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, RewardOptions rewardOptions) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 94c940ebd1..26230f5837 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 @@ -1330,13 +1330,28 @@ private void restoreTimedReward(String rewardEntry, long time, Throwable failure synchronized (plugin) { ReplayClaims claims = replayClaims(); HashMap pending = getTimedRewards(); - int retry = Math.min(8, asyncRetryCount(rewardEntry) + 1); + String occurrence = occurrenceId(stripTimedExecutionMarker(rewardEntry)); + String restoredEntry = rewardEntry; + if (occurrence != null) { + for (String candidate : pending.keySet()) { + if (occurrence.equals(occurrenceId(stripTimedExecutionMarker(candidate)))) { + restoredEntry = candidate; + if (!candidate.equals(rewardEntry)) break; + } + } + pending.keySet().removeIf(candidate -> occurrence.equals( + occurrenceId(stripTimedExecutionMarker(candidate)))); + claims.timed.removeIf(candidate -> occurrence.equals( + occurrenceId(stripTimedExecutionMarker(candidate)))); + } else { + pending.remove(rewardEntry); + claims.timed.remove(rewardEntry); + } + int retry = Math.min(8, asyncRetryCount(restoredEntry) + 1); long retryDelay = Math.min(TimeUnit.MINUTES.toMillis(5), TimeUnit.SECONDS.toMillis(1L << retry)); retryTime = System.currentTimeMillis() + retryDelay; - pending.remove(rewardEntry); - pending.put(withAsyncRetryCount(withAsyncReplayProgress(rewardEntry, failure), retry), retryTime); + pending.put(withAsyncRetryCount(withAsyncReplayProgress(restoredEntry, failure), retry), retryTime); setTimedRewards(pending); - claims.timed.remove(rewardEntry); releaseReplayClaimsIfEmpty(claims); } // A due entry restored after its original timer fired needs its own retry 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 b58f4a1956..4a47e2954c 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 @@ -653,6 +653,33 @@ void directAsyncDispatchLeavesBukkitPrimaryThreadBeforeFiringEvent() { } } + @Test + void timedOutPrimaryThreadHandoffCannotRunLateRewardSideEffects() throws Exception { + Reward shortTimeoutReward = new Reward("AsyncReward", data) { + @Override + protected long getServerThreadDispatchTimeoutMillis() { + return 25; + } + }; + java.lang.reflect.Field pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(shortTimeoutReward, plugin); + ArgumentCaptor delayedTask = ArgumentCaptor.forClass(Runnable.class); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + CompletionStage result = shortTimeoutReward.giveRewardAsync(user, + new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true)); + + verify(scheduler).runTaskAsynchronously(eq(plugin), delayedTask.capture()); + assertThrows(CompletionException.class, () -> result.toCompletableFuture().join()); + delayedTask.getValue().run(); + verify(pluginManager, never()).callEvent(any()); + } + } + @Test void durableReplayRetainsOccurrenceWhenRequirementEvaluationThrows() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 789bbe3469..3c1301bfeb 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -554,6 +554,35 @@ void failedAsyncTimedReplayIsRestoredAfterDueEntriesAreSaved() { eq(TimeUnit.MILLISECONDS)); } + @Test + void failedTimedReplayReconcilesCachedSameOccurrenceBeforeRestoring() { + long due = System.currentTimeMillis() - 1_000; + String occurrence = UUID.randomUUID().toString(); + String replaying = "VoteReward%asyncoccurrence%" + occurrence + "%placeholders%Server%pair%old"; + String cached = "VoteReward%asyncoccurrence%" + occurrence + + "%asyncprogress%2%asyncretry%3%placeholders%Server%pair%cached"; + ArrayList timed = new ArrayList<>(List.of( + replaying + "%ExecutionTime/%" + due, + cached + "%ExecutionTime/%" + (System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1)))); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(timed)); + org.mockito.Mockito.doAnswer(invocation -> { + timed.clear(); + timed.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any()); + when(rewardHandler.givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), + any(RewardOptions.class))).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("temporary"))); + + user.checkDelayedTimedRewards(); + + assertEquals(1, timed.size()); + assertTrue(timed.get(0).contains("%asyncoccurrence%" + occurrence)); + assertTrue(timed.get(0).contains("%asyncprogress%2%")); + assertTrue(timed.get(0).contains("%asyncretry%4%")); + assertTrue(timed.get(0).contains("Server%pair%cached")); + } + @Test void pendingAsyncOfflineReplayRemainsDurableAndIsNotDispatchedTwice() { ArrayList rewards = new ArrayList<>(); From d23509deaeb1c0e9ad2cc89dc1e396b78c068e8d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:32:27 -0600 Subject: [PATCH 71/78] Skip player gating for zero experience rewards --- .../api/rewards/builtin/RewardExp.java | 6 ++++-- .../rewards/BuiltinRewardBehaviorTest.java | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java index ae446cea10..5ad55aa18d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java @@ -34,7 +34,8 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, user.giveExp(num); return null; } - }.requiresPlayer().asPlaceholder("EXP").priority(100).addEditButton( + }.requiresPlayerWhen((data, placeholders) -> data.getInt("EXP", 0) != 0) + .asPlaceholder("EXP").priority(100).addEditButton( new EditGUIButton(new ItemBuilder("EXPERIENCE_BOTTLE"), new EditGUIValueInventory("EXP") { @Override public void openInventory(ClickEvent clickEvent) { @@ -57,7 +58,8 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, user.giveExpLevels(num); return null; } - }.requiresPlayer().asPlaceholder("EXP").priority(100).addEditButton( + }.requiresPlayerWhen((data, placeholders) -> data.getInt("EXPLevels", 0) != 0) + .asPlaceholder("EXP").priority(100).addEditButton( new EditGUIButton(new ItemBuilder("EXPERIENCE_BOTTLE"), new EditGUIValueInventory("EXPLevels") { @Override public void openInventory(ClickEvent clickEvent) { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 8a9d6af1d2..962261ff37 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -188,6 +188,15 @@ public void emptyActionBarDoesNotRequireAnOnlinePlayer() { assertTrue(inject.isPlayerRequiredFor(data, placeholders)); } + @Test + public void expScalarsRequireAnOnlinePlayerOnlyWhenNonZero() { + RewardExp.register(handler, plugin); + YamlConfiguration data = new YamlConfiguration(); + + assertScalarPlayerRequirement(injects.get(0), data, "EXP"); + assertScalarPlayerRequirement(injects.get(1), data, "EXPLevels"); + } + @Test public void bossBarActuallySendsConfiguredBossBar() { RewardBossBar.register(handler, plugin); @@ -774,6 +783,16 @@ private ConfigurationSection section(String name) { return new YamlConfiguration().createSection(name); } + private void assertScalarPlayerRequirement(RewardInject inject, YamlConfiguration data, String path) { + assertFalse(inject.isPlayerRequiredFor(data, placeholders), path + " is absent"); + data.set(path, 0); + assertFalse(inject.isPlayerRequiredFor(data, placeholders), path + " is zero"); + data.set(path, 1); + assertTrue(inject.isPlayerRequiredFor(data, placeholders), path + " is positive"); + data.set(path, -1); + assertTrue(inject.isPlayerRequiredFor(data, placeholders), path + " is negative"); + } + @SuppressWarnings("unused") private RewardInject register(BiConsumer registrar) { injects.clear(); From fdfc48a9f4d4e2f8942bbc182f0cb5386417d014 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:44:51 -0600 Subject: [PATCH 72/78] Harden asynchronous reward dispatch --- AdvancedCore/pom.xml | 2 +- .../api/rewards/RewardExecutor.java | 23 ++++++++++++++++--- .../api/rewards/builtin/RewardExp.java | 4 ++-- .../rewards/BuiltinRewardBehaviorTest.java | 11 +++++++++ .../tests/rewards/RewardExecutorTest.java | 22 ++++++++++++++++++ 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml index 5e33f01bc8..1865253270 100644 --- a/AdvancedCore/pom.xml +++ b/AdvancedCore/pom.xml @@ -96,7 +96,7 @@ 3.6.1 false - true + false diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java index 2dfcd3ab40..cb492b4649 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java @@ -6,6 +6,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; import org.bukkit.Bukkit; import org.bukkit.configuration.Configuration; @@ -217,18 +218,34 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward rewar CompletableFuture> handoff = new CompletableFuture<>(); try { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + CompletableFuture result = new CompletableFuture<>(); + if (!handoff.complete(result)) return; try { - handoff.complete(reward.giveRewardAsync(user, options)); + CompletionStage stage = reward.giveRewardAsync(user, options); + if (stage == null) { + result.completeExceptionally(new IllegalStateException( + "Reward dispatch returned no completion stage")); + return; + } + stage.whenComplete((ignored, failure) -> { + if (failure == null) result.complete(null); + else result.completeExceptionally(failure); + }); } catch (Throwable failure) { - handoff.completeExceptionally(failure); + result.completeExceptionally(failure); } }); } catch (Throwable failure) { handoff.completeExceptionally(failure); } - return handoff.thenCompose(stage -> stage); + return handoff.orTimeout(getPrimaryThreadHandoffTimeoutMillis(), TimeUnit.MILLISECONDS) + .thenCompose(stage -> stage); } + protected long getPrimaryThreadHandoffTimeoutMillis() { + return TimeUnit.SECONDS.toMillis(30); + } + public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) { RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user); if (reward == null || reward.isEmpty()) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java index 5ad55aa18d..88c76c4d0e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java @@ -31,7 +31,7 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, HashMap placeholders) { - user.giveExp(num); + if (num != 0) user.giveExp(num); return null; } }.requiresPlayerWhen((data, placeholders) -> data.getInt("EXP", 0) != 0) @@ -55,7 +55,7 @@ public void setVal(String key, Object value) { @Override public String onRewardRequest(Reward reward, AdvancedCoreUser user, int num, HashMap placeholders) { - user.giveExpLevels(num); + if (num != 0) user.giveExpLevels(num); return null; } }.requiresPlayerWhen((data, placeholders) -> data.getInt("EXPLevels", 0) != 0) diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 962261ff37..50d3c303d9 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -141,6 +141,17 @@ public void expActuallyGivesExperienceAndLevels() { verify(user).giveExpLevels(8); } + @Test + public void zeroExperienceScalarsDoNotInvokePlayerCallbacks() { + RewardExp.register(handler, plugin); + + ((RewardInjectInt) injects.get(0)).onRewardRequest(reward, user, 0, placeholders); + ((RewardInjectInt) injects.get(1)).onRewardRequest(reward, user, 0, placeholders); + + verify(user, never()).giveExp(org.mockito.ArgumentMatchers.anyInt()); + verify(user, never()).giveExpLevels(org.mockito.ArgumentMatchers.anyInt()); + } + @Test public void moneyActuallyGivesFixedAndRangedAmounts() { RewardMoney.register(handler, plugin); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java index 85f1992ee0..2b5fb0a16c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardExecutorTest.java @@ -198,6 +198,28 @@ public void asyncRewardSetupLeavesThePrimaryThreadAndRetainsCompletion() { } } + @Test + public void asyncRewardSetupTimesOutWhenTheAcceptedTaskNeverStarts() { + Reward child = mock(Reward.class); + BukkitScheduler scheduler = plugin.getBukkitScheduler(); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + RewardExecutor bounded = new RewardExecutor(handler, plugin) { + @Override + protected long getPrimaryThreadHandoffTimeoutMillis() { + return 10L; + } + }; + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + CompletionStage result = bounded.giveRewardAsync(user, child, new RewardOptions()); + verify(scheduler).runTaskAsynchronously(eq(plugin), task.capture()); + assertThrows(CompletionException.class, () -> result.toCompletableFuture().join()); + task.getValue().run(); + verify(child, never()).giveRewardAsync(eq(user), any(RewardOptions.class)); + } + } + @Test public void asyncListLazilyClonesAndSharesReplayMetadataThroughState() { YamlConfiguration data = new YamlConfiguration(); From 3cd0a2c45ec24f860f4c94e28d102ccae0f735d3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:01:01 -0600 Subject: [PATCH 73/78] Make random reward retries deterministic --- AdvancedCore/pom.xml | 32 +++++++++++--- .../api/rewards/builtin/RewardExp.java | 6 ++- .../api/rewards/builtin/RewardItems.java | 3 +- .../api/rewards/builtin/RewardMoney.java | 3 +- .../rewards/BuiltinRewardBehaviorTest.java | 43 ++++++++++++++++++- 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml index 1865253270..5fc0f83530 100644 --- a/AdvancedCore/pom.xml +++ b/AdvancedCore/pom.xml @@ -85,11 +85,6 @@ ${project.name} - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.4 - org.apache.maven.plugins maven-shade-plugin @@ -136,6 +131,33 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + default-test + + + **/PackagedArtifactTest.java + + + + + packaged-artifact-test + package + test + + **/PackagedArtifactTest.java + ${project.build.directory}/packaged-artifact-reports + + ${project.build.directory}/${project.name}.jar + + + + + diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java index 88c76c4d0e..f0b0f60874 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardExp.java @@ -81,7 +81,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat HashMap placeholders) { int minExp = section.getInt("Min", 0); int maxExp = section.getInt("Max", 0); - int value = ThreadLocalRandom.current().nextInt(minExp, maxExp); + int value = Integer.parseInt(Reward.replaySelection(placeholders, + () -> Integer.toString(ThreadLocalRandom.current().nextInt(minExp, maxExp)))); user.giveExp(value); return "" + value; } @@ -93,7 +94,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat HashMap placeholders) { int minExp = section.getInt("Min", 0); int maxExp = section.getInt("Max", 0); - int value = ThreadLocalRandom.current().nextInt(minExp, maxExp); + int value = Integer.parseInt(Reward.replaySelection(placeholders, + () -> Integer.toString(ThreadLocalRandom.current().nextInt(minExp, maxExp)))); user.giveExpLevels(value); return "" + value; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java index 9ca8766b7d..5d0cfd9860 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java @@ -57,7 +57,8 @@ public static void registerRandomItem(RewardHandler handler, AdvancedCorePlugin public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set section, ConfigurationSection data, HashMap placeholders) { if (!section.isEmpty()) { - String item = ArrayUtils.pickRandom(ArrayUtils.convert(section)); + String item = Reward.replaySelection(placeholders, + () -> ArrayUtils.pickRandom(ArrayUtils.convert(section))); ItemBuilder builder = new ItemBuilder(data.getConfigurationSection(item)); builder.setCheckLoreLength(false); user.giveItem(builder); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMoney.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMoney.java index bcba6e022b..3fbd2137e9 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMoney.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardMoney.java @@ -65,7 +65,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat HashMap placeholders) { double minMoney = section.getDouble("Min", 0); double maxMoney = section.getDouble("Max", 0); - double value = ThreadLocalRandom.current().nextDouble(minMoney, maxMoney); + double value = Double.parseDouble(Reward.replaySelection(placeholders, + () -> Double.toString(ThreadLocalRandom.current().nextDouble(minMoney, maxMoney)))); if (section.getBoolean("Round")) { value = Math.round(value); user.giveMoney(value); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 50d3c303d9..b75a81460a 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -136,11 +136,28 @@ public void expActuallyGivesExperienceAndLevels() { levels.set("Min", 8); levels.set("Max", 9); String levelResult = ((RewardInjectConfigurationSection) injects.get(3)) - .onRewardRequested(reward, user, levels, placeholders); + .onRewardRequested(reward, user, levels, new HashMap<>()); assertEquals("8", levelResult); verify(user).giveExpLevels(8); } + @Test + public void rangedExperienceReusesItsDurableSelectionOnRetry() { + RewardExp.register(handler, plugin); + RewardInjectConfigurationSection expInject = (RewardInjectConfigurationSection) injects.get(2); + ConfigurationSection exp = section("EXP"); + exp.set("Min", 5); + exp.set("Max", 6); + + assertEquals("5", expInject.onRewardRequested(reward, user, exp, placeholders)); + exp.set("Min", 8); + exp.set("Max", 9); + assertEquals("5", expInject.onRewardRequested(reward, user, exp, placeholders)); + + verify(user, org.mockito.Mockito.times(2)).giveExp(5); + verify(user, never()).giveExp(8); + } + @Test public void zeroExperienceScalarsDoNotInvokePlayerCallbacks() { RewardExp.register(handler, plugin); @@ -174,6 +191,26 @@ public void moneyActuallyGivesFixedAndRangedAmounts() { assertNotNull(result); } + @Test + public void rangedMoneyReusesItsDurableSelectionOnRetry() { + RewardMoney.register(handler, plugin); + RewardInjectConfigurationSection moneyInject = (RewardInjectConfigurationSection) injects.get(1); + ConfigurationSection money = section("Money"); + money.set("Min", 5.0); + money.set("Max", 6.0); + money.set("Round", false); + + String first = moneyInject.onRewardRequested(reward, user, money, placeholders); + money.set("Min", 8.0); + money.set("Max", 9.0); + String second = moneyInject.onRewardRequested(reward, user, money, placeholders); + + assertEquals(first, second); + ArgumentCaptor amount = ArgumentCaptor.forClass(Double.class); + verify(user, org.mockito.Mockito.times(2)).giveMoney(amount.capture()); + assertEquals(amount.getAllValues().get(0), amount.getAllValues().get(1)); + } + @Test public void actionBarActuallySendsConfiguredMessage() { RewardActionBar.register(handler, plugin); @@ -503,6 +540,10 @@ public void itemRewardsActuallyGiveBuiltItems() { String selected = ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, random.getKeys(false), random, placeholders); assertEquals("OnlyItem", selected); + random.createSection("OtherItem"); + String replayed = ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, + java.util.Set.of("OtherItem"), random, placeholders); + assertEquals("OnlyItem", replayed); verify(user, atLeastOnce()).giveItem(any(ItemBuilder.class)); injects.clear(); From 6d0a9f798f14ac5f387f25fcd2c543474d92f6c3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:11:58 -0600 Subject: [PATCH 74/78] Fail closed when a replayed random item is removed --- .../advancedcore/api/rewards/builtin/RewardItems.java | 6 +++++- .../tests/rewards/BuiltinRewardBehaviorTest.java | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java index 5d0cfd9860..add5592005 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java @@ -59,7 +59,11 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set ArrayUtils.pickRandom(ArrayUtils.convert(section))); - ItemBuilder builder = new ItemBuilder(data.getConfigurationSection(item)); + ConfigurationSection selected = data.getConfigurationSection(item); + if (selected == null) { + throw new IllegalStateException("Selected random item is no longer configured: " + item); + } + ItemBuilder builder = new ItemBuilder(selected); builder.setCheckLoreLength(false); user.giveItem(builder); return item; diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index b75a81460a..db4ef67a00 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -540,10 +540,10 @@ public void itemRewardsActuallyGiveBuiltItems() { String selected = ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, random.getKeys(false), random, placeholders); assertEquals("OnlyItem", selected); + random.set("OnlyItem", null); random.createSection("OtherItem"); - String replayed = ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, - java.util.Set.of("OtherItem"), random, placeholders); - assertEquals("OnlyItem", replayed); + assertThrows(IllegalStateException.class, () -> ((RewardInjectKeys) injects.get(0)) + .onRewardRequested(reward, user, java.util.Set.of("OtherItem"), random, placeholders)); verify(user, atLeastOnce()).giveItem(any(ItemBuilder.class)); injects.clear(); From 7d7f569be7b076f1347b802c2e741096abcb9a39 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:37:39 -0600 Subject: [PATCH 75/78] Freeze item payloads across durable retries --- .../advancedcore/api/rewards/Reward.java | 13 +++- .../api/rewards/builtin/RewardItems.java | 76 +++++++++++++++---- .../rewards/BuiltinRewardBehaviorTest.java | 37 +++++++-- .../rewards/RewardAsyncInjectionTest.java | 21 ++++- 4 files changed, 122 insertions(+), 25 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 d88e190acd..2f72691a0a 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 @@ -536,9 +536,16 @@ public static void preserveReplayState(RewardOptions options) { * child has already performed a side effect. */ public static String replaySelection(HashMap placeholders, Supplier selector) { + return replaySelection(placeholders, null, selector); + } + + /** Records an independent nondeterministic decision within the active injection. */ + public static String replaySelection(HashMap placeholders, String lane, Supplier selector) { String activeKey = ACTIVE_REPLAY_KEY.get(); + String scopedKey = activeKey == null ? "root" : activeKey; + if (lane != null && !lane.isEmpty()) scopedKey += "\u0000" + lane; String storageKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding() - .encodeToString((activeKey == null ? "root" : activeKey).getBytes(StandardCharsets.UTF_8)); + .encodeToString(scopedKey.getBytes(StandardCharsets.UTF_8)); ReplayState replayState = ACTIVE_REPLAY_STATE.get(); String stored = replayMetadata(placeholders, replayState, storageKey); if (stored != null) { @@ -1510,6 +1517,10 @@ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOption private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, RewardOptions rewardOptions) { if (!plugin.getOptions().isProcessRewards()) { plugin.debug("Processing rewards is disabled"); + if (isDurableReplay(rewardOptions)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Reward processing was disabled before queued delivery")); + } return CompletableFuture.completedFuture(null); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java index add5592005..20db78c611 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java @@ -2,9 +2,12 @@ import java.util.HashMap; import java.util.Set; +import java.util.function.Supplier; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent; @@ -43,9 +46,11 @@ public static void registerItem(RewardHandler handler, AdvancedCorePlugin plugin @Override public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, HashMap placeholders) { - ItemBuilder builder = new ItemBuilder(section); - builder.setCheckLoreLength(false); - user.giveItem(builder); + replayItem(user, placeholders, "payload", () -> { + ItemBuilder builder = new ItemBuilder(section); + builder.setCheckLoreLength(false); + return builder; + }); return null; } }.requiresPlayer().validator(itemValidator())); @@ -59,13 +64,15 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set ArrayUtils.pickRandom(ArrayUtils.convert(section))); - ConfigurationSection selected = data.getConfigurationSection(item); - if (selected == null) { - throw new IllegalStateException("Selected random item is no longer configured: " + item); - } - ItemBuilder builder = new ItemBuilder(selected); - builder.setCheckLoreLength(false); - user.giveItem(builder); + replayItem(user, placeholders, "payload:" + item, () -> { + ConfigurationSection selected = data.getConfigurationSection(item); + if (selected == null) { + throw new IllegalStateException("Selected random item is no longer configured: " + item); + } + ItemBuilder builder = new ItemBuilder(selected); + builder.setCheckLoreLength(false); + return builder; + }); return item; } return null; @@ -83,11 +90,13 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set { + ItemBuilder builder = new ItemBuilder(data.getConfigurationSection(item)); + builder.setCheckLoreLength(false); + return builder.setPlaceholders(placeholders); + }); + debug("Giving item " + item + ":" + selected); + if (selected != null && oneChance) { return item; } } @@ -110,7 +119,42 @@ public void setVal(String key, Object value) { } }.open(clickEvent.getPlayer(), reward); } - }.addLore("Edit items")))); + }.addLore("Edit items")))); + } + + private static ItemStack replayItem(AdvancedCoreUser user, HashMap placeholders, String lane, + Supplier builder) { + org.bukkit.entity.Player player = user.getPlayer(); + if (player == null) { + // Preserve the legacy deferred-delivery path. Durable replay records this + // action as not started if the player remains unavailable, so a later retry + // may safely build and freeze the payload before its first side effect. + user.giveItem(builder.get()); + return null; + } + String serialized = Reward.replaySelection(placeholders, lane, + () -> serializeItem(builder.get().toItemStack(player))); + if (serialized == null) return null; + if (!serialized.startsWith("v1:")) { + throw new IllegalStateException("Persisted item reward payload has an unsupported format"); + } + YamlConfiguration frozen = new YamlConfiguration(); + try { + frozen.loadFromString(serialized.substring(3)); + } catch (Exception failure) { + throw new IllegalStateException("Persisted item reward payload is invalid", failure); + } + ItemStack item = frozen.getItemStack("Item"); + if (item == null) throw new IllegalStateException("Persisted item reward payload is missing"); + user.giveItem(item); + return item; + } + + private static String serializeItem(ItemStack item) { + if (item == null) return null; + YamlConfiguration frozen = new YamlConfiguration(); + frozen.set("Item", item); + return "v1:" + frozen.saveToString(); } private static RewardInjectValidator itemValidator() { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index db4ef67a00..9c044bd69b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -29,9 +29,12 @@ import java.util.function.BiConsumer; import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Answers; @@ -527,11 +530,26 @@ public void messagesActuallySendPlayerAndBroadcastForms() { @Test public void itemRewardsActuallyGiveBuiltItems() { - try (MockedConstruction builders = mockConstruction(ItemBuilder.class, - withSettings().defaultAnswer(Answers.RETURNS_SELF))) { + when(user.getPlayer()).thenReturn(mock(Player.class)); + ItemStack frozenStack = mock(ItemStack.class); + when(frozenStack.serialize()).thenReturn(java.util.Map.of("v", 0, "type", "STONE", "amount", 2)); + org.bukkit.UnsafeValues unsafe = mock(org.bukkit.UnsafeValues.class); + when(unsafe.getMaterial("STONE", 0)).thenReturn(Material.STONE); + org.bukkit.inventory.ItemFactory itemFactory = mock(org.bukkit.inventory.ItemFactory.class); + when(itemFactory.equals(any(), any())).thenReturn(true); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedConstruction builders = mockConstruction(ItemBuilder.class, + withSettings().defaultAnswer(Answers.RETURNS_SELF), + (builder, context) -> when(builder.toItemStack(any(Player.class))) + .thenReturn(frozenStack))) { + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + bukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); RewardItems.registerItem(handler, plugin); configInject(0).onRewardRequested(reward, user, section("Item"), placeholders); - verify(user).giveItem(any(ItemBuilder.class)); + int buildersAfterItem = builders.constructed().size(); + configInject(0).onRewardRequested(reward, user, section("Item"), placeholders); + assertEquals(buildersAfterItem, builders.constructed().size()); + verify(user, org.mockito.Mockito.times(2)).giveItem(any(ItemStack.class)); injects.clear(); RewardItems.registerRandomItem(handler, plugin); @@ -540,18 +558,23 @@ public void itemRewardsActuallyGiveBuiltItems() { String selected = ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, random.getKeys(false), random, placeholders); assertEquals("OnlyItem", selected); + int buildersBeforeReplay = builders.constructed().size(); random.set("OnlyItem", null); random.createSection("OtherItem"); - assertThrows(IllegalStateException.class, () -> ((RewardInjectKeys) injects.get(0)) - .onRewardRequested(reward, user, java.util.Set.of("OtherItem"), random, placeholders)); - verify(user, atLeastOnce()).giveItem(any(ItemBuilder.class)); + assertEquals("OnlyItem", ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, + java.util.Set.of("OtherItem"), random, placeholders)); + assertEquals(buildersBeforeReplay, builders.constructed().size()); + verify(user, atLeastOnce()).giveItem(any(ItemStack.class)); injects.clear(); RewardItems.registerItems(handler, plugin); ConfigurationSection items = section("Items"); items.createSection("FirstItem"); ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, items.getKeys(false), items, placeholders); - verify(user, atLeastOnce()).giveItem(any(ItemBuilder.class)); + int buildersAfterItems = builders.constructed().size(); + ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, items.getKeys(false), items, placeholders); + assertEquals(buildersAfterItems, builders.constructed().size()); + verify(user, atLeastOnce()).giveItem(any(ItemStack.class)); assertTrue(builders.constructed().size() >= 3); } } 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 4a47e2954c..d82ae59f7b 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 @@ -310,12 +310,19 @@ void replayItemsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); data.createSection("Items").createSection("Stone").set("Material", "STONE"); RewardItems.registerItems(handler, plugin); + org.bukkit.UnsafeValues unsafe = mock(org.bukkit.UnsafeValues.class); + when(unsafe.getMaterial(any(String.class), eq(0))) + .thenAnswer(invocation -> Material.matchMaterial(invocation.getArgument(0, String.class))); + org.bukkit.inventory.ItemFactory itemFactory = mock(org.bukkit.inventory.ItemFactory.class); + when(itemFactory.equals(any(), any())).thenReturn(true); try (MockedConstruction builders = mockConstruction(ItemBuilder.class, (builder, context) -> { when(builder.setPlaceholders(any(HashMap.class))).thenReturn(builder); when(builder.toItemStack(any(Player.class))).thenAnswer(ignored -> new ItemStack(itemType.get())); }); org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { bukkit.when(() -> Bukkit.getPlayer(uuid)).thenAnswer(ignored -> availablePlayer.get()); + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + bukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); CompletionStage delivery = reward.giveRewardUserAsync(realUser, new HashMap<>(), new RewardOptions()); assertFalse(delivery.toCompletableFuture().isDone(), "the initial online check only queues injection dispatch"); assertNotNull(queuedInjection.get()); @@ -327,7 +334,7 @@ void replayItemsRemainPendingWhenThePlayerDisconnectsBeforeTheirInjectionRuns() delivery.toCompletableFuture().join(); }); Reward.RewardReplayFailure checkpoint = findCheckpoint(failure); - assertEquals(0, checkpoint.getCompletedInjectionCount()); + assertEquals(0, checkpoint.getCompletedInjectionCount(), checkpoint.getReplayProgress().toString()); assertEquals(0, checkpoint.getReplayProgress().getOrDefault("AsyncReward", 0)); assertTrue(checkpoint.getReplayPlaceholders().entrySet().stream().noneMatch(entry -> entry.getKey().startsWith("__advancedcore_replay_legacy_actions_") @@ -554,6 +561,18 @@ void offlineRequeueRetainsReplayStateAndLegacyActionMarkers() { .get("__advancedcore_replay_legacy_actions_marker")); } + @Test + void durableReplayFailsWhenProcessingIsDisabledBeforeDispatch() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(false); + when(plugin.getOptions()).thenReturn(config); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> { }); + + assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveRewardAsync(user, options).toCompletableFuture().join()); + } + @Test void timedReplayDeferralRemainsInTheTimedQueue() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); From 5172e36e059081617b81482577e1460fa2410ad3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:49:03 -0600 Subject: [PATCH 76/78] Preserve item chance decisions on replay --- .../api/rewards/builtin/RewardItems.java | 27 +++++++++----- .../rewards/BuiltinRewardBehaviorTest.java | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java index 20db78c611..eeda55541a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardItems.java @@ -90,13 +90,13 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Set { + ReplayedItem selected = replayItem(user, placeholders, "payload:" + item, () -> { ItemBuilder builder = new ItemBuilder(data.getConfigurationSection(item)); builder.setCheckLoreLength(false); return builder.setPlaceholders(placeholders); }); - debug("Giving item " + item + ":" + selected); - if (selected != null && oneChance) { + debug("Giving item " + item + ":" + selected.item()); + if (selected.chancePassed() && oneChance) { return item; } } @@ -122,19 +122,23 @@ public void setVal(String key, Object value) { }.addLore("Edit items")))); } - private static ItemStack replayItem(AdvancedCoreUser user, HashMap placeholders, String lane, + private static ReplayedItem replayItem(AdvancedCoreUser user, HashMap placeholders, String lane, Supplier builder) { org.bukkit.entity.Player player = user.getPlayer(); if (player == null) { // Preserve the legacy deferred-delivery path. Durable replay records this // action as not started if the player remains unavailable, so a later retry // may safely build and freeze the payload before its first side effect. - user.giveItem(builder.get()); - return null; + ItemBuilder deferred = builder.get(); + user.giveItem(deferred); + return new ReplayedItem(null, deferred.isChancePass()); } String serialized = Reward.replaySelection(placeholders, lane, - () -> serializeItem(builder.get().toItemStack(player))); - if (serialized == null) return null; + () -> { + ItemBuilder selected = builder.get(); + return serializeItem(selected.toItemStack(player), selected.isChancePass()); + }); + if (serialized == null) return new ReplayedItem(null, false); if (!serialized.startsWith("v1:")) { throw new IllegalStateException("Persisted item reward payload has an unsupported format"); } @@ -147,16 +151,19 @@ private static ItemStack replayItem(AdvancedCoreUser user, HashMap 0)); } - private static String serializeItem(ItemStack item) { + private static String serializeItem(ItemStack item, boolean chancePassed) { if (item == null) return null; YamlConfiguration frozen = new YamlConfiguration(); frozen.set("Item", item); + frozen.set("ChancePassed", chancePassed); return "v1:" + frozen.saveToString(); } + private record ReplayedItem(ItemStack item, boolean chancePassed) { } + private static RewardInjectValidator itemValidator() { return new RewardInjectValidator() { @Override diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java index 9c044bd69b..6e46f2dd47 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/BuiltinRewardBehaviorTest.java @@ -579,6 +579,43 @@ public void itemRewardsActuallyGiveBuiltItems() { } } + @Test + public void onlyOneItemChanceReplaysFailedRollBeforeSuccessfulRoll() { + when(user.getPlayer()).thenReturn(mock(Player.class)); + when(reward.getConfig().getConfigData().getBoolean("OnlyOneItemChance", false)).thenReturn(true); + ItemStack frozenStack = mock(ItemStack.class); + when(frozenStack.serialize()).thenReturn(java.util.Map.of("v", 0, "type", "STONE", "amount", 1)); + org.bukkit.UnsafeValues unsafe = mock(org.bukkit.UnsafeValues.class); + when(unsafe.getMaterial("STONE", 0)).thenReturn(Material.STONE); + org.bukkit.inventory.ItemFactory itemFactory = mock(org.bukkit.inventory.ItemFactory.class); + when(itemFactory.equals(any(), any())).thenReturn(true); + java.util.concurrent.atomic.AtomicInteger construction = new java.util.concurrent.atomic.AtomicInteger(); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedConstruction builders = mockConstruction(ItemBuilder.class, + withSettings().defaultAnswer(Answers.RETURNS_SELF), (builder, context) -> { + when(builder.toItemStack(any(Player.class))).thenReturn(frozenStack); + if (!context.arguments().isEmpty() + && context.arguments().get(0) instanceof ConfigurationSection) { + when(builder.isChancePass()).thenReturn(construction.getAndIncrement() == 1); + } + })) { + bukkit.when(Bukkit::getUnsafe).thenReturn(unsafe); + bukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); + RewardItems.registerItems(handler, plugin); + ConfigurationSection items = section("Items"); + items.createSection("First"); + items.createSection("Second"); + java.util.Set ordered = new java.util.LinkedHashSet<>(java.util.List.of("First", "Second")); + + assertEquals("Second", ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, + ordered, items, placeholders)); + assertEquals(2, construction.get()); + assertEquals("Second", ((RewardInjectKeys) injects.get(0)).onRewardRequested(reward, user, + ordered, items, placeholders)); + assertEquals(2, construction.get(), "replay must reuse both frozen chance decisions"); + } + } + @Test public void fireworkActuallyLaunchesConfiguredFirework() { RewardFirework.register(handler, plugin); From 53d4dd209f88bab679562e707073e62e30831529 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:05:02 -0600 Subject: [PATCH 77/78] Keep deferred child occurrences distinct --- .../advancedcore/api/rewards/Reward.java | 13 ++++++- .../tests/user/AdvancedCoreUserTest.java | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) 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 2f72691a0a..5ec245f330 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java @@ -1039,7 +1039,18 @@ public static RewardOptions withReplayState(RewardOptions options, ReplayState r public static RewardOptions withReplayState(RewardOptions options, ReplayState replayState, String parentKey, String childOccurrence, String occurrenceId) { if (replayState != null) options.setAsyncReplayState(replayState); - if (occurrenceId != null) options.setAsyncReplayOccurrenceId(occurrenceId); + if (occurrenceId != null) { + String effectiveOccurrence = occurrenceId; + // Fresh nested children can each be deferred as independent offline queue + // entries. Give those siblings stable distinct identities so adding the next + // child cannot replace the previous one. A durable parent keeps its shared + // occurrence because its checkpoint owns and resumes the complete sequence. + if (childOccurrence != null && (replayState == null || !replayState.hasCheckpointConsumer())) { + String childKey = parentKey == null ? childOccurrence : parentKey + "/" + childOccurrence; + effectiveOccurrence = replaySideEffectOccurrenceId(occurrenceId, childKey); + } + options.setAsyncReplayOccurrenceId(effectiveOccurrence); + } if (parentKey != null && childOccurrence != null) { options.setAsyncReplayKey(parentKey + "/" + childOccurrence); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 3c1301bfeb..7288548ddd 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -334,6 +335,39 @@ void deferredReplayAtomicallyReplacesItsExistingOccurrence() { assertTrue(persisted.get(0).contains("Server%pair%server-a")); } + @Test + void independentlyDeferredNestedChildrenKeepDistinctQueueEntries() { + ArrayList persisted = new ArrayList<>(); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)) + .thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { + persisted.clear(); + persisted.addAll(invocation.getArgument(1)); + return null; + }).when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); + when(reward.getRewardName()).thenReturn("ChildReward"); + Reward.ReplayState replayState = Reward.replayStateFor(new RewardOptions()); + RewardOptions first = Reward.withReplayState(new RewardOptions(), replayState, + "Parent/AdvancedRewards", "First:0", "parent-occurrence"); + RewardOptions second = Reward.withReplayState(new RewardOptions(), replayState, + "Parent/AdvancedRewards", "Second:1", "parent-occurrence"); + + user.addOfflineRewards(reward, first.getPlaceholders(), first); + user.addOfflineRewards(reward, second.getPlaceholders(), second); + + assertEquals(2, persisted.size()); + assertNotEquals(first.getAsyncReplayOccurrenceId(), second.getAsyncReplayOccurrenceId()); + assertTrue(persisted.get(0).contains("%asyncoccurrence%" + first.getAsyncReplayOccurrenceId())); + assertTrue(persisted.get(1).contains("%asyncoccurrence%" + second.getAsyncReplayOccurrenceId())); + + RewardOptions durableRoot = new RewardOptions(); + durableRoot.setAsyncReplayCheckpointConsumer(ignored -> { }); + Reward.ReplayState durableState = Reward.replayStateFor(durableRoot); + assertEquals("parent-occurrence", Reward.withReplayState(new RewardOptions(), durableState, + "Parent/AdvancedRewards", "First:0", "parent-occurrence").getAsyncReplayOccurrenceId()); + } + @Test void replayClaimCleanupRechecksSharedStateUnderItsLock() throws Exception { java.lang.reflect.Method replayClaims = AdvancedCoreUser.class.getDeclaredMethod("replayClaims"); From f225cd4431c491388ff51b7d02fbdf88bf961700 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:14:06 -0600 Subject: [PATCH 78/78] Make temporary permissions replay idempotent --- .../api/user/AdvancedCoreUser.java | 6 ++- .../rewards/RewardAsyncInjectionTest.java | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) 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 26230f5837..b6a3e2a352 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java @@ -649,7 +649,11 @@ public void addPermission(String permission) { * @param delay the delay in milliseconds */ public void addPermission(String permission, long delay) { - plugin.getPermissionHandler().addPermission(getPlayer(), permission, delay); + Player player = getPlayer(); + if (scheduleOwnedPlayerAction(player, + () -> plugin.getPermissionHandler().addPermission(player, permission, delay), + "temporary-permission:" + permission + ":" + delay)) return; + plugin.getPermissionHandler().addPermission(player, permission, delay); } /** 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 d82ae59f7b..d8683af4a4 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 @@ -1140,6 +1140,48 @@ void emptyLegacyActionCollectionDoesNotCreateItsOwnCheckpoint() { .anyMatch(key -> key.startsWith("__advancedcore_replay_legacy_actions_"))); } + @Test + void temporaryPermissionIsGrantedOnceAcrossReplayOfItsInjection() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isOnlineMode()).thenReturn(true); + when(plugin.getOptions()).thenReturn(config); + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + when(plugin.isEnabled()).thenReturn(true); + com.bencodez.advancedcore.api.permissions.PermissionHandler permissions = + mock(com.bencodez.advancedcore.api.permissions.PermissionHandler.class); + when(plugin.getPermissionHandler()).thenReturn(permissions); + UUID uuid = UUID.randomUUID(); + AdvancedCoreUser realUser = new AdvancedCoreUser(plugin, uuid, false, false); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + RewardOptions options = new RewardOptions(); + options.setAsyncReplayCheckpointConsumer(ignored -> { }); + Reward.ReplayState replayState = Reward.replayStateFor(options); + HashMap placeholders = new HashMap<>(); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + for (int attempt = 0; attempt < 2; attempt++) { + AdvancedCoreUser.AsyncActionCollection collection = realUser.beginAsyncActionCollection( + replayState, placeholders, "AsyncReward/TempPermission"); + realUser.addPermission("advancedcore.replay", 90); + realUser.endAsyncActionCollection(collection).toCompletableFuture().join(); + } + } + + verify(permissions).addPermission(player, "advancedcore.replay", 90); + } + @Test void serializedPersistedReplaysDoNotClaimUnscopedContinuationActions() throws Exception { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class);