diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml
index 3616f10ec7..4dd59d0430 100644
--- a/AdvancedCore/pom.xml
+++ b/AdvancedCore/pom.xml
@@ -392,7 +392,7 @@
org.xerial
sqlite-jdbc
3.53.4.0
- test
+ provided
com.nickuc.login
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java
index 0119155b7d..5746572831 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java
@@ -624,6 +624,10 @@ public CompletionStage convertDataStorageAsync(UserStorage from, UserStora
}
private void convertDataStorageNow(UserStorage from, UserStorage to) {
+ withLegacyIntegerOwnerReplacement(() -> convertDataStorageNowAdmitted(from, to));
+ }
+
+ private void convertDataStorageNowAdmitted(UserStorage from, UserStorage to) {
debug("Starting convert process");
if (to == null) {
throw new RuntimeException("Invalid Storage Method");
@@ -1196,6 +1200,10 @@ public void updateReplacements() {
* @param storageType the storage type to load
*/
public void loadUserAPI(UserStorage storageType) {
+ withLegacyIntegerOwnerReplacement(() -> loadUserAPIAdmitted(storageType));
+ }
+
+ private void loadUserAPIAdmitted(UserStorage storageType) {
if (storageType == null) {
throw new IllegalArgumentException("User storage must be SQLITE or MYSQL");
}
@@ -1774,6 +1782,10 @@ private UserStorageReplacement prepareUserStorageReplacement(UserStorage storage
}
private void installUserStorageReplacement(UserStorageReplacement replacement) {
+ withLegacyIntegerOwnerReplacement(() -> installUserStorageReplacementAdmitted(replacement));
+ }
+
+ private void installUserStorageReplacementAdmitted(UserStorageReplacement replacement) {
retryPendingNativeUserStorageCloses();
MySQL previousMysql = mysql;
Database previousDatabase = database;
@@ -1926,7 +1938,17 @@ public void setConfigData(YMLConfig ymlConfig) {
*
* @param mysql the mysql connection to set
*/
+ private void withLegacyIntegerOwnerReplacement(Runnable action) {
+ UserManager loaded = getLoadedUserManager();
+ if (loaded == null || loaded.getDataManager() == null) { action.run(); return; }
+ loaded.getDataManager().withLegacyIntegerOwnerReplacement(action);
+ }
+
public void setMysql(MySQL mysql) {
+ withLegacyIntegerOwnerReplacement(() -> setMysqlAdmitted(mysql));
+ }
+
+ private void setMysqlAdmitted(MySQL mysql) {
if (this.mysql != null) {
this.mysql.close();
this.mysql = null;
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java
index 511c9abe8c..8cc930a86b 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java
@@ -46,6 +46,8 @@
import com.bencodez.advancedcore.AdvancedCorePlugin;
import com.bencodez.advancedcore.api.rewards.Reward;
import com.bencodez.advancedcore.api.item.ItemBuilder;
+import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue;
+import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate;
import com.bencodez.advancedcore.api.messages.PlaceholderUtils;
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
import com.bencodez.advancedcore.bukkit.platform.BukkitPlatformServices;
@@ -244,16 +246,7 @@ public void run() {
public void executeConsoleCommands(final String playerName, final ArrayList cmds,
final HashMap placeholders, boolean stagger) {
if (cmds != null && !cmds.isEmpty()) {
- placeholders.put("player", playerName);
- OfflinePlayer p = Bukkit.getOfflinePlayer(playerName);
- ArrayList commands = cmds;
- if (p != null) {
- commands = PlaceholderUtils.replaceJavascriptOnly(p, commands);
- }
- commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders);
- if (p != null) {
- commands = PlaceholderUtils.replacePlaceHolders(p, commands);
- }
+ ArrayList commands = expandConsoleCommands(playerName, cmds, placeholders, true);
int tick = 0;
for (final String cmd : commands) {
plugin.debug("Executing console command: " + cmd);
@@ -264,6 +257,10 @@ public void executeConsoleCommands(final String playerName, final ArrayList placeholders) {
+ if ((playerName == null || playerName.isBlank()) && !Reward.hasReplayCommandSnapshot(placeholders, "console")) {
+ expandConsoleCommands(playerName, command == null || command.isEmpty()
+ ? new ArrayList<>() : new ArrayList<>(java.util.List.of(command)), placeholders, false);
+ }
executeConsoleCommandsAsync(playerName, command, placeholders);
}
@@ -284,15 +281,8 @@ public CompletionStage executeConsoleCommandsAsync(String playerName, Stri
try {
ArrayList templates = command == null || command.isEmpty()
? new ArrayList<>() : new ArrayList<>(java.util.List.of(command));
- String expanded = command;
- OfflinePlayer p = Bukkit.getOfflinePlayer(playerName);
- if (expanded != null && !expanded.isEmpty()) {
- if (p != null) expanded = PlaceholderUtils.replaceJavascriptOnly(p, expanded);
- expanded = PlaceholderUtils.replacePlaceHolder(expanded, placeholders);
- if (p != null) expanded = PlaceholderUtils.replacePlaceHolders(p, expanded);
- }
- ArrayList commands = command == null || command.isEmpty()
- ? new ArrayList<>() : new ArrayList<>(java.util.List.of(expanded == null ? "" : expanded));
+ ArrayList commands = Reward.hasReplayCommandSnapshot(placeholders, "console")
+ ? new ArrayList<>() : expandConsoleCommands(playerName, templates, placeholders, false);
return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands,
(cmd, ignoredIndex) -> {
plugin.debug("Executing console command: " + cmd);
@@ -319,16 +309,20 @@ public CompletionStage executeConsoleCommandsAsync(final String playerName
public CompletionStage executeConsoleCommandsAsync(final String playerName, final ArrayList cmds,
final HashMap placeholders, final boolean stagger,
AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard) {
+ return executeConsoleCommandsAsync(playerName, cmds, placeholders, stagger, eligibilityGuard,
+ Reward.currentReplayState(), Reward.currentReplayKey());
+ }
+
+ /** Carries the replay context across asynchronous availability checks. */
+ public CompletionStage executeConsoleCommandsAsync(final String playerName, final ArrayList cmds,
+ final HashMap placeholders, final boolean stagger,
+ AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard, Reward.ReplayState replayState, String replayKey) {
try {
- placeholders.put("player", playerName);
- OfflinePlayer p = Bukkit.getOfflinePlayer(playerName);
ArrayList templates = cmds == null ? new ArrayList<>() : new ArrayList<>(cmds);
- ArrayList commands = new ArrayList<>(templates);
- if (p != null) commands = PlaceholderUtils.replaceJavascriptOnly(p, commands);
- commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders);
- if (p != null) commands = PlaceholderUtils.replacePlaceHolders(p, commands);
+ ArrayList commands = Reward.hasReplayCommandSnapshot(placeholders, "console", replayState, replayKey)
+ ? new ArrayList<>() : expandConsoleCommands(playerName, templates, placeholders, true);
- return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands,
+ return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, replayState, replayKey,
(command, index) -> {
plugin.debug("Executing console command: " + command);
return runConsoleCommandAsync(stripLeadingSlash(command), index > 0 ? 1 : 0,
@@ -339,6 +333,92 @@ public CompletionStage executeConsoleCommandsAsync(final String playerName
}
}
+ @SuppressWarnings("deprecation")
+ private ArrayList expandConsoleCommands(String playerName, List templates,
+ HashMap placeholders, boolean addPlayer) {
+ ArrayList commands = new ArrayList<>(templates);
+ if (commands.isEmpty()) return commands;
+ boolean hasName = playerName != null && !playerName.isBlank();
+ if (hasName) {
+ if (addPlayer) placeholders.put("player", playerName);
+ OfflinePlayer player = Bukkit.getOfflinePlayer(playerName);
+ if (player != null) commands = PlaceholderUtils.replaceJavascriptOnly(player, commands);
+ commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders);
+ if (player != null) commands = PlaceholderUtils.replacePlaceHolders(player, commands);
+ return commands;
+ }
+ for (String command : commands) {
+ if (command != null && (containsPlayerNamePlaceholder(command)
+ || (plugin.getOptions().isJavascriptEngineEnabled()
+ && containsJavascriptSegment(command)))) {
+ throw new IllegalStateException("Console command requires an unavailable player identity");
+ }
+ }
+ // Trace identity aliases through map substitutions before an empty value can
+ // erase them. Other map entries may themselves introduce an alias.
+ String marker = "__advancedcore_missing_name_" + java.util.UUID.randomUUID() + "__";
+ HashMap guarded = placeholders == null ? new LinkedHashMap<>() : new LinkedHashMap<>(placeholders);
+ guarded.replaceAll((key, value) -> key != null && (key.equalsIgnoreCase("player") || key.equalsIgnoreCase("@p"))
+ ? marker : value);
+ guarded.put("player", marker);
+ guarded.put("@p", marker);
+ String encodedMarker = JavascriptPlaceholderValue.encode(marker);
+ for (String command : PlaceholderUtils.replacePlaceHolder(commands, guarded)) {
+ if (command != null && (command.contains(marker) || command.contains(encodedMarker)
+ || containsPlayerNamePlaceholder(command))) {
+ throw new IllegalStateException("Console command requires an unavailable player identity");
+ }
+ }
+ commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders);
+ for (String command : commands) {
+ if (command != null && (containsPlayerNamePlaceholder(command)
+ || (plugin.isPlaceHolderAPIEnabled() && containsProcessedExternalPlaceholder(command)))) {
+ throw new IllegalStateException("Console command placeholders require an unavailable player identity");
+ }
+ }
+ return commands;
+ }
+
+ private static boolean containsPlayerNamePlaceholder(String command) {
+ return Pattern.compile("(?i)%(?:player|@p)%|\\{(?:player|@p)\\}").matcher(command).find();
+ }
+
+ private static boolean containsJavascriptSegment(String command) {
+ AtomicBoolean found = new AtomicBoolean();
+ JavascriptTextTemplate.parse(command).transform(Function.identity(), script -> {
+ found.set(true);
+ return script;
+ });
+ return found.get();
+ }
+
+ /** PlaceholderAPI processes literal segments, not authored JavaScript bodies. */
+ private static boolean containsProcessedExternalPlaceholder(String command) {
+ AtomicBoolean found = new AtomicBoolean();
+ JavascriptTextTemplate.parse(command).transform(text -> {
+ if (containsExternalPlaceholder(text)) found.set(true);
+ return text;
+ }, Function.identity());
+ return found.get();
+ }
+
+ /** Matches the percent-delimited expansion boundaries used by PlaceholderAPI. */
+ private static boolean containsExternalPlaceholder(String text) {
+ int start = text.indexOf('%');
+ while (start >= 0) {
+ int end = text.indexOf('%', start + 1);
+ if (end < 0) return false;
+ boolean abandoned = false;
+ for (int index = start + 1; index < end; index++) {
+ char c = text.charAt(index);
+ if (c == '_') return true;
+ if (c == ' ') { abandoned = true; break; }
+ }
+ start = text.indexOf('%', abandoned ? start + 1 : end + 1);
+ }
+ return false;
+ }
+
public Object getBlockMeta(Block block, String str) {
for (MetadataValue meta : block.getMetadata(str)) {
if (meta.getOwningPlugin().equals(plugin)) {
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java
index 0132ec6554..aa2e67bbde 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java
@@ -215,7 +215,7 @@ public boolean hasPermission(Player player, String perm) {
* @return true, if successful
*/
public boolean hasPermission(String playerName, String perm) {
- if (playerName == null) {
+ if (playerName == null || playerName.isBlank()) {
return false;
}
Player player = Bukkit.getPlayer(playerName);
@@ -233,7 +233,7 @@ public boolean hasPermission(String playerName, String perm) {
* @return true, if successful
*/
public boolean hasServerPermission(String playerName, String perm) {
- if (playerName == null) {
+ if (playerName == null || playerName.isBlank()) {
return false;
}
Player player = Bukkit.getPlayer(playerName);
@@ -258,7 +258,7 @@ public boolean hasServerPermission(UUID playerUUID, String playername, String pe
Player player = Bukkit.getPlayer(playerUUID);
if (player != null) {
return player.hasPermission(perm);
- } else {
+ } else if (playername != null && !playername.isBlank()) {
player = Bukkit.getPlayer(playername);
if (player != null && playerUUID.equals(player.getUniqueId())) {
return player.hasPermission(perm);
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java
index 2ef7d050c3..ecc4347dd4 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java
@@ -247,7 +247,7 @@ public String getRewardName() {
public void giveInjectedRewards(AdvancedCoreUser user, HashMap placeholders) {
if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) {
giveRewardUserAsync(user, placeholders, new RewardOptions().setCheckTimed(false)).exceptionally(failure -> {
- logRewardUserFailure(failure);
+ logRewardUserFailure(failure, null);
return null;
});
return;
@@ -366,7 +366,7 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user,
final String injectionKey = replayKey + "/" + index;
if (index < resumeAfter) {
sequence = sequence.thenCompose(ignored -> notifyReplayCheckpointPersisted(inject, user, occurrenceId,
- injectionKey));
+ injectionKey, replayState, placeholders));
continue;
}
sequence = sequence.thenCompose(ignored -> {
@@ -383,7 +383,7 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user,
replayState.setRegistryFingerprint(replayKey, registryFingerprint);
return replayState.persistCheckpointAsync(plugin, placeholders)
.thenCompose(ignored -> notifyReplayCheckpointPersisted(inject, user, occurrenceId,
- injectionKey))
+ injectionKey, replayState, placeholders))
.thenApply(ignored -> result);
}).thenCompose(ignored -> resumeOnServerThread(user));
}
@@ -397,11 +397,13 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user,
}
private CompletionStage notifyReplayCheckpointPersisted(RewardInject inject, AdvancedCoreUser user,
- String occurrenceId,
- String injectionKey) {
+ String occurrenceId, String injectionKey, ReplayState replayState, HashMap placeholders) {
try {
+ String selectionKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(injectionKey.getBytes(StandardCharsets.UTF_8));
+ boolean selectionPresent = replaySelectionMetadata(placeholders, replayState, selectionKey) != null;
CompletionStage notification = inject.onReplayCheckpointPersisted(this, user, occurrenceId,
- injectionKey);
+ injectionKey, selectionPresent);
return notification == null ? CompletableFuture.failedFuture(new IllegalStateException(
"Reward injection returned a null replay-checkpoint result: " + inject.getPath())) : notification;
} catch (Throwable failure) {
@@ -541,7 +543,8 @@ public static ReplayState replayStateFor(RewardOptions options) {
// 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());
+ options.getAsyncReplayRegistryFingerprints(), options.isLegacyAsyncReplayCheckpoint(),
+ options.isRestoredAsyncReplayEntry(), options.isFreshAsyncReplayEntry());
} else {
// Deferred/nested options must retain their explicitly inherited parent
// state after the current thread-local scope ends.
@@ -593,7 +596,7 @@ public static String replaySelection(HashMap placeholders, Strin
String storageKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding()
.encodeToString(scopedKey.getBytes(StandardCharsets.UTF_8));
ReplayState replayState = ACTIVE_REPLAY_STATE.get();
- String stored = replayMetadata(placeholders, replayState, storageKey);
+ String stored = replaySelectionMetadata(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);
@@ -611,7 +614,16 @@ public static boolean hasReplaySelection(HashMap placeholders) {
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;
+ return replaySelectionMetadata(placeholders, ACTIVE_REPLAY_STATE.get(), storageKey) != null;
+ }
+
+ private static String replaySelectionMetadata(HashMap placeholders, ReplayState replayState, String key) {
+ // A fresh execution can have a checkpoint writer, but that alone does not
+ // make caller-supplied selection metadata authoritative. Newly selected
+ // values are held in this execution's replay state; only restored state
+ // can accept persisted selections from incoming placeholders.
+ if (replayState != null && !replayState.restoredCheckpoint) return replayState.replayMetadata(key);
+ return replayMetadata(placeholders, replayState, key);
}
/** Durably records replay metadata before dispatching its selected side effect. */
@@ -858,8 +870,12 @@ private static String replaySequenceKey(String prefix, String activeKey, String
/** Returns whether the active replay already froze this logical command lane. */
public static boolean hasReplayCommandSnapshot(HashMap placeholders, String lane) {
- ReplayState replayState = currentReplayState();
- String activeKey = currentReplayKey();
+ return hasReplayCommandSnapshot(placeholders, lane, currentReplayState(), currentReplayKey());
+ }
+
+ /** Checks a captured replay context after an asynchronous continuation. */
+ public static boolean hasReplayCommandSnapshot(HashMap placeholders, String lane,
+ ReplayState replayState, String activeKey) {
if (replayState == null || activeKey == null) return false;
String storageKey = replaySequenceKey(REPLAY_COMMAND_PREFIX, activeKey,
lane == null ? "commands" : lane);
@@ -1026,6 +1042,11 @@ private static boolean isReplayMetadataKey(String key) {
|| key.startsWith(REPLAY_LEGACY_ACTION_PREFIX) || key.startsWith(REPLAY_CHANCE_PREFIX));
}
+ /** Remove reserved replay metadata from untrusted fresh queue input. */
+ public static void clearReplayMetadata(HashMap placeholders) {
+ if (placeholders != null) placeholders.keySet().removeIf(Reward::isReplayMetadataKey);
+ }
+
/** Stable metadata key for the individual legacy actions produced by one injector. */
public static String legacyActionReplayKey(String injectionKey) {
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(
@@ -1117,6 +1138,7 @@ public static final class ReplayState {
private final HashMap replayMetadata = new HashMap<>();
private final boolean legacyCheckpoint;
private final boolean restoredCheckpoint;
+ private final boolean freshEntry;
private boolean livePlayerStateSet;
private boolean livePlayerOnline;
private boolean livePlayerVanished;
@@ -1125,10 +1147,15 @@ public static final class ReplayState {
private ReplayState(Map initial) { this(initial, null, false); }
private ReplayState(Map initial, Map initialFingerprints,
boolean legacyCheckpoint) {
+ this(initial, initialFingerprints, legacyCheckpoint, false, false);
+ }
+ private ReplayState(Map initial, Map initialFingerprints,
+ boolean legacyCheckpoint, boolean restoredEntry, boolean freshEntry) {
if (initial != null) completed.putAll(initial);
if (initialFingerprints != null) registryFingerprints.putAll(initialFingerprints);
this.legacyCheckpoint = legacyCheckpoint;
- this.restoredCheckpoint = legacyCheckpoint || (initial != null && !initial.isEmpty())
+ this.freshEntry = freshEntry;
+ this.restoredCheckpoint = restoredEntry || legacyCheckpoint || (initial != null && !initial.isEmpty())
|| (initialFingerprints != null && !initialFingerprints.isEmpty());
}
private synchronized int getCompleted(String rewardName, int fallback) {
@@ -1147,6 +1174,9 @@ public synchronized void recordReplayMetadata(String key, String value) {
if (isReplayMetadataKey(key)) replayMetadata.put(key, value);
}
public synchronized void mergeReplayMetadataInto(HashMap target) {
+ if (!restoredCheckpoint) target.keySet().removeIf(key ->
+ (freshEntry ? isReplayMetadataKey(key) : key != null && key.startsWith(REPLAY_SELECTION_PREFIX))
+ && !replayMetadata.containsKey(key));
Reward.mergeReplayMetadata(target, replayMetadata);
}
public synchronized String replayMetadata(String key) { return replayMetadata.get(key); }
@@ -1199,7 +1229,7 @@ private synchronized String replayPlaceholderKey(String executionKey, boolean ow
return checkpointConsumer != null && queueReplayKey != null ? queueReplayKey : executionKey;
}
private synchronized boolean acceptsPersistedMetadata() {
- return restoredCheckpoint || checkpointConsumer != null;
+ return restoredCheckpoint || (!freshEntry && checkpointConsumer != null);
}
public CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin,
HashMap placeholders) {
@@ -1211,7 +1241,9 @@ private CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin,
Consumer consumer;
synchronized (this) { consumer = checkpointConsumer; }
if (consumer == null) return CompletableFuture.completedFuture(null);
- ReplayCheckpoint checkpoint = new ReplayCheckpoint(copyProgress(), copyRegistryFingerprints(), placeholders);
+ HashMap checkpointPlaceholders = new HashMap<>(placeholders);
+ mergeReplayMetadataInto(checkpointPlaceholders);
+ ReplayCheckpoint checkpoint = new ReplayCheckpoint(copyProgress(), copyRegistryFingerprints(), checkpointPlaceholders);
CompletableFuture persisted = new CompletableFuture<>();
AtomicBoolean claimed = new AtomicBoolean();
try {
@@ -1481,8 +1513,9 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) {
// player owner. Unconfigured legacy definitions retain their established path.
if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData())
&& (rewardOptions == null || !rewardOptions.isIgnoreRequirements())) {
- giveRewardAsync(user, rewardOptions).exceptionally(failure -> {
- logRewardUserFailure(failure);
+ RewardOptions tracked = (rewardOptions == null ? new RewardOptions() : rewardOptions).captureLegacyFailureObserver();
+ giveRewardAsync(user, tracked).exceptionally(failure -> {
+ logRewardUserFailure(failure, tracked.legacyFailureObserverCapture().get());
return null;
});
return;
@@ -1612,6 +1645,9 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) {
* @return completion stage for the complete reward injection chain
*/
public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) {
+ if (rewardOptions != null && !rewardOptions.claimLegacyFailureObserverCapture()) {
+ rewardOptions = rewardOptions.withoutLegacyFailureObserverCapture();
+ }
boolean primaryThread = false;
try {
primaryThread = Bukkit.isPrimaryThread();
@@ -1661,6 +1697,12 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R
}
rewardOptions = rewardOptions == null ? new RewardOptions() : rewardOptions.copyForDispatch();
+ if (rewardOptions.getAsyncReplayState() == null && rewardOptions.getAsyncReplayCheckpointConsumer() == null
+ && !rewardOptions.isRestoredAsyncReplayEntry() && !rewardOptions.isLegacyAsyncReplayCheckpoint()
+ && rewardOptions.getAsyncReplayProgress().isEmpty()
+ && rewardOptions.getAsyncReplayRegistryFingerprints().isEmpty()) {
+ rewardOptions.setFreshAsyncReplayEntry(true);
+ }
if (!rewardOptions.getPlaceholders().containsKey("ExecDate")) {
rewardOptions.addPlaceholder("ExecDate", "" + System.currentTimeMillis());
}
@@ -1677,6 +1719,7 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R
PlayerRewardEvent event = new PlayerRewardEvent(this, user, rewardOptions);
Bukkit.getPluginManager().callEvent(event);
+ rewardOptions.updateLegacyFailureObserverCapture();
if (event.isCancelled()) {
plugin.debug("Reward " + name + " was cancelled for " + user.getPlayerName());
return CompletableFuture.completedFuture(null);
@@ -1777,7 +1820,9 @@ private CompletionStage evaluateLiveRewardDecision(AdvancedC
&& RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) {
String key = chanceReplayKey(options);
options.addPlaceholder(key, "1");
- if (options.getAsyncReplayState() != null) options.getAsyncReplayState().recordReplayMetadata(key, "1");
+ ReplayState state = options.getAsyncReplayState();
+ if (state == null) { state = replayStateFor(options); options.setAsyncReplayState(state); }
+ state.recordReplayMetadata(key, "1");
}
return current;
});
@@ -1913,8 +1958,9 @@ private RewardReplayFailure replayFailureWithSnapshot(RewardOptions options, Thr
public void giveRewardUser(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) {
RewardOptions effectiveOptions = rewardOptions == null ? new RewardOptions() : rewardOptions;
if (requiresAwaitedDelivery(effectiveOptions)) {
+ Consumer observer = effectiveOptions.getAsyncFailureObserver();
giveRewardUserAsync(user, phs, effectiveOptions).exceptionally(failure -> {
- logRewardUserFailure(failure);
+ logRewardUserFailure(failure, observer);
return null;
});
return;
@@ -2033,7 +2079,8 @@ private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap
Player player = user.getPlayer();
if (player == null) {
- player = Bukkit.getPlayer(user.getPlayerName());
+ String playerName = user.getPlayerName();
+ if (playerName != null && !playerName.isBlank()) player = Bukkit.getPlayer(playerName);
}
if (player != null || isForceOffline()) {
@@ -2066,7 +2113,11 @@ private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap
}
}
- private void logRewardUserFailure(Throwable failure) {
+ private void logRewardUserFailure(Throwable failure, Consumer observer) {
+ if (observer != null) {
+ try { observer.accept(failure); }
+ catch (Throwable ignored) { /* Diagnostics must not replace the original failure. */ }
+ }
if (plugin.getLogger() != null) {
plugin.getLogger().log(Level.WARNING, "Failed to give reward " + getRewardName(), failure);
} else {
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java
index c174557e8b..e468bf6de0 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java
@@ -4,6 +4,8 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.function.Consumer;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.atomic.AtomicBoolean;
import com.bencodez.advancedcore.AdvancedCorePlugin;
import com.bencodez.simpleapi.array.ArrayUtils;
@@ -75,6 +77,16 @@ public class RewardOptions {
@Setter
private boolean legacyAsyncReplayCheckpoint;
+ /** True only when loading an existing durable queue entry. */
+ @Getter
+ @Setter
+ private boolean restoredAsyncReplayEntry;
+
+ /** Explicit provenance for a newly created internal queue occurrence. */
+ @Getter
+ @Setter
+ private boolean freshAsyncReplayEntry;
+
@Getter
@Setter
private Reward.ReplayState asyncReplayState;
@@ -100,6 +112,35 @@ public class RewardOptions {
@Setter
private Consumer asyncReplayCheckpointConsumer;
+ /** Optional best-effort observer for a legacy fire-and-forget terminal failure.
+ * Invoked on the completion thread; it must not block or assume Bukkit ownership. */
+ @Getter
+ @Setter
+ private Consumer asyncFailureObserver;
+ private LegacyFailureObserverCapture legacyFailureObserverCapture;
+ private static final class LegacyFailureObserverCapture {
+ private final AtomicReference> observer;
+ private final AtomicBoolean claimed = new AtomicBoolean();
+ private LegacyFailureObserverCapture(Consumer observer) { this.observer = new AtomicReference<>(observer); }
+ }
+ RewardOptions captureLegacyFailureObserver() {
+ RewardOptions copy = copyForDispatch();
+ copy.legacyFailureObserverCapture = new LegacyFailureObserverCapture(copy.asyncFailureObserver);
+ return copy;
+ }
+ AtomicReference> legacyFailureObserverCapture() {
+ return legacyFailureObserverCapture == null ? null : legacyFailureObserverCapture.observer;
+ }
+ boolean claimLegacyFailureObserverCapture() {
+ return legacyFailureObserverCapture == null || legacyFailureObserverCapture.claimed.compareAndSet(false, true);
+ }
+ RewardOptions withoutLegacyFailureObserverCapture() {
+ RewardOptions copy = copyForDispatch(); copy.legacyFailureObserverCapture = null; return copy;
+ }
+ void updateLegacyFailureObserverCapture() {
+ if (legacyFailureObserverCapture != null) legacyFailureObserverCapture.observer.set(asyncFailureObserver);
+ }
+
/** True while replaying an entry that must remain in the timed queue on deferral. */
@Getter
@Setter
@@ -272,7 +313,11 @@ RewardOptions copyForDispatch() {
copy.setAsyncReplayProgress(new HashMap<>(asyncReplayProgress));
copy.setAsyncReplayRegistryFingerprints(new HashMap<>(asyncReplayRegistryFingerprints));
copy.setLegacyAsyncReplayCheckpoint(legacyAsyncReplayCheckpoint);
+ copy.setRestoredAsyncReplayEntry(restoredAsyncReplayEntry);
+ copy.setFreshAsyncReplayEntry(freshAsyncReplayEntry);
copy.setAsyncReplayCheckpointConsumer(asyncReplayCheckpointConsumer);
+ copy.setAsyncFailureObserver(asyncFailureObserver);
+ copy.legacyFailureObserverCapture = legacyFailureObserverCapture;
copy.setAsyncReplayKey(asyncReplayKey);
copy.setAsyncReplayOccurrenceId(asyncReplayOccurrenceId);
copy.setTimedQueueReplay(timedQueueReplay);
@@ -286,6 +331,7 @@ RewardOptions copyForDispatch() {
*/
RewardOptions copyForNestedDispatch(String replayKey) {
RewardOptions copy = copyForDispatch();
+ copy.legacyFailureObserverCapture = null;
copy.setCompletedAsyncInjections(0);
copy.setAsyncReplayProgress(new HashMap<>());
// The checkpoint consumer belongs to the queued parent occurrence. Nested
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java
index 3a48c82721..437d212e30 100644
--- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java
@@ -261,14 +261,14 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor
// Validate the player before any mixed-section side effect, then preserve
// the established console-before-player command order.
return availability.thenCompose(ignored -> consoleCommands.isEmpty()
- && !Reward.hasReplayCommandSnapshot(placeholders, "console")
+ && !Reward.hasReplayCommandSnapshot(placeholders, "console", replayState, replayKey)
? CompletableFuture.completedFuture(null)
- : eligibilityGuard == null
+ : replayState == null && eligibilityGuard == null
? MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands,
placeholders, stagger)
: MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands,
- placeholders, stagger, eligibilityGuard))
- .thenCompose(ignored -> eligibilityGuard == null
+ placeholders, stagger, eligibilityGuard, replayState, replayKey))
+ .thenCompose(ignored -> replayState == null && eligibilityGuard == null
? user.preformCommandAsync(userCommands, placeholders)
: user.preformCommandAsync(userCommands, placeholders, eligibilityGuard, replayState, replayKey))
.thenApply(ignored -> null);
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java
index 9c43766adf..dd712fd862 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
@@ -180,6 +180,16 @@ public CompletionStage onReplayCheckpointPersisted(Reward reward, Advanced
return CompletableFuture.completedFuture(null);
}
+ /**
+ * Notifies an injection with the authoritative selection state for this exact
+ * replay path. The default preserves existing four-argument overrides.
+ * A false value means no accepted replay selection exists for this injection.
+ */
+ public CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, String occurrenceId,
+ String injectionKey, boolean selectionPresent) {
+ return onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey);
+ }
+
/** Serializes a synchronized asynchronous injection through completion, not just invocation. */
public synchronized CompletionStage