placeholders) {
+ String placeholderName = inject.getPlaceholderName();
+ String value = "";
+ if (obj instanceof Boolean) {
+ value = obj.toString();
+ } else if (obj instanceof String) {
+ value = (String) obj;
+ } else if (obj instanceof Double) {
+ value = obj.toString();
+ } else if (obj instanceof Integer) {
+ value = obj.toString();
+ }
+ plugin.extraDebug("Adding placeholder " + placeholderName + ":" + value);
+ placeholders.put(placeholderName, value);
+ }
+
+ private boolean hasAsyncRewardInjection() {
+ for (RewardInject inject : plugin.getRewardHandler().getInjectedRewards()) {
+ if (inject.supportsAsyncRequest()
+ && (!inject.requiresConfiguredDataForAsync() || isRewardInjectionApplicable(inject))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isRewardInjectionApplicable(RewardInject inject) {
+ ConfigurationSection data = getConfig().getConfigData();
+ return inject.isAlwaysForceNoData() || data.contains(inject.getPath(), true);
+ }
+
public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) {
- if (!AdvancedCorePlugin.getInstance().getOptions().isProcessRewards()) {
- AdvancedCorePlugin.getInstance().debug("Processing rewards is disabled");
+ if (!plugin.getOptions().isProcessRewards()) {
+ plugin.debug("Processing rewards is disabled");
return;
}
@@ -322,7 +1438,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) {
if (plugin.getOptions().isPauseRewards()) {
checkRewardFile();
- user.addOfflineRewards(this, rewardOptions.getPlaceholders());
+ preserveReplayState(rewardOptions);
+ user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions);
plugin.getLogger()
.info("Rewards are paused, saving offline reward " + getRewardName() + ": " + user.getPlayerName());
return;
@@ -330,7 +1447,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) {
if ((plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) {
checkRewardFile();
- user.addOfflineRewards(this, rewardOptions.getPlaceholders());
+ preserveReplayState(rewardOptions);
+ user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions);
plugin.getLogger()
.info(getRewardName() + ": " + user.getPlayerName() + " is vanished, saving reward offline");
return;
@@ -341,7 +1459,8 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) {
&& (!isForceOffline() && !rewardOptions.isForceOffline()))) {
if (rewardOptions.isGiveOffline()) {
checkRewardFile();
- user.addOfflineRewards(this, rewardOptions.getPlaceholders());
+ preserveReplayState(rewardOptions);
+ user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions);
plugin.debug("Saving offline reward " + getRewardName() + " for " + user.getPlayerName());
}
return;
@@ -355,6 +1474,154 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) {
}
}
+ /**
+ * Gives this reward and completes when an asynchronous injection chain has
+ * finished. This is used by nested reward injectors so a child reward cannot
+ * overtake later parent post-reward injections.
+ *
+ * The established void API deliberately remains fire-and-forget. Callers
+ * that need ordering or durable replay semantics must use this method.
+ *
+ * @param user receiving user
+ * @param rewardOptions reward options
+ * @return completion stage for the complete reward injection chain
+ */
+ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) {
+ boolean primaryThread = false;
+ try {
+ primaryThread = Bukkit.isPrimaryThread();
+ } catch (IllegalStateException | NullPointerException ignored) {
+ // Early bootstrap and unit tests may not have an installed Bukkit server.
+ }
+ if (!primaryThread) return giveRewardAsyncOffPrimary(user, rewardOptions);
+
+ CompletableFuture> handoff = new CompletableFuture<>();
+ try {
+ RewardOptions requestedOptions = rewardOptions;
+ plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> {
+ CompletableFuture result = new CompletableFuture<>();
+ // Claim before invoking reward code. A timed-out task that Bukkit later
+ // runs must not produce side effects for a caller that already retries.
+ if (!handoff.complete(result)) return;
+ try {
+ CompletionStage stage = giveRewardAsyncOffPrimary(user, requestedOptions);
+ if (stage == null) {
+ result.completeExceptionally(new IllegalStateException(
+ "Reward dispatch returned no completion stage"));
+ return;
+ }
+ stage.whenComplete((ignored, failure) -> {
+ if (failure == null) result.complete(null);
+ else result.completeExceptionally(failure);
+ });
+ } catch (Throwable failure) {
+ result.completeExceptionally(failure);
+ }
+ });
+ } catch (Throwable failure) {
+ handoff.completeExceptionally(failure);
+ }
+ return handoff.orTimeout(getServerThreadDispatchTimeoutMillis(), TimeUnit.MILLISECONDS)
+ .thenCompose(stage -> stage);
+ }
+
+ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, RewardOptions rewardOptions) {
+ if (!plugin.getOptions().isProcessRewards()) {
+ plugin.debug("Processing rewards is disabled");
+ if (isDurableReplay(rewardOptions)) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("Reward processing was disabled before queued delivery"));
+ }
+ return CompletableFuture.completedFuture(null);
+ }
+
+ if (rewardOptions == null) rewardOptions = new RewardOptions();
+ if (!rewardOptions.getPlaceholders().containsKey("ExecDate")) {
+ rewardOptions.addPlaceholder("ExecDate", "" + System.currentTimeMillis());
+ }
+ if (!rewardOptions.getPlaceholders().containsKey("date")) {
+ try {
+ LocalDateTime ldt = LocalDateTime.now();
+ Date date = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
+ rewardOptions.addPlaceholder("Date", "" + new SimpleDateFormat(
+ plugin.getOptions().getFormatRewardTimeFormat()).format(date));
+ } catch (Exception e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ PlayerRewardEvent event = new PlayerRewardEvent(this, user, rewardOptions);
+ Bukkit.getPluginManager().callEvent(event);
+ if (event.isCancelled()) {
+ plugin.debug("Reward " + name + " was cancelled for " + user.getPlayerName());
+ return CompletableFuture.completedFuture(null);
+ }
+ if (rewardOptions.isCheckTimed() && (checkDelayed(user, rewardOptions.getPlaceholders())
+ || checkTimed(user, rewardOptions.getPlaceholders()))) {
+ return CompletableFuture.completedFuture(null);
+ }
+ if (!rewardOptions.isOnlineSet()) rewardOptions.setOnline(user.isOnline());
+ for (RewardPlaceholderHandle handle : plugin.getRewardHandler().getPlaceholders()) {
+ if (handle.isPreProcess()) rewardOptions.addPlaceholder(handle.getKey(), handle.getValue(this, user));
+ }
+
+ boolean allowOffline = false;
+ boolean canGive = true;
+ if (!rewardOptions.isIgnoreRequirements()) {
+ for (RequirementInject inject : plugin.getRewardHandler().getInjectedRequirements()) {
+ try {
+ if (!inject.onRequirementRequest(this, user, getConfig().getConfigData(), rewardOptions)) {
+ canGive = false;
+ if (!inject.isAllowReattempt()) return CompletableFuture.completedFuture(null);
+ allowOffline = true;
+ }
+ } catch (Exception e) {
+ plugin.debug("Failed to check requirement " + inject.getPath());
+ e.printStackTrace();
+ if (isDurableReplay(rewardOptions)) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("Failed to evaluate reward requirement " + inject.getPath(), e));
+ }
+ canGive = false;
+ }
+ }
+ }
+ if (plugin.getOptions().isPauseRewards() || (plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) {
+ checkRewardFile();
+ preserveReplayState(rewardOptions);
+ return deferRewardAsync(user, rewardOptions);
+ }
+ if (((((!rewardOptions.isOnline() || rewardOptions.getServer() != null) && !user.isOnline()) || allowOffline)
+ && (!isForceOffline() && !rewardOptions.isForceOffline()))) {
+ if (rewardOptions.isGiveOffline()) {
+ checkRewardFile();
+ preserveReplayState(rewardOptions);
+ return deferRewardAsync(user, rewardOptions);
+ }
+ return CompletableFuture.completedFuture(null);
+ }
+ if (canGive || isForceOffline() || rewardOptions.isForceOffline()) {
+ plugin.debug(name + ": Passed requirements, attempting to give to " + user.getPlayerName() + "/"
+ + user.getUUID());
+ return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions);
+ }
+ return CompletableFuture.completedFuture(null);
+ }
+
+ private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) {
+ if (rewardOptions.isTimedQueueReplay()) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("Timed reward replay remains deferred in its original queue"));
+ }
+ if (isDurableReplay(rewardOptions) && rewardOptions.getAsyncReplayCheckpointConsumer() == null) {
+ return CompletableFuture.failedFuture(new OfflineReplayDeferredException());
+ }
+ user.addOfflineRewards(this, rewardOptions.getPlaceholders(), rewardOptions);
+ return rewardOptions.getAsyncReplayCheckpointConsumer() == null
+ ? CompletableFuture.completedFuture(null)
+ : CompletableFuture.failedFuture(new OfflineReplayDeferredException());
+ }
+
/**
* Give reward user.
*
@@ -363,6 +1630,84 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) {
* @param rewardOptions rewardOptions
*/
public void giveRewardUser(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) {
+ RewardOptions effectiveOptions = rewardOptions == null ? new RewardOptions() : rewardOptions;
+ if (requiresAwaitedDelivery(effectiveOptions)) {
+ giveRewardUserAsync(user, phs, effectiveOptions).exceptionally(failure -> {
+ logRewardUserFailure(failure);
+ return null;
+ });
+ return;
+ }
+
+ HashMap placeholders = prepareRewardUser(user, phs);
+ if (placeholders == null) {
+ return;
+ }
+ giveInjectedRewards(user, placeholders);
+ plugin.debug("Gave " + user.getPlayerName() + " reward " + name);
+ }
+
+ /**
+ * Asynchronously gives a reward to a user when an injection opts into the
+ * asynchronous API. Preparation remains on the calling thread; only the
+ * opted-in injection chain is asynchronous.
+ *
+ * @param user receiving user
+ * @param phs placeholders
+ * @param rewardOptions reward options (reserved for API symmetry)
+ * @return completion stage that completes after reward injections finish
+ */
+ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap phs,
+ RewardOptions rewardOptions) {
+ ReplayState replayState = replayStateFor(rewardOptions);
+ String replayKey = rewardOptions.getAsyncReplayKey();
+ if (replayKey == null) {
+ String parentKey = ACTIVE_REPLAY_KEY.get();
+ replayKey = parentKey == null ? getRewardName() : parentKey + "/" + getRewardName();
+ }
+ if (!replayState.matchesRegistryFingerprint(injectionRegistryFingerprint(orderedInjectedRewards()))) {
+ return CompletableFuture.failedFuture(new IncompatibleReplayCheckpointException(replayKey));
+ }
+ String occurrenceId = rewardOptions.getAsyncReplayOccurrenceId();
+ if (occurrenceId == null || occurrenceId.isEmpty()) {
+ occurrenceId = ACTIVE_REPLAY_OCCURRENCE_ID.get();
+ // A pre-occurrence persisted entry cannot safely survive an ambiguous
+ // side effect, so leave it on the legacy compatibility path rather than
+ // inventing a different id for each retry.
+ if (occurrenceId == null && rewardOptions.getAsyncReplayCheckpointConsumer() == null) {
+ occurrenceId = UUID.randomUUID().toString();
+ }
+ }
+ final HashMap placeholders;
+ try {
+ placeholders = prepareRewardUser(user, phs);
+ } catch (Throwable throwable) {
+ return CompletableFuture.failedFuture(throwable);
+ }
+ if (placeholders == null) {
+ if (isDurableReplay(rewardOptions) || hasPersistedReplayCheckpoint(rewardOptions)) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("Player became unavailable before persisted reward replay"));
+ }
+ return CompletableFuture.completedFuture(null);
+ }
+ return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, replayKey,
+ occurrenceId)
+ .thenRun(() -> plugin.debug("Gave " + user.getPlayerName() + " reward " + name));
+ }
+
+ private static boolean hasPersistedReplayCheckpoint(RewardOptions options) {
+ return options.isLegacyAsyncReplayCheckpoint() || options.getCompletedAsyncInjections() > 0
+ || !options.getAsyncReplayProgress().isEmpty() || !options.getAsyncReplayRegistryFingerprints().isEmpty()
+ || (options.getAsyncReplayState() != null && options.getAsyncReplayState().hasPersistedCheckpoint());
+ }
+
+ private boolean requiresAwaitedDelivery(RewardOptions options) {
+ return options.getAsyncReplayCheckpointConsumer() != null || hasAsyncRewardInjection()
+ || hasPersistedReplayCheckpoint(options);
+ }
+
+ private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap phs) {
Player player = user.getPlayer();
if (player == null) {
@@ -391,14 +1736,19 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, R
}
}
- final HashMap placeholders = new HashMap<>(phs);
-
- giveInjectedRewards(user, placeholders);
-
- plugin.debug("Gave " + user.getPlayerName() + " reward " + name);
+ return new HashMap<>(phs);
} else {
plugin.debug(getRewardName() + ": Player == null & forceoffline false, player: " + user.getPlayerName()
+ "/" + user.getUUID());
+ return null;
+ }
+ }
+
+ private void logRewardUserFailure(Throwable failure) {
+ if (plugin.getLogger() != null) {
+ plugin.getLogger().log(Level.WARNING, "Failed to give reward " + getRewardName(), failure);
+ } else {
+ failure.printStackTrace();
}
}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java
index 0c16f10e0a..21a134e719 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardBuilder.java
@@ -6,6 +6,8 @@
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
import org.bukkit.OfflinePlayer;
import org.bukkit.configuration.ConfigurationSection;
@@ -89,6 +91,16 @@ public void send(AdvancedCoreUser user) {
}
}
+ /** Sends this builder's reward and completes when its async injection chain finishes. */
+ public CompletionStage sendAsync(AdvancedCoreUser user) {
+ RewardHandler handler = user.getPlugin().getRewardHandler();
+ if (reward == null) {
+ return data == null ? CompletableFuture.completedFuture(null)
+ : handler.giveRewardAsync(user, data, path, rewardOptions);
+ }
+ return handler.giveRewardAsync(user, reward, rewardOptions);
+ }
+
public void send(AdvancedCoreUser... users) {
for (AdvancedCoreUser user : users) {
send(user);
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java
index 9ec68eb337..cb492b4649 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardExecutor.java
@@ -3,6 +3,10 @@
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.TimeUnit;
import org.bukkit.Bukkit;
import org.bukkit.configuration.Configuration;
@@ -101,6 +105,65 @@ public void giveReward(AdvancedCoreUser user, ConfigurationSection data, String
}
}
+ /**
+ * Asynchronous counterpart for nested rewards that must preserve injection
+ * ordering. The legacy void dispatch methods intentionally remain unchanged.
+ */
+ public CompletionStage giveRewardAsync(AdvancedCoreUser user, ConfigurationSection data, String path,
+ RewardOptions rewardOptions) {
+ RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user);
+ RewardOptions options = context.getOptions();
+ if (path == null || data == null) return CompletableFuture.completedFuture(null);
+ if (!plugin.isEnabled()) return disabledDispatch();
+
+ Reward.ReplayState replayState = Reward.replayStateFor(options);
+ if (options.getAsyncReplayOccurrenceId() == null) {
+ String activeOccurrenceId = Reward.currentReplayOccurrenceId();
+ if (activeOccurrenceId != null) options.setAsyncReplayOccurrenceId(activeOccurrenceId);
+ }
+ String parentReplayKey = options.getAsyncReplayKey();
+ if (parentReplayKey == null) parentReplayKey = Reward.currentReplayKey();
+ if (parentReplayKey == null) parentReplayKey = "list:" + path;
+ String nestedLane = "nested-list:" + path;
+ if (data.isList(path)
+ || Reward.hasReplayNestedRewardSnapshot(options.getPlaceholders(), nestedLane, replayState,
+ parentReplayKey)) {
+ final String stableParentReplayKey = parentReplayKey;
+ return Reward.replayNestedRewardSequence(plugin, options.getPlaceholders(), nestedLane,
+ data.isList(path) ? new ArrayList<>(data.getStringList(path)) : java.util.List.of(), replayState,
+ stableParentReplayKey, (nestedReward, nestedIndex) -> {
+ // Clone only when this child is actually reached. This lets
+ // metadata from earlier children be merged before a later
+ // child receives its isolated ordinary placeholders.
+ RewardOptions nestedOptions = options.copyForNestedDispatch(
+ stableParentReplayKey + "/" + nestedReward + ":" + nestedIndex);
+ nestedOptions.setAsyncReplayState(replayState);
+ return Reward.continueOnServerThread(plugin, user,
+ () -> giveRewardAsync(user, nestedReward, nestedOptions)).handle((childResult, failure) -> {
+ // Child options stay isolated for normal placeholders. Replay
+ // markers are shared lazily so an earlier child remains durable
+ // when a later child fails and the list is restarted.
+ if (Reward.isDurableReplay(options)) {
+ replayState.mergeReplayMetadataInto(options.getPlaceholders());
+ }
+ if (failure != null) throw new CompletionException(failure);
+ return childResult;
+ });
+ });
+ }
+ if (data.isConfigurationSection(path)) {
+ return giveSectionRewardAsync(user, data, path, context);
+ }
+ String nestedReward = data.getString(path, "");
+ if (nestedReward.isEmpty()) {
+ return Reward.isDurableReplay(options)
+ ? CompletableFuture.failedFuture(
+ new IllegalStateException("Nested replay configuration could not be resolved: " + path))
+ : CompletableFuture.completedFuture(null);
+ }
+ return giveRewardAsync(user, nestedReward, options);
+ }
+
public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) {
RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user);
if (reward == null) {
@@ -108,7 +171,14 @@ public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewar
return;
}
- if (Bukkit.isPrimaryThread()) {
+ boolean primaryThread = false;
+ try {
+ primaryThread = Bukkit.isPrimaryThread();
+ } catch (IllegalStateException | NullPointerException ignored) {
+ // Unit tests and early bootstrap can call this facade before Bukkit has
+ // installed a server. There is no primary server thread to leave then.
+ }
+ if (primaryThread) {
plugin.getBukkitScheduler().runTaskAsynchronously(plugin,
() -> reward.giveReward(user, context.getOptions()));
} else {
@@ -116,6 +186,66 @@ public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewar
}
}
+ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) {
+ if (reward == null) return CompletableFuture.completedFuture(null);
+ if (!plugin.isEnabled()) return disabledDispatch();
+ RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user);
+ RewardOptions options = context.getOptions();
+ Reward.ReplayState activeState = Reward.currentReplayState();
+ if (options.getAsyncReplayState() == null && activeState != null) options.setAsyncReplayState(activeState);
+ String activeKey = Reward.currentReplayKey();
+ if (options.getAsyncReplayKey() == null && activeKey != null) {
+ options.setAsyncReplayKey(activeKey + "/" + reward.getRewardName());
+ }
+ String activeOccurrenceId = Reward.currentReplayOccurrenceId();
+ if (options.getAsyncReplayOccurrenceId() == null && activeOccurrenceId != null) {
+ options.setAsyncReplayOccurrenceId(activeOccurrenceId);
+ }
+ // PlayerRewardEvent is explicitly asynchronous, so Bukkit rejects it when
+ // a nested reward reaches this dispatcher from an owner thread. Hand only
+ // reward setup to the async scheduler; injectors marshal their player/world
+ // work back to the appropriate owner thread and the flattened stage keeps
+ // the parent replay waiting for the complete child reward.
+ boolean primaryThread = false;
+ try {
+ primaryThread = Bukkit.isPrimaryThread();
+ } catch (IllegalStateException | NullPointerException ignored) {
+ // Unit tests and early bootstrap can call this facade before Bukkit has
+ // installed a server. There is no primary server thread to leave then.
+ }
+ if (!primaryThread) return reward.giveRewardAsync(user, options);
+
+ CompletableFuture> handoff = new CompletableFuture<>();
+ try {
+ plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> {
+ CompletableFuture result = new CompletableFuture<>();
+ if (!handoff.complete(result)) return;
+ try {
+ CompletionStage stage = reward.giveRewardAsync(user, options);
+ if (stage == null) {
+ result.completeExceptionally(new IllegalStateException(
+ "Reward dispatch returned no completion stage"));
+ return;
+ }
+ stage.whenComplete((ignored, failure) -> {
+ if (failure == null) result.complete(null);
+ else result.completeExceptionally(failure);
+ });
+ } catch (Throwable failure) {
+ result.completeExceptionally(failure);
+ }
+ });
+ } catch (Throwable failure) {
+ handoff.completeExceptionally(failure);
+ }
+ return handoff.orTimeout(getPrimaryThreadHandoffTimeoutMillis(), TimeUnit.MILLISECONDS)
+ .thenCompose(stage -> stage);
+ }
+
+ protected long getPrimaryThreadHandoffTimeoutMillis() {
+ return TimeUnit.SECONDS.toMillis(30);
+ }
+
public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) {
RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user);
if (reward == null || reward.isEmpty()) {
@@ -130,6 +260,25 @@ public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewar
giveReward(user, handler.getReward(reward), context.getOptions());
}
+ public CompletionStage giveRewardAsync(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) {
+ RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user);
+ if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null);
+ if (!plugin.isEnabled()) return disabledDispatch();
+ if (reward.startsWith("/")) {
+ RewardOptions options = context.getOptions();
+ return Reward.replayCommandSequence(plugin, context.getPlaceholders(), "direct", java.util.List.of(reward),
+ Reward.replayStateFor(options), options.getAsyncReplayKey(),
+ (command, ignoredIndex) -> MiscUtils.getInstance().executeConsoleCommandsAsync(
+ user.getPlayerName(), command, context.getPlaceholders()));
+ }
+ Reward resolved = handler.getReward(reward);
+ if (resolved == null && Reward.isDurableReplay(context.getOptions())) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("Nested replay reward could not be resolved: " + reward));
+ }
+ return giveRewardAsync(user, resolved, context.getOptions());
+ }
+
public void givePersistedQueueReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) {
RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user);
if (reward == null || reward.isEmpty()) {
@@ -177,6 +326,53 @@ public void givePersistedQueueReward(AdvancedCoreUser user, String reward, Rewar
giveReward(user, resolved, context.getOptions());
}
+ /** Resolves and awaits a persisted queue item so a failed async injection can be requeued. */
+ public CompletionStage givePersistedQueueRewardAsync(AdvancedCoreUser user, String reward,
+ RewardOptions rewardOptions) {
+ RewardExecutionContext context = new RewardExecutionContext(rewardOptions).initializeOnlineState(user);
+ if (reward == null || reward.isEmpty()) return CompletableFuture.completedFuture(null);
+ if (!plugin.isEnabled()) return disabledDispatch();
+
+ String rewardName = reward;
+ Boolean generatedSnapshot = null;
+ if (reward.startsWith(QUEUED_REFERENCE_PREFIX)) {
+ String encoded = reward.substring(QUEUED_REFERENCE_PREFIX.length());
+ int modeEnd = encoded.indexOf('/');
+ if (modeEnd > 0) {
+ String mode = encoded.substring(0, modeEnd);
+ String encodedName = encoded.substring(modeEnd + 1);
+ if ((mode.equals("snapshot") || mode.equals("normal")) && !encodedName.isEmpty()) {
+ try {
+ rewardName = new String(Base64.getUrlDecoder().decode(encodedName), StandardCharsets.UTF_8);
+ generatedSnapshot = Boolean.valueOf(mode.equals("snapshot"));
+ } catch (IllegalArgumentException ignored) {
+ rewardName = reward;
+ }
+ }
+ }
+ }
+ Reward resolved;
+ if (generatedSnapshot != null) {
+ resolved = generatedSnapshot.booleanValue() ? handler.getQueuedGeneratedReward(rewardName, user.getUUID())
+ : handler.getReward(rewardName);
+ } else if (handler.rewardExist(rewardName) || handler.hasDirectRewardHandle(rewardName)) {
+ resolved = handler.getReward(rewardName);
+ } else {
+ resolved = handler.getQueuedGeneratedReward(rewardName, user.getUUID());
+ if (resolved == null) resolved = handler.getReward(rewardName);
+ }
+ if (resolved == null) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("Persisted queued reward could not be resolved: " + rewardName));
+ }
+ return giveRewardAsync(user, resolved, context.getOptions());
+ }
+
+ private CompletionStage disabledDispatch() {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("Plugin disabled before asynchronous reward dispatch"));
+ }
+
public void updateReward(Configuration data, String path, RewardOptions rewardOptions) {
if (data == null || path == null || !data.isConfigurationSection(path)) {
return;
@@ -228,4 +424,21 @@ private void giveSectionReward(AdvancedCoreUser user, ConfigurationSection data,
+ user.getUUID());
giveReward(user, reward, options);
}
+
+ private CompletionStage giveSectionRewardAsync(AdvancedCoreUser user, ConfigurationSection data, String path,
+ RewardExecutionContext context) {
+ RewardOptions options = context.getOptions();
+ String rewardName = context.buildRewardName(path);
+ DirectlyDefinedReward direct = handler.getDirectlyDefined(path);
+ SubDirectlyDefinedReward sub = handler.getSubDirectlyDefined(rewardName);
+ SubRewardResolver resolver = handler.getSubRewardResolver();
+ SubDirectlyDefinedReward fileSub = resolver == null ? null : resolver.getFileBackedSubReward(rewardName);
+ if (context.supportsDirectDispatch() && (direct != null || sub != null || fileSub != null)) {
+ Reward selected = direct != null ? direct.getReward() : (sub != null ? sub : fileSub).getReward();
+ return giveRewardAsync(user, selected, options);
+ }
+ Reward selected = new Reward(rewardName, data.getConfigurationSection(path));
+ selected.checkRewardFile();
+ return giveRewardAsync(user, selected, options);
+ }
}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
index 885e062615..4312db52ea 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java
@@ -7,6 +7,7 @@
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@@ -184,14 +185,27 @@ public void giveReward(AdvancedCoreUser user, ConfigurationSection data, String
rewardExecutor.giveReward(user, data, path, rewardOptions);
}
+ public CompletionStage giveRewardAsync(AdvancedCoreUser user, ConfigurationSection data, String path,
+ RewardOptions rewardOptions) {
+ return rewardExecutor.giveRewardAsync(user, data, path, rewardOptions);
+ }
+
public void giveReward(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) {
rewardExecutor.giveReward(user, reward, rewardOptions);
}
+ public CompletionStage giveRewardAsync(AdvancedCoreUser user, Reward reward, RewardOptions rewardOptions) {
+ return rewardExecutor.giveRewardAsync(user, reward, rewardOptions);
+ }
+
public void giveReward(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) {
rewardExecutor.giveReward(user, reward, rewardOptions);
}
+ public CompletionStage giveRewardAsync(AdvancedCoreUser user, String reward, RewardOptions rewardOptions) {
+ return rewardExecutor.giveRewardAsync(user, reward, rewardOptions);
+ }
+
public void givePersistedQueueReward(AdvancedCoreUser user, PersistedQueueReference rewardReference,
RewardOptions rewardOptions) {
if (rewardReference == null) {
@@ -200,6 +214,14 @@ public void givePersistedQueueReward(AdvancedCoreUser user, PersistedQueueRefere
rewardExecutor.givePersistedQueueReward(user, rewardReference.getReference(), rewardOptions);
}
+ public CompletionStage givePersistedQueueRewardAsync(AdvancedCoreUser user,
+ PersistedQueueReference rewardReference, RewardOptions rewardOptions) {
+ if (rewardReference == null) {
+ return java.util.concurrent.CompletableFuture.completedFuture(null);
+ }
+ return rewardExecutor.givePersistedQueueRewardAsync(user, rewardReference.getReference(), rewardOptions);
+ }
+
public boolean hasDirectRewardHandle(String reward) {
return rewardRegistry.hasDirectRewardHandle(reward);
}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java
index 014c611637..bcda82489a 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java
@@ -1,7 +1,9 @@
package com.bencodez.advancedcore.api.rewards;
-import java.util.HashMap;
-import java.util.Map.Entry;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.function.Consumer;
import com.bencodez.advancedcore.AdvancedCorePlugin;
import com.bencodez.simpleapi.array.ArrayUtils;
@@ -41,8 +43,60 @@ public class RewardOptions {
@Getter
private boolean useDefaultWorlds = true;
- @Getter
- private long orginalTrigger = -1;
+ @Getter
+ private long orginalTrigger = -1;
+
+ /**
+ * Number of completed injection stages recorded for a persisted replay. This
+ * is intentionally internal queue metadata: normal reward dispatch always
+ * starts at zero.
+ */
+ @Getter
+ @Setter
+ private int completedAsyncInjections;
+
+ @Getter
+ @Setter
+ private Map asyncReplayProgress = new HashMap<>();
+
+ @Getter
+ @Setter
+ private Map asyncReplayRegistryFingerprints = new HashMap<>();
+
+ /** True when this queued entry used the old count-only replay format. */
+ @Getter
+ @Setter
+ private boolean legacyAsyncReplayCheckpoint;
+
+ @Getter
+ @Setter
+ private Reward.ReplayState asyncReplayState;
+
+ /**
+ * Stable execution path for a nested asynchronous reward. The path is queue
+ * metadata only; callers that do not participate in replay leave it unset.
+ */
+ @Getter
+ @Setter
+ private String asyncReplayKey;
+
+ /**
+ * Stable identity for one logical queued reward occurrence. It is distinct
+ * from {@link #asyncReplayKey}, which identifies a stage path shared by
+ * independent executions of the same reward definition.
+ */
+ @Getter
+ @Setter
+ private String asyncReplayOccurrenceId;
+
+ @Getter
+ @Setter
+ private Consumer asyncReplayCheckpointConsumer;
+
+ /** True while replaying an entry that must remain in the timed queue on deferral. */
+ @Getter
+ @Setter
+ private boolean timedQueueReplay;
public RewardOptions() {
}
@@ -172,11 +226,36 @@ public String toString() {
}
- public RewardOptions withPlaceHolder(HashMap placeholders2) {
+ public RewardOptions withPlaceHolder(HashMap placeholders2) {
for (Entry entry : placeholders2.entrySet()) {
placeholders.put(entry.getKey(), entry.getValue());
}
- return this;
- }
-
-}
+ return this;
+ }
+
+ /**
+ * Makes an isolated option object for one member of an asynchronously
+ * dispatched list. Mutable placeholders and replay metadata must not leak
+ * from one list member into the next.
+ */
+ RewardOptions copyForNestedDispatch(String replayKey) {
+ RewardOptions copy = new RewardOptions().setCheckTimed(checkTimed).setGiveOffline(giveOffline)
+ .setIgnoreChance(ignoreChance).setIgnoreRequirements(ignoreRequirements).setPrefix(prefix).setSuffix(suffix)
+ .orginalTrigger(orginalTrigger).setPlaceholders(new HashMap<>(placeholders));
+ if (forceOffline) copy.forceOffline();
+ if (!useDefaultWorlds) copy.disableDefaultWorlds();
+ if (onlineSet) copy.setOnline(online);
+ if (!server.isEmpty()) copy.setServer(server);
+ copy.setAsyncReplayState(asyncReplayState);
+ copy.setAsyncReplayRegistryFingerprints(new HashMap<>(asyncReplayRegistryFingerprints));
+ copy.setLegacyAsyncReplayCheckpoint(legacyAsyncReplayCheckpoint);
+ copy.setAsyncReplayKey(replayKey);
+ copy.setAsyncReplayOccurrenceId(asyncReplayOccurrenceId);
+ // The checkpoint consumer belongs to the queued parent occurrence. Nested
+ // children share its ReplayState but must never replace or complete that
+ // queue entry independently.
+ copy.setTimedQueueReplay(timedQueueReplay);
+ return copy;
+ }
+
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java
index bbba30eb1b..763f34d2c6 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardActionBar.java
@@ -34,7 +34,8 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat
section.getInt("Delay", 30));
return null;
}
- }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("ActionBar") {
+ }.requiresPlayerWhen((data, placeholders) -> !data.getString("ActionBar.Message", "").isEmpty())
+ .addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("ActionBar") {
@Override
public void openInventory(ClickEvent clickEvent) {
RewardEditData reward = (RewardEditData) getInv().getData("Reward");
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java
index fc1bb37eb7..09560aaea0 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedPriority.java
@@ -2,6 +2,8 @@
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
@@ -28,6 +30,20 @@ private RewardAdvancedPriority() {
public static void register(RewardHandler handler, AdvancedCorePlugin plugin) {
handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedPriority") {
+ @Override
+ public boolean supportsAsyncRequest() { return true; }
+
+ @Override
+ public boolean requiresConfiguredDataForAsync() { return true; }
+
+ @Override
+ public boolean hasPendingReplayWork(HashMap placeholders) {
+ return Reward.hasReplaySelection(placeholders);
+ }
+
+ @Override
+ public boolean supportsAsyncSynchronization() { return false; }
+
@Override
public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, ConfigurationSection section,
HashMap placeholders) {
@@ -46,6 +62,40 @@ public String onRewardRequested(Reward sourceReward, AdvancedCoreUser user, Conf
return null;
}
+ @Override
+ public CompletionStage onRewardRequestedAsync(Reward sourceReward, AdvancedCoreUser user,
+ ConfigurationSection section, HashMap placeholders) {
+ String selectedKey = Reward.replaySelection(placeholders, () -> {
+ for (String key : section.getKeys(false)) {
+ RewardOptions namingOptions = new RewardOptions()
+ .setPrefix(sourceReward.getName() + "_AdvancedPriority");
+ Reward reward = handler.getReward(section, key, namingOptions);
+ if (reward != null && reward.canGiveReward(user,
+ new RewardOptions().withPlaceHolder(placeholders))) {
+ return key;
+ }
+ }
+ return null;
+ });
+ if (selectedKey == null) return CompletableFuture.completedFuture(null);
+ Reward.ReplayState replayState = Reward.currentReplayState();
+ String replayKey = Reward.currentReplayKey();
+ String occurrenceId = Reward.currentReplayOccurrenceId();
+ return Reward.persistReplayMetadataAsync(plugin, placeholders)
+ .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders,
+ "selected", replayState, replayKey, () -> Reward.continueOnServerThread(plugin, user, () -> {
+ Reward selected = handler.getReward(section, selectedKey, new RewardOptions()
+ .setPrefix(sourceReward.getName() + "_AdvancedPriority"));
+ if (selected == null) return CompletableFuture.failedFuture(new IllegalStateException(
+ "Selected advanced priority reward could not be resolved: " + selectedKey));
+ RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setIgnoreChance(true)
+ .setIgnoreRequirements(true).setPrefix(sourceReward.getName() + "_AdvancedPriority")
+ .withPlaceHolder(placeholders), replayState, replayKey,
+ "selected:" + selectedKey, occurrenceId);
+ return handler.giveRewardAsync(user, selected, childOptions);
+ }))).thenApply(ignored -> selectedKey);
+ }
+
@Override
public ArrayList subRewards(DefinedReward direct) {
ArrayList subs = new ArrayList<>();
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java
index 1003eab2bc..3059ac1ac3 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRandomReward.java
@@ -4,6 +4,8 @@
import java.util.HashMap;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
@@ -31,6 +33,20 @@ private RewardAdvancedRandomReward() {
public static void register(RewardHandler handler, AdvancedCorePlugin plugin) {
handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedRandomReward") {
+ @Override
+ public boolean supportsAsyncRequest() { return true; }
+
+ @Override
+ public boolean requiresConfiguredDataForAsync() { return true; }
+
+ @Override
+ public boolean hasPendingReplayWork(HashMap placeholders) {
+ return Reward.hasReplaySelection(placeholders);
+ }
+
+ @Override
+ public boolean supportsAsyncSynchronization() { return false; }
+
@Override
public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section,
HashMap placeholders) {
@@ -45,6 +61,29 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat
return null;
}
+ @Override
+ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCoreUser user,
+ ConfigurationSection section, HashMap placeholders) {
+ ArrayList rewards = ArrayUtils.convert(section.getKeys(false));
+ if (rewards.isEmpty() && !hasPendingReplayWork(placeholders)) {
+ return CompletableFuture.completedFuture(null);
+ }
+ String selected = Reward.replaySelection(placeholders,
+ () -> rewards.get(ThreadLocalRandom.current().nextInt(rewards.size())));
+ Reward.ReplayState replayState = Reward.currentReplayState();
+ String replayKey = Reward.currentReplayKey();
+ RewardOptions childOptions = Reward.withReplayState(new RewardOptions().setPlaceholders(placeholders),
+ replayState, replayKey, "selected:" + selected,
+ Reward.currentReplayOccurrenceId())
+ .setPrefix(reward.getRewardName() + "_AdvancedRandomReward");
+ return Reward.persistReplayMetadataAsync(plugin, placeholders)
+ .thenCompose(ignored -> Reward.replaySingleNestedReward(plugin, placeholders,
+ "selected", replayState, replayKey,
+ () -> Reward.continueOnServerThread(plugin, user,
+ () -> handler.giveRewardAsync(user, section, selected, childOptions))))
+ .thenApply(ignored -> selected);
+ }
+
@Override
public ArrayList subRewards(DefinedReward direct) {
ArrayList subs = new ArrayList<>();
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java
index f3701c64ff..9268ac270f 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardAdvancedRewards.java
@@ -3,6 +3,8 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
@@ -30,6 +32,30 @@ private RewardAdvancedRewards() {
public static void register(RewardHandler handler, AdvancedCorePlugin plugin) {
handler.getInjectedRewards().add(new RewardInjectConfigurationSection("AdvancedRewards") {
+ @Override
+ public boolean supportsAsyncRequest() {
+ return true;
+ }
+
+ @Override
+ public boolean requiresConfiguredDataForAsync() {
+ return true;
+ }
+
+ @Override
+ public boolean hasPendingReplayWork(HashMap placeholders) {
+ String lane = "advanced-rewards:" + getPath();
+ return Reward.hasReplayNestedRewardSnapshot(placeholders, lane)
+ && !Reward.hasCompletedNestedRewardSequence(placeholders, lane);
+ }
+
+ @Override
+ public boolean supportsAsyncSynchronization() {
+ // This injector awaits nested rewards, which can invoke this same
+ // shared instance. Serializing that recursive chain would deadlock.
+ return false;
+ }
+
@Override
public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section,
HashMap placeholders) {
@@ -42,6 +68,35 @@ public String onRewardRequested(Reward reward, AdvancedCoreUser user, Configurat
return null;
}
+ @Override
+ public CompletionStage