diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java
index eb218bfdaf..54c6de4256 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java
@@ -865,7 +865,7 @@ public void executeAll(CommandSender sender, String[] args) {
@Override
public void executeSinglePlayer(CommandSender sender, String[] args) {
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]);
- user.cache();
+ if (!UserStorage.MYSQL.equals(plugin.getStorageType())) user.cache();
user.removePoints(Integer.parseInt(args[3]), removed -> {
if (!removed) {
runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize(
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java
index 87a136604a..6e8e092ea2 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java
@@ -67,6 +67,8 @@ private record PlaceholderClassification(List> all
private record PlatformUpdateKey(UUID uuid, PlaceHolder placeholder) { }
private final ConcurrentHashMap platformUpdateGenerations = new ConcurrentHashMap<>();
private boolean userDataChangeListenerRegistered;
+ private final PointPlaceholderFence pointFence = new PointPlaceholderFence();
+ private final ConcurrentHashMap pointRecoveryWorkers = new ConcurrentHashMap<>();
@Getter
private PlaceholderCacheLevel cacheLevel;
@@ -185,6 +187,69 @@ public String getPlaceHolder(Player p, String identifier) {
* @return the placeholder value
*/
public String getPlaceholderValue(OfflinePlayer p, String identifier, boolean javascript, boolean forceProcess,
+ boolean useCache) {
+ if (p == null || !isPointIdentifier(identifier)) {
+ return getPlaceholderValueUnfenced(p, identifier, javascript, forceProcess, useCache);
+ }
+ VotingPluginUser pointUser = resolvePlaceholderUser(p);
+ UUID uuid = pointUser.getJavaUUID();
+ var generation = pointFence.generation(uuid);
+ try {
+ if (!pointFence.readable(uuid, generation)) {
+ if (pointFence.recoverable(uuid)) schedulePointRecovery(pointUser);
+ return "...";
+ }
+ if (com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ var snapshot = publishedPointSnapshot(pointUser);
+ if (snapshot == null) {
+ schedulePointRecovery(pointUser);
+ return "...";
+ }
+ // A complete row with invalid data is unavailable, not a missing snapshot to reload.
+ if (pointBalance(snapshot, pointUser.getPointsPath()) == null) return "...";
+ }
+ String value = getPlaceholderValueUnfenced(p, identifier, javascript, forceProcess, useCache);
+ return pointFence.readable(uuid, generation) ? value : "...";
+ } finally {
+ pointFence.release(uuid);
+ }
+ }
+
+ private java.util.Map publishedPointSnapshot(VotingPluginUser user) {
+ var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(user.getJavaUUID());
+ return cache == null ? null : cache.snapshotIfPublished();
+ }
+
+ private Integer pointBalance(VotingPluginUser user) {
+ if (!com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType())) return user.getPoints();
+ return pointBalance(publishedPointSnapshot(user), user.getPointsPath());
+ }
+
+ private Integer pointBalance(java.util.Map snapshot, String column) {
+ if (snapshot == null) return null;
+ // AC publishes a complete row; absence has the historical integer default.
+ if (!snapshot.containsKey(column)) return 0;
+ var value = snapshot.get(column);
+ if (value == null) return null;
+ try {
+ if (value.isInt()) return value.getInt();
+ if (value.isString()) return Integer.valueOf(value.getString());
+ } catch (NumberFormatException malformed) {
+ return null;
+ }
+ return null;
+ }
+
+ private void schedulePointRecovery(VotingPluginUser user) {
+ schedulePlaceholderCheck(user);
+ }
+
+ private static boolean isPointIdentifier(String identifier) {
+ String key = identifier.toLowerCase(Locale.ROOT);
+ return key.equals("points") || key.equals("points_format") || key.startsWith("enoughpoints_");
+ }
+
+ private String getPlaceholderValueUnfenced(OfflinePlayer p, String identifier, boolean javascript, boolean forceProcess,
boolean useCache) {
if (plugin.getConfigFile().isUseJavascriptPlaceholders() && javascript && p != null) {
identifier = PlaceholderUtils.replaceJavascript(p, identifier);
@@ -498,7 +563,8 @@ public String placeholderRequest(VotingPluginUser user, String identifier) {
@Override
public String placeholderRequest(VotingPluginUser user, String identifier) {
- return Integer.toString(user.getPoints());
+ Integer points = pointBalance(user);
+ return points == null ? "..." : Integer.toString(points);
}
}.withDescription("User points").updateDataKey("Points"));
@@ -507,7 +573,8 @@ public String placeholderRequest(VotingPluginUser user, String identifier) {
@Override
public String placeholderRequest(VotingPluginUser user, String identifier) {
NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.US);
- return numberFormat.format(user.getPoints());
+ Integer points = pointBalance(user);
+ return points == null ? "..." : numberFormat.format(points);
}
}.withDescription("User points").updateDataKey("Points"));
@@ -633,7 +700,9 @@ public String placeholderRequest(VotingPluginUser user, String identifier) {
return "invalid";
}
int number = Integer.parseInt(identifier.split("_")[1]);
- if (user.getPoints() >= number) {
+ Integer points = pointBalance(user);
+ if (points == null) return "...";
+ if (points >= number) {
return "true";
}
return "false";
@@ -1415,6 +1484,13 @@ private boolean shouldRefresh(PlaceHolder placeholder, UUID uu
@SuppressWarnings("unchecked")
private void updateCachedPlaceholder(PlaceHolder placeholder, VotingPluginUser user,
UUID uuid, String... keys) {
+ var pointGeneration = pointFence.generation(uuid);
+ try {
+ if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey())
+ && !pointFence.readable(uuid, pointGeneration)) {
+ if (pointFence.recoverable(uuid)) schedulePointRecovery(user);
+ return;
+ }
for (String ident : Set.copyOf(placeholder.getCache().keySet())) {
ConcurrentHashMap values = placeholder.getCache().get(ident);
if (values == null) continue;
@@ -1435,7 +1511,11 @@ private void updateCachedPlaceholder(PlaceHolder placeholder,
}
} else {
String value = placeholder.placeholderRequest(user, ident);
- values.put(uuid, value);
+ if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey())) {
+ pointFence.publish(uuid, pointGeneration, () -> {
+ if (plugin.isEnabled()) values.put(uuid, value);
+ });
+ } else values.put(uuid, value);
if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) {
values.remove(uuid);
continue;
@@ -1444,6 +1524,9 @@ private void updateCachedPlaceholder(PlaceHolder placeholder,
+ matchingKey(placeholder, keys) + " with " + value);
}
}
+ } finally {
+ pointFence.release(uuid);
+ }
}
private void publishCachedValue(PlaceHolder placeholder, VotingPluginUser user, UUID uuid,
@@ -1468,6 +1551,52 @@ private String matchingKey(PlaceHolder placeholder, String...
return placeholder.getUpdateDataKey();
}
+ /** Blocks stale point results before a shared debit enters its worker. */
+ public void beginSharedPointDebit(UUID uuid) {
+ pointFence.begin(uuid, () -> clearPointResults(uuid));
+ }
+
+ /** Owns a queued batch without replacing its worker's mutation outcome. */
+ public java.util.function.Consumer sharedPointAdmissionCompletion(UUID uuid) {
+ beginSharedPointDebit(uuid);
+ var completed = new java.util.concurrent.atomic.AtomicBoolean();
+ return settled -> {
+ if (completed.compareAndSet(false, true)) pointFence.finishAdmission(uuid,
+ settled && plugin.isEnabled() && plugin.getPlaceholders() == this);
+ };
+ }
+
+ private void clearPointResults(UUID uuid) {
+ for (PlaceHolder placeholder : userDataChangeClassification.all()) {
+ if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey()) && placeholder.isUsesCache()) {
+ placeholder.clearCachePlayer(uuid);
+ }
+ }
+ }
+
+ /** Completes the fence only after the storage worker has reconciled its snapshot. */
+ public void finishSharedPointDebit(UUID uuid, boolean trustworthySnapshot) {
+ finishSharedPointDebit(uuid, trustworthySnapshot, trustworthySnapshot);
+ }
+
+ public void finishSharedPointDebit(UUID uuid, boolean knownOutcome, boolean trustworthySnapshot) {
+ pointFence.finish(uuid, knownOutcome, trustworthySnapshot);
+ }
+
+ /** Captures a worker refill generation without granting permission to retry a mutation. */
+ public java.util.function.Consumer sharedPointRefillCompletion(UUID uuid) {
+ var stamp = pointFence.beginRefill(uuid, () -> clearPointResults(uuid));
+ var completed = new java.util.concurrent.atomic.AtomicBoolean();
+ return verified -> {
+ if (!completed.compareAndSet(false, true)) return;
+ try {
+ pointFence.finishRefill(uuid, stamp, verified && plugin.isEnabled() && plugin.getPlaceholders() == this);
+ } finally {
+ pointFence.release(uuid);
+ }
+ };
+ }
+
/**
* Handle player logout.
* @param user the voting plugin user
@@ -1518,6 +1647,7 @@ public void onUpdate() {
public void onUpdate(VotingPluginUser user, boolean login) {
if (user == null) return;
UUID uuid = user.getJavaUUID();
+ if (pointFence.recoverable(uuid)) schedulePointRecovery(user);
Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid);
PlaceholderClassification classification = userDataChangeClassification;
List> platformUpdates = new ArrayList<>();
@@ -1559,6 +1689,8 @@ public void onVotePartyUpdate() {
* Reload placeholders.
*/
public void reload() {
+ pointFence.invalidateLifecycle();
+ pointRecoveryWorkers.clear();
platformUpdateGenerations.clear();
plugin.refreshPlaceholderPlayerPresence();
cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel();
@@ -1592,16 +1724,47 @@ VotingPluginUser resolvePlaceholderUser(OfflinePlayer player) {
* Schedule placeholder check for user.
* @param user the voting plugin user
*/
- public void schedulePlaceholderCheck(VotingPluginUser user) {
- plugin.getTimer().execute(new Runnable() {
-
- @Override
- public void run() {
- checkNonCachedPlaceholders();
- // Admit cold offline entries only from this worker callback.
- onUpdate(user, !getCacheLevel().onlineOnly()
- || plugin.getPlaceholderPlayerPresence().isOnline(user.getJavaUUID()));
- }
- });
- }
+ public void schedulePlaceholderCheck(VotingPluginUser user) {
+ if (!plugin.isEnabled() || plugin.getPlaceholders() != this) return;
+ UUID uuid = user.getJavaUUID();
+ Object token = new Object();
+ long lifecycle = pointFence.lifecycle();
+ // Common warming and direct point recovery share queued and executing admission.
+ if (pointRecoveryWorkers.putIfAbsent(uuid, token) != null) return;
+ try {
+ plugin.getTimer().execute(() -> {
+ boolean warmed = false;
+ try {
+ if (!plugin.isEnabled() || plugin.getPlaceholders() != this || lifecycle != pointFence.lifecycle()) return;
+ var observed = pointFence.generation(uuid);
+ try {
+ // Invalid data in a complete published row is not a missing snapshot.
+ // Pending/unknown writes cannot authorize a competing point refill.
+ if (pointFence.recoverable(uuid)
+ || (pointFence.readable(uuid, observed)
+ && com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType())
+ && publishedPointSnapshot(user) == null)) {
+ com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(
+ plugin, user.getUUID(), user.getPointsPath());
+ }
+ } finally { pointFence.release(uuid); }
+ if (!plugin.isEnabled() || plugin.getPlaceholders() != this || lifecycle != pointFence.lifecycle()) return;
+ checkNonCachedPlaceholders();
+ // Preserve each cache level's existing offline-entry admission policy.
+ onUpdate(user, !getCacheLevel().onlineOnly() || plugin.getPlaceholderPlayerPresence().isOnline(uuid));
+ warmed = true;
+ } finally {
+ boolean retired = pointRecoveryWorkers.remove(uuid, token);
+ // A new identifier may have arrived after this worker's discovery pass.
+ if (retired && warmed && !placeholdersToSetCacheOn.isEmpty() && plugin.isEnabled()
+ && plugin.getPlaceholders() == this && lifecycle == pointFence.lifecycle()) {
+ schedulePlaceholderCheck(user);
+ }
+ }
+ });
+ } catch (RuntimeException rejected) {
+ pointRecoveryWorkers.remove(uuid, token);
+ plugin.debug(rejected);
+ }
+ }
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PointPlaceholderFence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PointPlaceholderFence.java
new file mode 100644
index 0000000000..2a15afa668
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PointPlaceholderFence.java
@@ -0,0 +1,107 @@
+package com.bencodez.votingplugin.placeholders;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/** Coordinates point rendering with in-flight shared-storage mutations. */
+final class PointPlaceholderFence {
+ private static final class State {
+ long generation;
+ int pending;
+ int readers;
+ int refills;
+ boolean uncertain;
+ boolean snapshotUnavailable;
+ }
+
+ private final Map states = new HashMap<>();
+ private long generation;
+ private long lifecycle;
+ record Stamp(long lifecycle, long generation) { }
+
+ synchronized Stamp generation(UUID uuid) {
+ State state = states.computeIfAbsent(uuid, ignored -> {
+ State created = new State();
+ created.generation = ++generation;
+ return created;
+ });
+ state.readers++;
+ return new Stamp(lifecycle, state.generation);
+ }
+
+ synchronized boolean readable(UUID uuid, Stamp observed) {
+ if (lifecycle != observed.lifecycle()) return false;
+ State state = states.get(uuid);
+ return state != null && state.generation == observed.generation()
+ && state.pending == 0 && state.refills == 0 && !state.uncertain && !state.snapshotUnavailable;
+ }
+
+ synchronized void release(UUID uuid) {
+ State state = states.get(uuid);
+ if (state == null || state.readers == 0) throw new IllegalStateException("Unbalanced point reader");
+ state.readers--;
+ retireIfSafe(uuid, state);
+ }
+
+ private void retireIfSafe(UUID uuid, State state) {
+ if (state.readers == 0 && state.pending == 0 && state.refills == 0 && !state.uncertain && !state.snapshotUnavailable) states.remove(uuid);
+ }
+
+ synchronized void invalidateLifecycle() { lifecycle++; }
+ synchronized long lifecycle() { return lifecycle; }
+ synchronized boolean recoverable(UUID uuid) {
+ State state = states.get(uuid);
+ return state != null && state.snapshotUnavailable && !state.uncertain && state.pending == 0 && state.refills == 0;
+ }
+
+ synchronized Stamp beginRefill(UUID uuid, Runnable invalidate) {
+ State state = states.computeIfAbsent(uuid, ignored -> new State());
+ state.generation = ++generation;
+ state.snapshotUnavailable = true;
+ state.refills++;
+ state.readers++;
+ invalidate.run();
+ return new Stamp(lifecycle, state.generation);
+ }
+
+ synchronized void begin(UUID uuid, Runnable invalidate) {
+ State state = states.computeIfAbsent(uuid, ignored -> new State());
+ state.generation = ++generation;
+ state.pending++;
+ invalidate.run();
+ }
+
+ synchronized void finish(UUID uuid, boolean knownOutcome, boolean trustworthy) {
+ State state = states.get(uuid);
+ if (state == null || state.pending == 0) throw new IllegalStateException("Unbalanced point mutation");
+ state.uncertain |= !knownOutcome;
+ state.snapshotUnavailable = !trustworthy || state.refills != 0;
+ state.pending--;
+ state.generation = ++generation;
+ retireIfSafe(uuid, state);
+ }
+
+ synchronized void finishAdmission(UUID uuid, boolean workerSettled) {
+ State state = states.get(uuid);
+ if (state == null || state.pending == 0) throw new IllegalStateException("Unbalanced point admission");
+ state.pending--;
+ state.snapshotUnavailable |= !workerSettled;
+ state.generation = ++generation;
+ retireIfSafe(uuid, state);
+ }
+
+ synchronized void finishRefill(UUID uuid, Stamp observed, boolean verified) {
+ State state = states.get(uuid);
+ if (state == null || state.refills == 0) throw new IllegalStateException("Unbalanced point refill");
+ boolean current = lifecycle == observed.lifecycle() && state.generation == observed.generation();
+ state.refills--;
+ state.snapshotUnavailable = !(verified && current && state.pending == 0 && state.refills == 0 && !state.uncertain);
+ // Even an obsolete read completion can replace storage data: retire every older render.
+ state.generation = ++generation;
+ }
+
+ synchronized void publish(UUID uuid, Stamp observed, Runnable publication) {
+ if (readable(uuid, observed)) publication.run();
+ }
+}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java
index f6d9dc09c8..d6d18da20c 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java
@@ -93,10 +93,20 @@ public static void invalidate(VotingPluginMain plugin, String uuid, String... co
public static void invalidateAndRefresh(VotingPluginMain plugin, String uuid, String... columns) {
invalidate(plugin, uuid, columns);
if (plugin == null || uuid == null) return;
+ String pointColumn = plugin.getBungeeSettings().isPerServerPoints()
+ ? plugin.getBungeeSettings().getServerNameStorage() + "_Points" : "Points";
+ boolean affectsPoints = columns != null && java.util.Arrays.asList(columns).contains(pointColumn);
+ var placeholders = plugin.getPlaceholders();
+ java.util.function.Consumer completion = affectsPoints && placeholders != null
+ ? placeholders.sharedPointRefillCompletion(UUID.fromString(uuid)) : null;
try {
- plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuid), false).cacheAsync();
+ plugin.getBukkitScheduler().runTaskAsynchronously(plugin,
+ () -> tryInvalidateAndRefreshOnWorker(plugin, uuid, columns));
} catch (RuntimeException refreshFailure) {
plugin.debug(refreshFailure);
+ } finally {
+ // This admission is not storage evidence. Only the actual worker can recover rendering.
+ if (completion != null) completion.accept(false);
}
}
@@ -106,13 +116,37 @@ public static void invalidateAndRefresh(VotingPluginMain plugin, String uuid, St
* A failed refill leaves the snapshot explicitly unavailable, not false zero.
*/
public static void invalidateAndRefreshOnWorker(VotingPluginMain plugin, String uuid, String... columns) {
- invalidate(plugin, uuid, columns);
- if (plugin == null || uuid == null) return;
+ tryInvalidateAndRefreshOnWorker(plugin, uuid, columns);
+ }
+
+ static boolean tryInvalidateAndRefreshOnWorker(VotingPluginMain plugin, String uuid, String... columns) {
+ if (plugin == null || uuid == null) return false;
+ UUID playerUuid = UUID.fromString(uuid);
+ String pointColumn = plugin.getBungeeSettings().isPerServerPoints()
+ ? plugin.getBungeeSettings().getServerNameStorage() + "_Points" : "Points";
+ boolean affectsPoints = columns != null && java.util.Arrays.asList(columns).contains(pointColumn);
+ var placeholders = plugin.getPlaceholders();
+ java.util.function.Consumer completion = affectsPoints && placeholders != null
+ ? placeholders.sharedPointRefillCompletion(playerUuid) : null;
+ boolean verified = false;
try {
- plugin.getUserManager().getDataManager().cacheUser(UUID.fromString(uuid), null);
+ invalidate(plugin, uuid, columns);
+ var manager = plugin.getUserManager().getDataManager();
+ manager.cacheUser(playerUuid, null);
+ var cache = manager.getUserDataCache().get(playerUuid);
+ if (cache != null) synchronized (cache) {
+ // A complete published row may legitimately omit the selected points key.
+ // Rendering separately rejects present malformed/unsupported values.
+ verified = manager.getUserDataCache().get(playerUuid) == cache
+ && cache.hasPublishedStorageSnapshot();
+ }
+ return true;
} catch (RuntimeException refreshFailure) {
plugin.getLogger().warning("Shared MySQL cache refresh is unavailable after mutation; stale data was not published.");
plugin.debug(refreshFailure);
+ return false;
+ } finally {
+ if (completion != null) completion.accept(verified);
}
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java
index 375bd943e9..ca55ae8140 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java
@@ -185,16 +185,29 @@ AddResult addCommitted(VotingPluginUser user, int amount, String operationId) {
AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) {
if (operationId == null || operationId.isEmpty()) return addCommittedToColumn(user, amount, pointsColumn);
- drainCache(user, pointsColumn);
+ var placeholders = plugin.getPlaceholders();
+ UUID uuid = UUID.fromString(user.getUUID());
+ if (placeholders != null) placeholders.beginSharedPointDebit(uuid);
+ boolean confirmed = false;
try {
+ drainCache(user, pointsColumn);
SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql())
.add(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis());
+ confirmed = true;
return new AddResult(true, result.total());
} catch (SQLException failure) {
logFailure(failure);
return new AddResult(MutationOutcome.INDETERMINATE, 0);
} finally {
- discardPointsCache(user, pointsColumn);
+ boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), pointsColumn);
+ if (placeholders != null) {
+ var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(uuid);
+ boolean available = false;
+ if (cache != null) synchronized (cache) {
+ available = refreshed && confirmed && cache.hasPublishedStorageSnapshot();
+ }
+ placeholders.finishSharedPointDebit(uuid, confirmed, available);
+ }
}
}
@@ -209,18 +222,36 @@ AddResult removeCommitted(VotingPluginUser user, int amount, String operationId)
AddResult removeCommittedFromColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) {
if (operationId == null || operationId.isEmpty()) return new AddResult(false, 0);
- drainCache(user, pointsColumn);
+ var placeholders = plugin.getPlaceholders();
+ UUID uuid = UUID.fromString(user.getUUID());
+ if (placeholders != null) placeholders.beginSharedPointDebit(uuid);
+ AddResult outcome = new AddResult(MutationOutcome.INDETERMINATE, 0);
+ boolean refreshed;
try {
+ drainCache(user, pointsColumn);
SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql())
.subtract(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis());
- return new AddResult(true, result.total());
+ outcome = new AddResult(true, result.total());
} catch (SQLException failure) {
- logFailure(failure);
- return new AddResult(failure instanceof SharedPointAdditionJournal.DebitRejectedException
- ? MutationOutcome.REJECTED : MutationOutcome.INDETERMINATE, 0);
+ if (failure instanceof SharedPointAdditionJournal.DebitRejectedException) {
+ outcome = new AddResult(MutationOutcome.REJECTED, 0);
+ } else {
+ logFailure(failure);
+ outcome = new AddResult(MutationOutcome.INDETERMINATE, 0);
+ }
} finally {
- discardPointsCache(user, pointsColumn);
+ refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), pointsColumn);
+ if (placeholders != null) {
+ var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(uuid);
+ boolean available = false;
+ if (cache != null) synchronized (cache) {
+ available = refreshed && outcome.outcome() != MutationOutcome.INDETERMINATE
+ && cache.hasPublishedStorageSnapshot();
+ }
+ placeholders.finishSharedPointDebit(uuid, outcome.outcome() != MutationOutcome.INDETERMINATE, available);
+ }
}
+ return new AddResult(outcome.outcome(), outcome.total(), refreshed);
}
/** Looks up a completed retry before its Bukkit receive event is dispatched. */
@@ -326,7 +357,20 @@ void acknowledgePointAdditionNow(String operationId) {
boolean set(VotingPluginUser user, int value, boolean async) {
String pointsColumn = user.getPointsPath();
- return run(() -> setAbsolute(user, value, pointsColumn), async);
+ var placeholders = plugin.getPlaceholders();
+ Consumer admission = placeholders == null ? null
+ : placeholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID()));
+ boolean accepted = run(() -> {
+ boolean settled = false;
+ try {
+ setAbsolute(user, value, pointsColumn);
+ settled = true;
+ } finally {
+ if (admission != null) admission.accept(settled);
+ }
+ }, async);
+ if (!accepted && admission != null) admission.accept(false);
+ return accepted;
}
boolean setCommitted(VotingPluginUser user, int value) {
@@ -498,41 +542,40 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target
org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : sourcePlayer;
String sourcePoints = source.getPointsPath();
String targetPoints = target.getPointsPath();
+ var reservationPublication = beginPointPublication(source);
try {
plugin.getTimer().execute(() -> {
- try {
- drainCache(source);
- drainCache(target);
- } catch (RuntimeException cacheFailure) {
- plugin.debug(cacheFailure);
- completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
- return;
- }
MySQL table = plugin.getMysql();
String transferId = UUID.randomUUID().toString();
String owner = UUID.randomUUID().toString();
- SharedPointTransferJournal journal;
+ SharedPointTransferJournal prepared = null;
+ boolean known = true;
+ boolean reserved = false;
+ PointTransferResult failed = PointTransferResult.UNAVAILABLE;
try {
- journal = SharedPointTransferJournal.forTable(table);
- recoverTransfers(plugin, journal);
- if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(),
- debitAmount, System.currentTimeMillis())) {
- completeOnBukkit(sourcePlayer, completion, PointTransferResult.INSUFFICIENT_POINTS);
- return;
- }
- // The source cache may have been recreated while the reservation was being
- // committed. Invalidate its points after the durable debit, before any later
- // dump can restore the pre-debit balance.
- discardPointsCache(source, sourcePoints);
+ drainCache(source);
+ drainCache(target);
+ prepared = SharedPointTransferJournal.forTable(table);
+ recoverTransfers(plugin, prepared);
+ known = false;
+ reserved = prepared.reserve(transferId, source.getUUID(), sourcePoints, debitAmount,
+ target.getUUID(), debitAmount, System.currentTimeMillis());
+ known = true;
+ failed = PointTransferResult.INSUFFICIENT_POINTS;
} catch (SQLException failure) {
- // An acknowledgement/confirmation failure can follow a committed
- // reservation debit. Never allow a cache recreated during the unknown
- // outcome to flush the pre-debit source balance over it.
- discardPointsCache(source, sourcePoints);
+ known |= failure instanceof SharedPointTransferJournal.PreparationFailure;
logFailure(failure);
- completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
+ } catch (RuntimeException cacheFailure) {
+ plugin.debug(cacheFailure);
+ } finally {
+ boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
+ finishPointPublication(reservationPublication, source, sourcePoints, known, refreshed);
+ }
+ if (!reserved) {
+ completeOnBukkit(sourcePlayer, completion, failed);
return;
}
+ SharedPointTransferJournal journal = prepared;
/*
* Do not claim the reservation until the Bukkit approval task has actually
@@ -562,6 +605,7 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target
// No reservation exists when the initial persistence task is rejected.
// Still complete the command contract on the source entity lane.
plugin.debug(schedulingFailure);
+ finishPointPublication(reservationPublication, source, sourcePoints, true, false);
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
}
}
@@ -574,8 +618,7 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser
System.currentTimeMillis());
if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) {
try {
- journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
- discardPointsCache(source, sourcePoints);
+ refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
} catch (SQLException failure) {
logFailure(failure);
}
@@ -713,10 +756,7 @@ private void compensateRejectedTransfer(VotingPluginUser source, org.bukkit.enti
} catch (SQLException markerFailure) {
boolean refunded = false;
try {
- refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
- if (refunded) {
- discardPointsCache(source, sourcePoints);
- }
+ refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
} catch (SQLException refundFailure) {
logFailure(refundFailure);
}
@@ -736,17 +776,24 @@ private void settleTransfer(VotingPluginUser source, VotingPluginUser target, or
int debitAmount,
Consumer completion, SharedPointTransferJournal journal, String transferId, String owner,
String sourcePoints, String targetPoints, Integer approvedAmount) {
+ var sourcePublication = beginPointPublication(source);
+ var targetPublication = beginPointPublication(target);
+ boolean known = false;
PointTransferResult result;
try {
// The hook may have recreated either cache while it ran on Bukkit.
- drainCache(target);
+
SharedPointTransferJournal.SettlementOutcome outcome;
try {
+ drainCache(target);
outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints,
target.getUUID(), targetPoints, debitAmount, approvedAmount);
+ known = outcome != SharedPointTransferJournal.SettlementOutcome.INDETERMINATE;
} finally {
- discardPointsCache(source, sourcePoints);
- discardPointsCache(target, targetPoints);
+ boolean sourceRefreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
+ boolean targetRefreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, target.getUUID(), targetPoints);
+ finishPointPublication(sourcePublication, source, sourcePoints, known, sourceRefreshed);
+ finishPointPublication(targetPublication, target, targetPoints, known, targetRefreshed);
}
result = outcome == SharedPointTransferJournal.SettlementOutcome.COMPLETED ? PointTransferResult.SUCCESS
: outcome == SharedPointTransferJournal.SettlementOutcome.REFUNDED ? PointTransferResult.CANCELLED
@@ -760,14 +807,29 @@ private void settleTransfer(VotingPluginUser source, VotingPluginUser target, or
completeOnBukkit(sourcePlayer, completion, result);
}
+ private boolean refundReservedWithPublication(VotingPluginUser source, SharedPointTransferJournal journal,
+ String transferId, String sourcePoints, int debitAmount) throws SQLException {
+ var publication = beginPointPublication(source);
+ boolean known = false;
+ try {
+ boolean refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
+ known = true;
+ return refunded;
+ } catch (SQLException failure) {
+ known = failure instanceof SharedPointTransferJournal.PreparationFailure;
+ throw failure;
+ } finally {
+ boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
+ finishPointPublication(publication, source, sourcePoints, known, refreshed);
+ }
+ }
+
private void refundReservedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer,
Consumer completion,
SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount,
RuntimeException failure) {
try {
- if (journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount)) {
- discardPointsCache(source, sourcePoints);
- }
+ refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
} catch (SQLException refundFailure) {
logFailure(refundFailure);
}
@@ -783,24 +845,23 @@ private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, org
// HOOK_STARTED is safe to compensate because the approval task has not
// been submitted yet. A RESERVED row is handled by its normal refund.
if (journal.markCompensating(transferId)) {
- refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
+ refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
} else {
- refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
+ refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
}
} catch (SQLException markerFailure) {
// A lost marker acknowledgement may still have committed. Both refund
// operations are idempotent and cover either durable pre-hook state.
try {
- refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
+ refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
if (!refunded) {
- refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
+ refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
}
} catch (SQLException refundFailure) {
logFailure(refundFailure);
}
logFailure(markerFailure);
}
- if (refunded) discardPointsCache(source, sourcePoints);
if (!refunded) {
rememberPendingCompensationMarker(plugin, transferId);
logIndeterminateClaim(transferId);
@@ -815,19 +876,32 @@ void completeRejectedPersistenceSubmission(org.bukkit.entity.Player sourcePlayer
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
}
+ private boolean refundClaimedWithPublication(VotingPluginUser source, SharedPointTransferJournal journal,
+ String transferId, String sourcePoints, int debitAmount) throws SQLException {
+ var publication = beginPointPublication(source);
+ boolean known = false;
+ try {
+ boolean refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
+ known = true;
+ return refunded;
+ } catch (SQLException refundFailure) {
+ known = refundFailure instanceof SharedPointTransferJournal.PreparationFailure;
+ throw refundFailure;
+ } finally {
+ // A later guarded fallback or refill cannot erase an earlier ambiguous write.
+ boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
+ finishPointPublication(publication, source, sourcePoints, known, refreshed);
+ }
+ }
+
void refundClaimedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer,
Consumer completion,
SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount,
RuntimeException failure) {
try {
- journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
+ refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
} catch (SQLException refundFailure) {
logFailure(refundFailure);
- } finally {
- // The refund may have committed even when its acknowledgement and
- // confirmation read both failed. Invalidate a cache recreated after the
- // original debit so it cannot later overwrite either durable outcome.
- discardPointsCache(source, sourcePoints);
}
plugin.debug(failure);
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
@@ -990,9 +1064,12 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount, S
enum MutationOutcome { CONFIRMED, REJECTED, INDETERMINATE }
- record AddResult(MutationOutcome outcome, int total) {
+ record AddResult(MutationOutcome outcome, int total, boolean sourceRefreshed) {
+ AddResult(MutationOutcome outcome, int total) {
+ this(outcome, total, false);
+ }
AddResult(boolean success, int total) {
- this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total);
+ this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total, false);
}
boolean success() { return outcome == MutationOutcome.CONFIRMED; }
}
@@ -1002,21 +1079,29 @@ private boolean setAbsolute(VotingPluginUser user, int value) {
}
private boolean setAbsolute(VotingPluginUser user, int value, String points) {
- drainCache(user, points);
- MySQL table = plugin.getMysql();
- String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points)
- + " = ? WHERE " + table.qi("uuid")
- + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
- try (Connection connection = requireConnection(table);
- PreparedStatement statement = connection.prepareStatement(sql)) {
- statement.setInt(1, value);
- statement.setString(2, user.getUUID());
- return statement.executeUpdate() == 1;
- } catch (SQLException failure) {
- logFailure(failure);
- return false;
+ var publication = beginPointPublication(user);
+ boolean known = true;
+ try {
+ drainCache(user, points);
+ MySQL table = plugin.getMysql();
+ String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points)
+ + " = ? WHERE " + table.qi("uuid")
+ + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
+ try (Connection connection = requireConnection(table);
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setInt(1, value);
+ statement.setString(2, user.getUUID());
+ known = false;
+ int affected = statement.executeUpdate();
+ known = true;
+ return affected == 1;
+ } catch (SQLException failure) {
+ logFailure(failure);
+ return false;
+ }
} finally {
- discardPointsCache(user, points);
+ boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
+ finishPointPublication(publication, user, points, known, refreshed);
}
}
@@ -1101,6 +1186,27 @@ private static Connection requireConnection(MySQL table) throws SQLException {
* deliberately not discarded here because it is a distinct, later write and
* the generic absolute-value API cannot provide cross-server atomic semantics.
*/
+ private com.bencodez.votingplugin.placeholders.PlaceHolders beginPointPublication(VotingPluginUser user) {
+ var placeholders = plugin.getPlaceholders();
+ if (placeholders != null) placeholders.beginSharedPointDebit(UUID.fromString(user.getUUID()));
+ return placeholders;
+ }
+
+ private void finishPointPublication(com.bencodez.votingplugin.placeholders.PlaceHolders placeholders,
+ VotingPluginUser user, String column, boolean known, boolean refreshed) {
+ if (placeholders == null) return;
+ UUID uuid = UUID.fromString(user.getUUID());
+ var caches = plugin.getUserManager().getDataManager().getUserDataCache();
+ var cache = caches.get(uuid);
+ boolean available = false;
+ if (cache != null) synchronized (cache) {
+ available = known && refreshed && caches.get(uuid) == cache && cache.hasPublishedStorageSnapshot()
+ && column.equals(user.getPointsPath())
+ && plugin.isEnabled() && plugin.getPlaceholders() == placeholders;
+ }
+ placeholders.finishSharedPointDebit(uuid, known, available);
+ }
+
private void discardPointsCache(VotingPluginUser user) {
discardPointsCache(user, user.getPointsPath());
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java
index c2f7281ee8..42cfa062b4 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java
@@ -28,6 +28,14 @@
* settlement is a second short, idempotent transaction.
*/
final class SharedPointTransferJournal {
+ /** Positive proof that this invocation did not attempt a points write. */
+ static final class PreparationFailure extends SQLException {
+ private static final long serialVersionUID = 1L;
+ PreparationFailure(SQLException cause) {
+ super(cause.getMessage(), cause.getSQLState(), cause.getErrorCode(), cause);
+ }
+ }
+
private static final String RESERVED = "RESERVED";
private static final String HOOK_STARTED = "HOOK_STARTED";
/*
@@ -144,51 +152,59 @@ public boolean equals(Object other) {
*/
boolean reserve(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints, String targetUuid,
int requestedCreditPoints, long now) throws SQLException {
- TransferRow existing = find(transferId);
- if (existing != null) {
- return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints)
- && !REFUNDED.equals(existing.state);
- }
+ boolean pointWriteAttempted = false;
+ try {
+ TransferRow existing = find(transferId);
+ if (existing != null) {
+ return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints)
+ && !REFUNDED.equals(existing.state);
+ }
- String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid")
- + ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points")
- + ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at")
- + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
- String points = qi(sourcePointsColumn);
- String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
- + " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?";
- try (Connection connection = connection()) {
- connection.setAutoCommit(false);
- try (PreparedStatement insertStatement = connection.prepareStatement(insert);
- PreparedStatement debitStatement = connection.prepareStatement(debit)) {
- insertStatement.setString(1, transferId);
- insertStatement.setString(2, sourceUuid);
- insertStatement.setString(3, sourcePointsColumn);
- insertStatement.setString(4, targetUuid);
- insertStatement.setInt(5, debitPoints);
- insertStatement.setInt(6, requestedCreditPoints);
- insertStatement.setString(7, RESERVED);
- insertStatement.setLong(8, now);
- insertStatement.executeUpdate();
-
- debitStatement.setInt(1, debitPoints);
- debitStatement.setString(2, sourceUuid);
- debitStatement.setInt(3, debitPoints);
- if (debitStatement.executeUpdate() != 1) {
- connection.rollback();
- return false;
- }
- return commitAndConfirm(connection, transferId, RESERVED);
- } catch (SQLException failure) {
- rollback(connection);
- if (isDuplicate(failure)) {
- closeQuietly(connection);
- TransferRow duplicate = find(transferId);
- return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints,
- requestedCreditPoints) && !REFUNDED.equals(duplicate.state);
+ String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid")
+ + ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points")
+ + ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at")
+ + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
+ String points = qi(sourcePointsColumn);
+ String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
+ + " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?";
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try (PreparedStatement insertStatement = connection.prepareStatement(insert);
+ PreparedStatement debitStatement = connection.prepareStatement(debit)) {
+ insertStatement.setString(1, transferId);
+ insertStatement.setString(2, sourceUuid);
+ insertStatement.setString(3, sourcePointsColumn);
+ insertStatement.setString(4, targetUuid);
+ insertStatement.setInt(5, debitPoints);
+ insertStatement.setInt(6, requestedCreditPoints);
+ insertStatement.setString(7, RESERVED);
+ insertStatement.setLong(8, now);
+ insertStatement.executeUpdate();
+
+ debitStatement.setInt(1, debitPoints);
+ debitStatement.setString(2, sourceUuid);
+ debitStatement.setInt(3, debitPoints);
+ pointWriteAttempted = true;
+ if (debitStatement.executeUpdate() != 1) {
+ connection.rollback();
+ return false;
+ }
+ return commitAndConfirm(connection, transferId, RESERVED);
+ } catch (SQLException failure) {
+ rollback(connection);
+ if (isDuplicate(failure)) {
+ closeQuietly(connection);
+ TransferRow duplicate = find(transferId);
+ return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints,
+ requestedCreditPoints) && !REFUNDED.equals(duplicate.state);
+ }
+ throw failure;
}
- throw failure;
}
+ } catch (SQLException failure) {
+ if (failure instanceof PreparationFailure) throw failure;
+ if (!pointWriteAttempted) throw new PreparationFailure(failure);
+ throw failure;
}
}
@@ -286,42 +302,50 @@ boolean markCompensating(String transferId) throws SQLException {
*/
boolean refundReserved(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints)
throws SQLException {
- if (!isSafeColumn(sourcePointsColumn)) return false;
- String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id")
- + " = ? FOR UPDATE";
- String points = qi(sourcePointsColumn);
- String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
- + " + ? WHERE " + qi("uuid") + uuidCast();
- String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id")
- + " = ?";
- try (Connection connection = connection()) {
- connection.setAutoCommit(false);
- try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
- selectStatement.setString(1, transferId);
- try (ResultSet result = selectStatement.executeQuery()) {
- if (!result.next() || !RESERVED.equals(result.getString(1))) {
+ boolean pointWriteAttempted = false;
+ try {
+ if (!isSafeColumn(sourcePointsColumn)) return false;
+ String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id")
+ + " = ? FOR UPDATE";
+ String points = qi(sourcePointsColumn);
+ String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
+ + " + ? WHERE " + qi("uuid") + uuidCast();
+ String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id")
+ + " = ?";
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
+ selectStatement.setString(1, transferId);
+ try (ResultSet result = selectStatement.executeQuery()) {
+ if (!result.next() || !RESERVED.equals(result.getString(1))) {
+ connection.rollback();
+ return false;
+ }
+ }
+ }
+ try (PreparedStatement refundStatement = connection.prepareStatement(refund)) {
+ refundStatement.setInt(1, debitPoints);
+ refundStatement.setString(2, sourceUuid);
+ pointWriteAttempted = true;
+ if (refundStatement.executeUpdate() != 1) {
connection.rollback();
return false;
}
}
- }
- try (PreparedStatement refundStatement = connection.prepareStatement(refund)) {
- refundStatement.setInt(1, debitPoints);
- refundStatement.setString(2, sourceUuid);
- if (refundStatement.executeUpdate() != 1) {
- connection.rollback();
- return false;
- }
- }
- try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
- updateStatement.setString(1, REFUNDED);
- updateStatement.setString(2, transferId);
- if (updateStatement.executeUpdate() != 1) {
- connection.rollback();
- return false;
+ try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
+ updateStatement.setString(1, REFUNDED);
+ updateStatement.setString(2, transferId);
+ if (updateStatement.executeUpdate() != 1) {
+ connection.rollback();
+ return false;
+ }
}
+ return commitAndConfirm(connection, transferId, REFUNDED);
}
- return commitAndConfirm(connection, transferId, REFUNDED);
+ } catch (SQLException failure) {
+ if (failure instanceof PreparationFailure) throw failure;
+ if (!pointWriteAttempted) throw new PreparationFailure(failure);
+ throw failure;
}
}
@@ -340,6 +364,7 @@ boolean refundHookStarted(String transferId, String sourceUuid, String sourcePoi
+ " + ? WHERE " + qi("uuid") + uuidCast();
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id")
+ " = ?";
+ boolean pointWriteAttempted = false;
try (Connection connection = connection()) {
connection.setAutoCommit(false);
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
@@ -363,6 +388,7 @@ boolean refundHookStarted(String transferId, String sourceUuid, String sourcePoi
try (PreparedStatement refundStatement = connection.prepareStatement(refund)) {
refundStatement.setInt(1, debitPoints);
refundStatement.setString(2, sourceUuid);
+ pointWriteAttempted = true;
if (refundStatement.executeUpdate() != 1) {
connection.rollback();
return false;
@@ -378,6 +404,7 @@ boolean refundHookStarted(String transferId, String sourceUuid, String sourcePoi
}
return commitAndConfirm(connection, transferId, REFUNDED);
} catch (SQLException failure) {
+ if (!pointWriteAttempted) throw new PreparationFailure(failure);
throw failure;
}
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java
index d985aa23e8..3dccd557d6 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java
@@ -1,19 +1,19 @@
-package com.bencodez.votingplugin.user;
-
+package com.bencodez.votingplugin.user;
+
import java.text.SimpleDateFormat;
import java.sql.SQLException;
-import java.time.Duration;
-import java.time.Instant;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
import java.util.Map.Entry;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -22,41 +22,41 @@
import java.util.concurrent.ConcurrentMap;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
-import java.util.stream.Collectors;
-
-import org.bukkit.Bukkit;
-import org.bukkit.entity.Player;
-
-import com.bencodez.advancedcore.api.messages.PlaceholderUtils;
-import com.bencodez.advancedcore.api.misc.MiscUtils;
-import com.bencodez.advancedcore.api.rewards.RewardBuilder;
-import com.bencodez.advancedcore.api.rewards.RewardOptions;
+import java.util.stream.Collectors;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+
+import com.bencodez.advancedcore.api.messages.PlaceholderUtils;
+import com.bencodez.advancedcore.api.misc.MiscUtils;
+import com.bencodez.advancedcore.api.rewards.RewardBuilder;
+import com.bencodez.advancedcore.api.rewards.RewardOptions;
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
import com.bencodez.advancedcore.api.user.UserStorage;
import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
-import com.bencodez.simpleapi.messages.MessageAPI;
-import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.messages.MessageAPI;
+import com.bencodez.simpleapi.sql.data.DataValue;
import com.bencodez.simpleapi.sql.data.DataValueInt;
-import com.bencodez.simpleapi.sql.data.DataValueString;
-import com.bencodez.simpleapi.time.ParsedDuration;
-import com.bencodez.votingplugin.VotingPluginMain;
-import com.bencodez.votingplugin.events.PlayerReceivePointsEvent;
-import com.bencodez.votingplugin.events.PlayerSpecialRewardEvent;
-import com.bencodez.votingplugin.events.PlayerVoteEvent;
-import com.bencodez.votingplugin.events.SpecialRewardType;
-import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot;
-import com.bencodez.votingplugin.topvoter.TopVoter;
+import com.bencodez.simpleapi.sql.data.DataValueString;
+import com.bencodez.simpleapi.time.ParsedDuration;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.events.PlayerReceivePointsEvent;
+import com.bencodez.votingplugin.events.PlayerSpecialRewardEvent;
+import com.bencodez.votingplugin.events.PlayerVoteEvent;
+import com.bencodez.votingplugin.events.SpecialRewardType;
+import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot;
+import com.bencodez.votingplugin.topvoter.TopVoter;
import com.bencodez.votingplugin.topvoter.TopVoterPlayer;
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
-import com.bencodez.votingplugin.votesites.NextSite;
-import com.bencodez.votingplugin.votesites.VoteSite;
-
-/**
- * The Class VotingPluginUser. This class represents a user in the VotingPlugin
- * system. It extends the AdvancedCoreUser class and provides additional
- * functionality specific to the VotingPlugin.
- */
+import com.bencodez.votingplugin.votesites.NextSite;
+import com.bencodez.votingplugin.votesites.VoteSite;
+
+/**
+ * The Class VotingPluginUser. This class represents a user in the VotingPlugin
+ * system. It extends the AdvancedCoreUser class and provides additional
+ * functionality specific to the VotingPlugin.
+ */
public class VotingPluginUser extends com.bencodez.advancedcore.api.user.AdvancedCoreUser {
private static final int BULK_POINT_BATCH_SIZE = 64;
/** Durable record of an offline reward batch not yet fully confirmed. */
@@ -84,137 +84,137 @@ static void endOfflineVoteBulkClear() {
private static final ConcurrentMap> IN_FLIGHT_POINT_REPLAYS =
new ConcurrentHashMap<>();
-
- /** The plugin instance. */
- private VotingPluginMain plugin;
-
- /**
- * Instantiates a new VotingPluginUser.
- *
- * @param plugin the plugin instance
- * @param user the AdvancedCoreUser instance
- */
- public VotingPluginUser(VotingPluginMain plugin, AdvancedCoreUser user) {
- super(plugin, user);
- this.plugin = plugin;
- }
-
- /**
- * Instantiates a new VotingPluginUser.
- *
- * @param plugin the plugin instance
- * @param player the player instance
- * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
- * instead.
- */
- @Deprecated
- public VotingPluginUser(VotingPluginMain plugin, Player player) {
- super(plugin, player);
- this.plugin = plugin;
- }
-
- /**
- * Instantiates a new VotingPluginUser.
- *
- * @param plugin the plugin instance
- * @param playerName the player name
- * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
- * instead.
- */
- @Deprecated
- public VotingPluginUser(VotingPluginMain plugin, String playerName) {
- super(plugin, playerName);
- this.plugin = plugin;
- }
-
- /**
- * Instantiates a new VotingPluginUser.
- *
- * @param plugin the plugin instance
- * @param uuid the UUID of the player
- * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
- * instead.
- */
- @Deprecated
- public VotingPluginUser(VotingPluginMain plugin, UUID uuid) {
- super(plugin, uuid);
- this.plugin = plugin;
- }
-
- /**
- * Instantiates a new VotingPluginUser.
- *
- * @param plugin the plugin instance
- * @param uuid the UUID of the player
- * @param loadName whether to load the player name
- * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
- * instead.
- */
- @Deprecated
- public VotingPluginUser(VotingPluginMain plugin, UUID uuid, boolean loadName) {
- super(plugin, uuid, loadName);
- this.plugin = plugin;
- }
-
- /**
- * Instantiates a new VotingPluginUser.
- *
- * @param plugin the plugin instance
- * @param uuid the UUID of the player
- * @param playerName the player name
- * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
- * instead.
- */
- @Deprecated
- public VotingPluginUser(VotingPluginMain plugin, UUID uuid, String playerName) {
- super(plugin, uuid, playerName);
- this.plugin = plugin;
- }
-
- /**
- * Adds one to the all-time total votes.
- */
- public void addAllTimeTotal() {
- setAllTimeTotal(getAllTimeTotal() + 1);
- }
-
- /**
- * Adds one to the daily vote streak.
- */
- @Deprecated
- public void addDayVoteStreak() {
- setDayVoteStreak(getDayVoteStreak() + 1);
- }
-
- /**
- * Adds one to the monthly total votes.
- */
- public void addMonthTotal() {
- setMonthTotal(getMonthTotal() + 1);
- }
-
- /**
- * Adds one to the monthly vote streak.
- */
- @Deprecated
- public void addMonthVoteStreak() {
- setMonthVoteStreak(getMonthVoteStreak() + 1);
- }
-
- /**
- * Adds an offline vote for the specified vote site.
- *
- * @param voteSiteName the name of the vote site
- */
- public void addOfflineVote(String voteSiteName) {
- ArrayList offlineVotes = getOfflineVotes();
- offlineVotes.add(voteSiteName);
- setOfflineVotes(offlineVotes);
- }
-
- /**
- * Adds points to the user based on the configuration.
- */
+
+ /** The plugin instance. */
+ private VotingPluginMain plugin;
+
+ /**
+ * Instantiates a new VotingPluginUser.
+ *
+ * @param plugin the plugin instance
+ * @param user the AdvancedCoreUser instance
+ */
+ public VotingPluginUser(VotingPluginMain plugin, AdvancedCoreUser user) {
+ super(plugin, user);
+ this.plugin = plugin;
+ }
+
+ /**
+ * Instantiates a new VotingPluginUser.
+ *
+ * @param plugin the plugin instance
+ * @param player the player instance
+ * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
+ * instead.
+ */
+ @Deprecated
+ public VotingPluginUser(VotingPluginMain plugin, Player player) {
+ super(plugin, player);
+ this.plugin = plugin;
+ }
+
+ /**
+ * Instantiates a new VotingPluginUser.
+ *
+ * @param plugin the plugin instance
+ * @param playerName the player name
+ * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
+ * instead.
+ */
+ @Deprecated
+ public VotingPluginUser(VotingPluginMain plugin, String playerName) {
+ super(plugin, playerName);
+ this.plugin = plugin;
+ }
+
+ /**
+ * Instantiates a new VotingPluginUser.
+ *
+ * @param plugin the plugin instance
+ * @param uuid the UUID of the player
+ * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
+ * instead.
+ */
+ @Deprecated
+ public VotingPluginUser(VotingPluginMain plugin, UUID uuid) {
+ super(plugin, uuid);
+ this.plugin = plugin;
+ }
+
+ /**
+ * Instantiates a new VotingPluginUser.
+ *
+ * @param plugin the plugin instance
+ * @param uuid the UUID of the player
+ * @param loadName whether to load the player name
+ * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
+ * instead.
+ */
+ @Deprecated
+ public VotingPluginUser(VotingPluginMain plugin, UUID uuid, boolean loadName) {
+ super(plugin, uuid, loadName);
+ this.plugin = plugin;
+ }
+
+ /**
+ * Instantiates a new VotingPluginUser.
+ *
+ * @param plugin the plugin instance
+ * @param uuid the UUID of the player
+ * @param playerName the player name
+ * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)}
+ * instead.
+ */
+ @Deprecated
+ public VotingPluginUser(VotingPluginMain plugin, UUID uuid, String playerName) {
+ super(plugin, uuid, playerName);
+ this.plugin = plugin;
+ }
+
+ /**
+ * Adds one to the all-time total votes.
+ */
+ public void addAllTimeTotal() {
+ setAllTimeTotal(getAllTimeTotal() + 1);
+ }
+
+ /**
+ * Adds one to the daily vote streak.
+ */
+ @Deprecated
+ public void addDayVoteStreak() {
+ setDayVoteStreak(getDayVoteStreak() + 1);
+ }
+
+ /**
+ * Adds one to the monthly total votes.
+ */
+ public void addMonthTotal() {
+ setMonthTotal(getMonthTotal() + 1);
+ }
+
+ /**
+ * Adds one to the monthly vote streak.
+ */
+ @Deprecated
+ public void addMonthVoteStreak() {
+ setMonthVoteStreak(getMonthVoteStreak() + 1);
+ }
+
+ /**
+ * Adds an offline vote for the specified vote site.
+ *
+ * @param voteSiteName the name of the vote site
+ */
+ public void addOfflineVote(String voteSiteName) {
+ ArrayList offlineVotes = getOfflineVotes();
+ offlineVotes.add(voteSiteName);
+ setOfflineVotes(offlineVotes);
+ }
+
+ /**
+ * Adds points to the user based on the configuration.
+ */
public void addPoints() {
int points = plugin.getConfigFile().getPointsOnVote();
SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
@@ -244,37 +244,37 @@ public void addPoints() {
} else if (getPoints() > limit) {
setPoints(limit);
}
- }
- }
-
- /**
- * Adds the specified number of points to the user.
- *
- * @param value the number of points to add
- * @return the current total points
- */
- public int addPoints(int value) {
- return addPoints(value, false);
- }
-
- /**
- * Adds the specified number of points to the user, optionally asynchronously.
+ }
+ }
+
+ /**
+ * Adds the specified number of points to the user.
+ *
+ * @param value the number of points to add
+ * @return the current total points
+ */
+ public int addPoints(int value) {
+ return addPoints(value, false);
+ }
+
+ /**
+ * Adds the specified number of points to the user, optionally asynchronously.
* MySQL mutations from platform-owned threads are always deferred; the return
* value is predicted in that case. Use the storage-aware completion API for
* a confirmed committed balance. Worker-thread synchronous calls still wait
* for the mutation and authoritative cache refill.
- *
- * @param value the number of points to add
- * @param async whether to add the points asynchronously
- * @return the current total points
- */
+ *
+ * @param value the number of points to add
+ * @param async whether to add the points asynchronously
+ * @return the current total points
+ */
public synchronized int addPoints(int value, boolean async) {
- PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value);
- Bukkit.getPluginManager().callEvent(event);
-
- if (event.isCancelled()) {
- return getPoints();
- }
+ PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value);
+ Bukkit.getPluginManager().callEvent(event);
+
+ if (event.isCancelled()) {
+ return getPoints();
+ }
SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
if (sharedPoints.usesMysqlPointMutations()) {
// Platform-owned callers use the existing asynchronous contract; JDBC and
@@ -317,6 +317,38 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu
* @return committed point total, or an exceptional stage when persistence fails
*/
public synchronized CompletionStage addPointsStorageAwareAsync(int value, String operationId) {
+ if (!new SharedMysqlPointMutator(plugin).usesMysqlPointMutations()) return addPointsStorageAwareAsyncUnfenced(value, operationId);
+ var placeholders = plugin.getPlaceholders();
+ if (placeholders == null) return addPointsStorageAwareAsyncUnfenced(value, operationId);
+ UUID uuid = UUID.fromString(getUUID());
+ String column = getPointsPath();
+ placeholders.beginSharedPointDebit(uuid);
+ var finished = new java.util.concurrent.atomic.AtomicBoolean();
+ java.util.function.BiConsumer finish = (total, failure) -> {
+ if (!finished.compareAndSet(false, true)) return;
+ boolean known = failure == null || failure instanceof java.util.concurrent.RejectedExecutionException
+ || failure instanceof PointAdditionNotStartedException;
+ var caches = plugin.getUserManager().getDataManager().getUserDataCache();
+ var cache = caches.get(uuid);
+ boolean available = false;
+ if (cache != null) synchronized (cache) {
+ available = failure == null && caches.get(uuid) == cache && cache.hasPublishedStorageSnapshot()
+ && column.equals(getPointsPath()) && plugin.isEnabled()
+ && plugin.getPlaceholders() == placeholders;
+ }
+ placeholders.finishSharedPointDebit(uuid, known, available);
+ };
+ try {
+ CompletionStage completion = addPointsStorageAwareAsyncUnfenced(value, operationId);
+ completion.whenComplete(finish);
+ return completion;
+ } catch (RuntimeException | Error failure) {
+ finish.accept(null, failure);
+ throw failure;
+ }
+ }
+
+ private CompletionStage addPointsStorageAwareAsyncUnfenced(int value, String operationId) {
SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
if (!sharedPoints.applies() && operationId != null && !operationId.isEmpty()
&& SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)) {
@@ -381,6 +413,7 @@ private CompletionStage addPerServerPointsWithReplayClaim(SharedMysqlPo
SharedPointAdditionJournal.HookClaim claim = sharedPoints.claimPointAdditionHook(operationId, uuid,
journalPointsPath, value, claimOwner);
if (claim.completed()) {
+ SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(plugin, getUUID(), getPointsPath());
completion.complete(claim.total());
return;
}
@@ -499,6 +532,7 @@ private CompletionStage addSharedPointsWithReplayLookup(SharedMysqlPoin
SharedPointAdditionJournal.HookClaim claim = sharedPoints.claimPointAdditionHook(operationId, uuid,
pointsPath, value, claimOwner);
if (claim.completed()) {
+ SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(plugin, getUUID(), getPointsPath());
completion.complete(claim.total());
return;
}
@@ -579,13 +613,18 @@ private void reportIndeterminateSharedPointAddition(SharedMysqlPointMutator shar
* deliberately retained for reconciliation instead of making a retry unsafe.
* Started hooks still use the indeterminate path above.
*/
+ private static final class PointAdditionNotStartedException extends IllegalStateException {
+ private static final long serialVersionUID = 1L;
+ PointAdditionNotStartedException() { super("Shared MySQL point addition was not started; retry is safe"); }
+ }
+
private void releaseUnstartedSharedPointAddition(SharedMysqlPointMutator sharedPoints, String operationId,
String uuid, String pointsPath, int requestedAmount, String claimOwner, CompletableFuture completion) {
Runnable release = () -> {
try {
sharedPoints.releaseUnstartedPointAdditionHook(operationId, uuid, pointsPath, requestedAmount, claimOwner);
completion.completeExceptionally(
- new IllegalStateException("Shared MySQL point addition was not started; retry is safe"));
+ new PointAdditionNotStartedException());
} catch (SQLException failure) {
plugin.getLogger().severe("Unable to release unstarted shared MySQL point addition " + operationId
+ "; retaining its claim for manual reconciliation");
@@ -751,12 +790,23 @@ public void setPointsStorageAware(int value, Consumer completion) {
}
Player player = getPlayer();
String pointsColumn = getPointsPath();
+ var placeholders = plugin.getPlaceholders();
+ Consumer admission = placeholders == null ? null
+ : placeholders.sharedPointAdmissionCompletion(UUID.fromString(getUUID()));
try {
plugin.getTimer().execute(() -> {
- boolean updated = sharedPoints.setCommittedInColumn(this, value, pointsColumn);
+ boolean updated;
+ boolean settled = false;
+ try {
+ updated = sharedPoints.setCommittedInColumn(this, value, pointsColumn);
+ settled = true;
+ } finally {
+ if (admission != null) admission.accept(settled);
+ }
BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(updated));
});
} catch (RuntimeException rejected) {
+ if (admission != null) admission.accept(false);
plugin.debug(rejected);
BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(false));
}
@@ -847,12 +897,39 @@ private static void bulkSharedMysqlMutation(VotingPluginMain plugin, List players = new ArrayList<>(users.size());
for (VotingPluginUser user : users) players.add(capturedPlayers.get(user));
- submitSharedMysqlChunk(plugin, users, players, 0, completion, sharedMutation);
+ java.util.IdentityHashMap>> admissions = new java.util.IdentityHashMap<>();
+ for (VotingPluginUser user : users) {
+ var placeholders = plugin.getPlaceholders();
+ if (placeholders != null) {
+ Consumer admission = placeholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID()));
+ if (admission != null) admissions.computeIfAbsent(user, ignored -> new java.util.ArrayDeque<>()).addLast(admission);
+ }
+ }
+ Consumer abandon = user -> {
+ var queue = admissions.get(user);
+ Consumer admission = null;
+ if (queue != null) synchronized (queue) { admission = queue.pollFirst(); }
+ if (admission != null) admission.accept(false);
+ };
+ SharedPointMutation guarded = (mutator, user) -> {
+ var queue = admissions.get(user);
+ Consumer admission = null;
+ if (queue != null) synchronized (queue) { admission = queue.pollFirst(); }
+ boolean settled = false;
+ try {
+ boolean result = sharedMutation.apply(mutator, user);
+ settled = true;
+ return result;
+ } finally {
+ if (admission != null) admission.accept(settled);
+ }
+ };
+ submitSharedMysqlChunk(plugin, users, players, 0, completion, guarded, abandon);
}
private static void submitSharedMysqlChunk(VotingPluginMain plugin, List users, List players,
int start,
- BiConsumer completion, SharedPointMutation sharedMutation) {
+ BiConsumer completion, SharedPointMutation sharedMutation, Consumer abandon) {
int end = Math.min(start + BULK_POINT_BATCH_SIZE, users.size());
Runnable persistenceWork = () -> {
boolean[] results = new boolean[end - start];
@@ -866,13 +943,14 @@ private static void submitSharedMysqlChunk(VotingPluginMain plugin, List completion.accept(false, 0));
}
}
-
- /**
- * Adds one to the total votes.
- */
- public void addTotal() {
- addMonthTotal();
- addAllTimeTotal();
- }
-
- /**
- * Adds one to the daily total votes.
- */
- public void addTotalDaily() {
- setDailyTotal(getDailyTotal() + 1);
- }
-
- /**
- * Adds one to the weekly total votes.
- */
- public void addTotalWeekly() {
- setWeeklyTotal(getWeeklyTotal() + 1);
- }
-
- /**
- * Adds one to the weekly vote streak.
- */
- @Deprecated
- public void addWeekVoteStreak() {
- setWeekVoteStreak(getWeekVoteStreak() + 1);
- }
-
- /**
- * Handles a plugin messaging bungee vote.
- *
- * @param service the service name
- * @param time the vote time
- * @param text the bungee message data
- * @param setTotals whether to set the totals
- * @param wasOnline whether the player was online
- * @param broadcast whether to broadcast the vote
- * @param num the vote number
- */
+
+ /**
+ * Adds one to the total votes.
+ */
+ public void addTotal() {
+ addMonthTotal();
+ addAllTimeTotal();
+ }
+
+ /**
+ * Adds one to the daily total votes.
+ */
+ public void addTotalDaily() {
+ setDailyTotal(getDailyTotal() + 1);
+ }
+
+ /**
+ * Adds one to the weekly total votes.
+ */
+ public void addTotalWeekly() {
+ setWeeklyTotal(getWeeklyTotal() + 1);
+ }
+
+ /**
+ * Adds one to the weekly vote streak.
+ */
+ @Deprecated
+ public void addWeekVoteStreak() {
+ setWeekVoteStreak(getWeekVoteStreak() + 1);
+ }
+
+ /**
+ * Handles a plugin messaging bungee vote.
+ *
+ * @param service the service name
+ * @param time the vote time
+ * @param text the bungee message data
+ * @param setTotals whether to set the totals
+ * @param wasOnline whether the player was online
+ * @param broadcast whether to broadcast the vote
+ * @param num the vote number
+ */
public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals,
boolean wasOnline, boolean broadcast, int num) {
bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, false, false, null);
@@ -1013,19 +1091,19 @@ public PlayerVoteEvent bungeeVotePluginMessagingEvent(String service, long time,
boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId,
boolean targetedProxyVote) {
if (plugin.getBungeeSettings().isUseBungeecoord()) {
- plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service);
-
- PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true),
- getPlayerName(), service, true);
- voteEvent.setBungee(true);
- voteEvent.setVotingPluginUser(this);
- voteEvent.setForceBungee(true);
- voteEvent.setTime(time);
- voteEvent.setAddTotals(setTotals);
+ plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service);
+
+ PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true),
+ getPlayerName(), service, true);
+ voteEvent.setBungee(true);
+ voteEvent.setVotingPluginUser(this);
+ voteEvent.setForceBungee(true);
+ voteEvent.setTime(time);
+ voteEvent.setAddTotals(setTotals);
voteEvent.setBungeeTextTotals(text);
voteEvent.setProxyVoteId(proxyVoteId);
- voteEvent.setWasOnline(wasOnline);
- voteEvent.setBroadcast(broadcast);
+ voteEvent.setWasOnline(wasOnline);
+ voteEvent.setBroadcast(broadcast);
voteEvent.setVoteNumber(num);
voteEvent.setQueuedProxyVote(queuedProxyVote);
voteEvent.setProxyQueueClassificationKnown(proxyQueueClassificationKnown);
@@ -1033,1168 +1111,1168 @@ public PlayerVoteEvent bungeeVotePluginMessagingEvent(String service, long time,
voteEvent.setTargetedProxyVote(targetedProxyVote);
plugin.getServer().getPluginManager().callEvent(voteEvent);
return voteEvent;
- }
+ }
return null;
- }
-
- /**
- * Checks if the user can vote on all sites.
- *
- * @return true, if the user can vote on all sites
- */
- public boolean canVoteAll() {
- for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (!voteSite.isHidden()) {
- boolean canVote = canVoteSite(voteSite);
- if (!canVote) {
- return false;
- }
- }
- }
- return true;
- }
-
- /**
- * Checks if the user can vote on any site.
- *
- * @return true, if the user can vote on any site
- */
- public boolean canVoteAny() {
- for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (!voteSite.isIgnoreCanVote() && !voteSite.isHidden()) {
- boolean canVote = canVoteSite(voteSite);
- if (canVote) {
- return true;
- }
- }
- }
- return false;
- }
-
- /**
- * Checks if the user can vote on the specified site.
- *
- * @param voteSite the vote site
- * @return true, if the user can vote on the site
- */
- public boolean canVoteSite(VoteSite voteSite) {
- long time = getTime(voteSite);
- if (time == 0) {
- return true;
- }
- try {
- LocalDateTime now = plugin.getTimeChecker().getTime();
- LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault())
- .plusHours(plugin.getOptions().getTimeHourOffSet());
-
- if (!voteSite.isVoteDelayDaily()) {
- ParsedDuration voteDelay = voteSite.getVoteDelay();
-
- // Preserve old behavior: if delay is 0, you can never vote again (unless daily
- // reset mode)
- if (voteDelay == null || voteDelay.isEmpty() || voteDelay.getMillis() == 0L) {
- return false;
- }
-
- LocalDateTime nextVote = lastVote.plus(Duration.ofMillis(voteDelay.getMillis()));
- return now.isAfter(nextVote);
- }
-
- // Daily reset logic unchanged
- LocalDateTime resetTime = lastVote.withHour(voteSite.getVoteDelayDailyHour()).withMinute(0).withSecond(0);
- LocalDateTime resetTimeTomorrow = resetTime.plusHours(24);
-
- if (lastVote.isBefore(resetTime)) {
- return now.isAfter(resetTime);
- } else {
- return now.isAfter(resetTimeTomorrow);
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- return false;
- }
-
- /**
- * Checks if the user has voted on all sites.
- *
- * @return true, if the user has voted on all sites
- */
- public boolean checkAllVotes() {
- VotingPluginUser user = this;
-
- ArrayList months = new ArrayList<>();
- ArrayList days = new ArrayList<>();
-
- for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (voteSite.isEnabled() && !voteSite.isHidden()) {
- long time = user.getTime(voteSite);
- if (time == 0) {
- return false;
- }
- months.add(MiscUtils.getInstance().getMonthFromMili(time));
- days.add(MiscUtils.getInstance().getDayFromMili(time));
- }
- }
-
- // check months
- for (Integer month : months) {
- if (!months.get(0).equals(month)) {
- return false;
- }
- }
-
- // check days
- for (Integer day : days) {
- if (!days.get(0).equals(day)) {
- return false;
- }
- }
-
- return true;
- }
-
- /**
- * Checks if the user has voted on almost all sites.
- *
- * @return true, if the user has voted on almost all sites
- */
- public boolean checkAlmostAllVotes() {
- if (getSitesNotVotedOn() <= 1) {
- return true;
- }
- return false;
- }
-
- /**
- * Checks the day vote streak and updates it if necessary.
- *
- * @param forceBungee whether to force bungee
- */
- @Deprecated
- public void checkDayVoteStreak(boolean forceBungee) {
- if (!voteStreakUpdatedToday(LocalDateTime.now())) {
- if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || hasPercentageTotal(
- TopVoter.Daily, plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)) {
- plugin.extraDebug("Adding day vote streak to " + getUUID() + " "
- + plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + " "
- + hasPercentageTotal(TopVoter.Daily,
- plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null));
- addDayVoteStreak();
- plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee);
- setDayVoteStreakLastUpdate(System.currentTimeMillis());
- }
- }
- }
-
- /**
- * Clears the offline votes.
- */
- public void clearOfflineVotes() {
- setOfflineVotes(new ArrayList<>());
- setOfflineRewards(new ArrayList<>());
- }
-
- /**
- * Clears the total votes for all top voter categories.
- */
- public void clearTotals() {
- for (TopVoter top : TopVoter.values()) {
- resetTotals(top);
- }
- }
-
- /**
- * Gets the all-time total votes.
- *
- * @return the all-time total votes
- * @deprecated Use getTotal(TopVoter.AllTime) when able instead
- */
- @Deprecated
- public int getAllTimeTotal() {
- return getTotal(TopVoter.AllTime);
- }
-
- /**
- * Gets the best day vote streak.
- *
- * @return the best day vote streak
- */
- @Deprecated
- public int getBestDayVoteStreak() {
- return getData().getInt("BestDayVoteStreak");
- }
-
- /**
- * Gets the best month vote streak.
- *
- * @return the best month vote streak
- */
- @Deprecated
- public int getBestMonthVoteStreak() {
- return getData().getInt("BestMonthVoteStreak");
- }
-
- /**
- * Gets the best week vote streak.
- *
- * @return the best week vote streak
- */
- @Deprecated
- public int getBestWeekVoteStreak() {
- return getData().getInt("BestWeekVoteStreak");
- }
-
- /**
- * Checks if the cooldown check is enabled.
- *
- * @return true, if the cooldown check is enabled
- */
- public boolean getCoolDownCheck() {
- return getData().getBoolean(getCoolDownCheckPath());
- }
-
- /**
- * Gets the path for the cooldown check.
- *
- * @return the cooldown check path
- */
- public String getCoolDownCheckPath() {
- if (plugin.getBungeeSettings().isUseBungeecoord()) {
- return "CoolDownCheck_" + plugin.getBungeeSettings().getServerNameStorage();
- }
- return "CoolDownCheck";
- }
-
- /**
- * Checks if the cooldown check is enabled for a specific vote site.
- *
- * @param site the vote site
- * @return true, if the cooldown check is enabled for the site
- */
- public boolean getCoolDownCheckSite(VoteSite site) {
- HashMap coolDownChecks = getCoolDownCheckSiteList();
- if (coolDownChecks.containsKey(site.getKey())) {
- return coolDownChecks.get(site.getKey()).booleanValue();
- }
- return false;
- }
-
- /**
- * Gets the list of cooldown checks for all vote sites.
- *
- * @return the list of cooldown checks for all vote sites
- */
- public HashMap getCoolDownCheckSiteList() {
- HashMap coolDownChecks = new HashMap<>();
- ArrayList coolDownCheck = getData().getStringList(getCoolDownCheckSitePath());
- for (String str : coolDownCheck) {
- String[] data = str.split("//");
- if (data.length > 1 && plugin.getVoteSiteManager().hasVoteSite(data[0])) {
- VoteSite site = plugin.getVoteSiteManager().getVoteSite(data[0], true);
- if (site != null) {
- Boolean b = Boolean.valueOf(data[1]);
- coolDownChecks.put(site.getKey(), b);
- }
- }
- }
- return coolDownChecks;
- }
-
- /**
- * Gets the path for the cooldown check site list.
- *
- * @return the cooldown check site list path
- */
- public String getCoolDownCheckSitePath() {
- if (plugin.getBungeeSettings().isUseBungeecoord()) {
- return "CoolDownCheck_" + plugin.getBungeeSettings().getServerNameStorage() + "_Sites";
- }
- return "CoolDownCheck" + "_Sites";
- }
-
- /**
- * Gets the daily total votes.
- *
- * @return the daily total votes
- * @deprecated Use getTotal(TopVoter.Daily) instead
- */
- @Deprecated
- public int getDailyTotal() {
- return getTotal(TopVoter.Daily);
- }
-
- /**
- * Gets the day vote streak.
- *
- * @return the day vote streak
- */
- @Deprecated
- public int getDayVoteStreak() {
- return getData().getInt("DayVoteStreak");
- }
-
- /**
- * Gets the last update time for the day vote streak.
- *
- * @return the last update time for the day vote streak
- */
- @Deprecated
- public long getDayVoteStreakLastUpdate() {
- String str = getData().getString("DayVoteStreakLastUpdate");
- if (str == null || str.isEmpty() || str.equals("null")) {
- return 0;
- }
- try {
- return Long.parseLong(str);
- } catch (NumberFormatException e) {
- return 0;
- }
- }
-
- /**
- * Checks if the broadcast is disabled.
- *
- * @return true if the broadcast is disabled, false otherwise
- */
- public boolean getDisableBroadcast() {
- return getUserData().getBoolean("DisableBroadcast");
- }
-
- /**
- * Gets the day when the user has gotten all sites.
- *
- * @return the day when the user has gotten all sites
- */
- public int getGottenAllSitesDay() {
- return getData().getInt(plugin.getVotingPluginUserManager().getGottenAllSitesDayPath(), 0);
- }
-
- /**
- * Gets the day when the user has gotten almost all sites.
- *
- * @return the day when the user has gotten almost all sites
- */
- public int getGottenAlmostAllSitesDay() {
- return getData().getInt(plugin.getVotingPluginUserManager().getGottenAlmostAllSitesDayPath(), 0);
- }
-
- /**
- * Gets the highest daily total votes.
- *
- * @return the highest daily total votes
- */
- public int getHighestDailyTotal() {
- return getData().getInt("HighestDailyTotal");
- }
-
- /**
- * Returns whether this user has already claimed the NameMC like reward.
- *
- * @return true if claimed
- */
- public boolean hasClaimedNameMCLikeReward() {
- return getUserData().getBoolean("NameMCLikeRewardClaimed");
- }
-
- /**
- * Sets whether this user has already claimed the NameMC like reward.
- *
- * @param claimed true if claimed
- */
- public void setClaimedNameMCLikeReward(boolean claimed) {
- // A completed NameMC reward must be durably persisted before clearing
- // its recovery marker; never leave this as an ordinary queued write.
- getUserData().setBoolean("NameMCLikeRewardClaimed", claimed, false);
}
- /** Whether the NameMC grant may have started but is not yet confirmed. */
- public boolean isNameMCLikeRewardPending() {
- return getUserData().getBoolean("NameMCLikeRewardPending");
+ /**
+ * Checks if the user can vote on all sites.
+ *
+ * @return true, if the user can vote on all sites
+ */
+ public boolean canVoteAll() {
+ for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ if (!voteSite.isHidden()) {
+ boolean canVote = canVoteSite(voteSite);
+ if (!canVote) {
+ return false;
+ }
+ }
+ }
+ return true;
}
- /** Persist/clear the NameMC replay fence on the user-data worker. */
- public void setNameMCLikeRewardPending(boolean pending) {
- getUserData().setBoolean("NameMCLikeRewardPending", pending, false);
- }
-
- /**
- * Gets the highest monthly total votes.
- *
- * @return the highest monthly total votes
- */
- public int getHighestMonthlyTotal() {
- return getData().getInt("HighestMonthlyTotal");
- }
-
- /**
- * Gets the highest weekly total votes.
- *
- * @return the highest weekly total votes
- */
- public int getHighestWeeklyTotal() {
- return getData().getInt("HighestWeeklyTotal");
- }
-
- /**
- * Gets the total votes for the last month.
- *
- * @return the total votes for the last month
- */
- public int getLastMonthTotal() {
- return getData().getInt("LastMonthTotal");
- }
-
- /**
- * Gets the last votes for each vote site.
- *
- * @return a map of vote sites and the last vote time
- */
- public HashMap getLastVotes() {
- HashMap lastVotes = new HashMap<>();
- ArrayList lastVotesList = getUserData().getStringList("LastVotes");
-
- for (String str : lastVotesList) {
- String[] data = str.split("//");
- if (data.length <= 1) {
- continue;
- }
-
- String rawSiteKey = data[0];
- String rawTime = data[1];
-
- if (!plugin.getVoteSiteManager().hasVoteSite(rawSiteKey)) {
- continue;
- }
-
- VoteSite site = plugin.getVoteSiteManager().getVoteSite(rawSiteKey, true);
- if (site == null) {
- continue;
- }
-
- long time = 0;
- try {
- time = Long.parseLong(rawTime);
- } catch (NumberFormatException ignored) {
- time = 0;
- }
-
- lastVotes.put(site, time);
- }
-
- return lastVotes;
- }
-
- /**
- * Gets the time of the last vote.
- *
- * @return the time of the last vote
- */
- public Long getLastVoteTime() {
- Long time = Long.valueOf(0);
- for (Long value : getLastVotes().values()) {
- if (value.longValue() > time) {
- time = value;
- }
- }
- return time;
- }
-
- /**
- * Gets the last vote time for a specific vote site.
- *
- * @param voteSite the vote site
- * @return the last vote time for the vote site
- */
- public long getLastVoteTimer(VoteSite voteSite) {
- HashMap times = getLastVotes();
- if (times.containsKey(voteSite)) {
- return times.get(voteSite).longValue();
- }
- return 0;
- }
-
- /**
- * Gets the last vote times sorted in descending order.
- *
- * @return a map of vote sites and the last vote times sorted in descending
- * order
- */
- public HashMap getLastVoteTimesSorted() {
- LinkedHashMap times = new LinkedHashMap<>();
-
- for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- times.put(voteSite, getTime(voteSite));
- }
- LinkedHashMap sorted = new LinkedHashMap<>(
- times.entrySet().stream().sorted(Collections.reverseOrder(Map.Entry.comparingByValue()))
- .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
- return sorted;
- }
-
- /**
- * Gets the total votes for the month.
- *
- * @return the total votes for the month
- * @deprecated Use getTotal(TopVoter.Monthly) instead
- */
- @Deprecated
- public int getMonthTotal() {
- return getTotal(TopVoter.Monthly);
- }
-
- /**
- * Gets the month vote streak.
- *
- * @return the month vote streak
- */
- @Deprecated
- public int getMonthVoteStreak() {
- return getData().getInt("MonthVoteStreak");
- }
-
- /**
- * Gets the next time all sites are available for voting.
- *
- * @return the next time all sites are available for voting
- */
- public long getNextTimeAllSitesAvailable() {
- long longest = 0;
- for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- long seconds = voteNextDurationTime(site);
- if (seconds > longest) {
- longest = seconds;
- }
- }
-
- return longest;
- }
-
- /**
- * Gets the next time the first site is available for voting.
- *
- * @return seconds until first site is available, or 0 if none
- */
- public long getNextTimeFirstSiteAvailable() {
- NextSite next = getNextSiteAvailable();
- return next == null ? 0 : next.getSecondsUntilAvailable();
- }
-
- /**
- * Returns the next VoteSite that will become available, and how many seconds
- * until it is available.
- *
- * - Only considers enabled sites. - Skips hidden sites (matching
- * canVoteAll/canVoteAny intent for player-facing voting). - Only considers
- * sites the player CANNOT currently vote on (seconds > 0).
- *
- * @return NextSite or null if there is no upcoming site (i.e. can vote all, or
- * no delays)
- */
- public NextSite getNextSiteAvailable() {
- List sites = plugin.getVoteSiteManager().getVoteSitesEnabled();
- if (sites == null || sites.isEmpty()) {
- return null;
- }
-
- VoteSite bestSite = null;
- long bestSeconds = 0;
-
- for (int i = 0; i < sites.size(); i++) {
- VoteSite site = sites.get(i);
- if (site == null) {
- continue;
- }
-
- // Match the same "don't care" sites as your other checks generally do
- if (!site.isEnabled() || site.isHidden()) {
- continue;
- }
-
- // If you can already vote, it isn't "next"
- if (canVoteSite(site)) {
- continue;
- }
-
- long seconds = voteNextDurationTime(site); // uses getTime(site) internally
- if (seconds <= 0) {
- continue;
- }
-
- if (bestSite == null || seconds < bestSeconds) {
- bestSite = site;
- bestSeconds = seconds;
- }
- }
-
- return bestSite == null ? null : new NextSite(bestSite, bestSeconds);
- }
-
- /**
- * Gets the number of offline votes for the specified vote site.
- *
- * @param site the vote site
- * @return the number of offline votes for the specified vote site
- */
- public int getNumberOfOfflineVotes(VoteSite site) {
- ArrayList offlineVotes = getOfflineVotes();
- int num = 0;
- for (String str : offlineVotes) {
- if (str.equals(site.getKey())) {
- num++;
- }
- }
- return num;
- }
-
- /**
- * Gets the list of offline votes.
- *
- * @return the list of offline votes
- */
- public ArrayList getOfflineVotes() {
- return getUserData().getStringList("OfflineVotes");
- }
-
- /**
- * Gets the points of the user.
- *
- * @return the points of the user
- */
- public int getPoints() {
- return getUserData().getInt(getPointsPath());
- }
-
- /**
- * Gets the path for the points.
- *
- * @return the points path
- */
- public String getPointsPath() {
- if (plugin.getBungeeSettings().isPerServerPoints()) {
- return plugin.getBungeeSettings().getServerNameStorage() + "_Points";
- }
- return "Points";
- }
-
- /**
- * Gets the number of sites not voted on.
- *
- * @return the number of sites not voted on
- */
- public int getSitesNotVotedOn() {
- int amount = 0;
- for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (!site.isHidden()) {
- if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) {
- if (canVoteSite(site)) {
- amount++;
- }
- }
- }
- }
- return amount;
+ /**
+ * Checks if the user can vote on any site.
+ *
+ * @return true, if the user can vote on any site
+ */
+ public boolean canVoteAny() {
+ for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ if (!voteSite.isIgnoreCanVote() && !voteSite.isHidden()) {
+ boolean canVote = canVoteSite(voteSite);
+ if (canVote) {
+ return true;
+ }
+ }
+ }
+ return false;
}
/**
- * Match the existing offline result without resolving a live Bukkit player.
- * Permission-gated sites are excluded because the normal call uses
- * {@code hasPermission(permission, false)}, which returns false offline.
+ * Checks if the user can vote on the specified site.
+ *
+ * @param voteSite the vote site
+ * @return true, if the user can vote on the site
*/
- public int getSitesNotVotedOnWithoutOnlinePermissions() {
- int amount = 0;
- for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (!site.isHidden() && site.getPermissionToView().isEmpty() && canVoteSite(site)) amount++;
+ public boolean canVoteSite(VoteSite voteSite) {
+ long time = getTime(voteSite);
+ if (time == 0) {
+ return true;
}
- return amount;
- }
-
- public int getTotalNumberOfSites() {
- int amount = 0;
- for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (!site.isHidden()) {
- if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) {
- amount++;
- }
- }
- }
- return amount;
+ try {
+ LocalDateTime now = plugin.getTimeChecker().getTime();
+ LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault())
+ .plusHours(plugin.getOptions().getTimeHourOffSet());
+
+ if (!voteSite.isVoteDelayDaily()) {
+ ParsedDuration voteDelay = voteSite.getVoteDelay();
+
+ // Preserve old behavior: if delay is 0, you can never vote again (unless daily
+ // reset mode)
+ if (voteDelay == null || voteDelay.isEmpty() || voteDelay.getMillis() == 0L) {
+ return false;
+ }
+
+ LocalDateTime nextVote = lastVote.plus(Duration.ofMillis(voteDelay.getMillis()));
+ return now.isAfter(nextVote);
+ }
+
+ // Daily reset logic unchanged
+ LocalDateTime resetTime = lastVote.withHour(voteSite.getVoteDelayDailyHour()).withMinute(0).withSecond(0);
+ LocalDateTime resetTimeTomorrow = resetTime.plusHours(24);
+
+ if (lastVote.isBefore(resetTime)) {
+ return now.isAfter(resetTime);
+ } else {
+ return now.isAfter(resetTimeTomorrow);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return false;
}
/**
- * Match the existing offline total without resolving a live Bukkit player.
- * Permission-gated sites are excluded because the normal call uses
- * {@code hasPermission(permission, false)}, which returns false offline.
+ * Checks if the user has voted on all sites.
+ *
+ * @return true, if the user has voted on all sites
*/
- public int getTotalNumberOfSitesWithoutOnlinePermissions() {
- int amount = 0;
- for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (!site.isHidden() && site.getPermissionToView().isEmpty()) amount++;
+ public boolean checkAllVotes() {
+ VotingPluginUser user = this;
+
+ ArrayList months = new ArrayList<>();
+ ArrayList days = new ArrayList<>();
+
+ for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ if (voteSite.isEnabled() && !voteSite.isHidden()) {
+ long time = user.getTime(voteSite);
+ if (time == 0) {
+ return false;
+ }
+ months.add(MiscUtils.getInstance().getMonthFromMili(time));
+ days.add(MiscUtils.getInstance().getDayFromMili(time));
+ }
}
- return amount;
+
+ // check months
+ for (Integer month : months) {
+ if (!months.get(0).equals(month)) {
+ return false;
+ }
+ }
+
+ // check days
+ for (Integer day : days) {
+ if (!days.get(0).equals(day)) {
+ return false;
+ }
+ }
+
+ return true;
}
-
- public int getSitesVotedOn() {
- int amount = 0;
- for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (!canVoteSite(site)) {
- amount++;
- }
- }
- return amount;
- }
-
- /**
- * Gets the time.
- *
- * @param voteSite the vote site
- * @return the time
- */
- public long getTime(VoteSite voteSite) {
- HashMap lastVotes = getLastVotes();
- if (lastVotes.containsKey(voteSite)) {
- return lastVotes.get(voteSite);
- }
- return 0;
- }
-
- /**
- * Gets the top voter player.
- *
- * @return the top voter player
- */
- public TopVoterPlayer getTopVoterPlayer() {
- return new TopVoterPlayer(UUID.fromString(getUUID()), getPlayerName(), getLastOnline());
- }
-
- /**
- * Gets the total votes for the specified top voter category.
- *
- * @param top the top voter category
- * @return the total votes for the specified top voter category
- */
- public int getTotal(TopVoter top) {
- switch (top) {
- case AllTime:
- return getUserData().getInt("AllTimeTotal");
- case Daily:
- return getUserData().getInt("DailyTotal");
- case Monthly:
- if (plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) {
- return getData().getInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath());
- }
- return getData().getInt("MonthTotal");
- case Weekly:
- return getUserData().getInt("WeeklyTotal");
- default:
- break;
- }
- return 0;
- }
-
- /**
- * Gets the total votes for the specified top voter category at a specific time.
- *
- * @param top the top voter category
- * @param atTime the specific time
- * @return the total votes for the specified top voter category at the specific
- * time
- */
- public int getTotal(TopVoter top, LocalDateTime atTime) {
- switch (top) {
- case AllTime:
- return getUserData().getInt("AllTimeTotal");
- case Daily:
- return getUserData().getInt("DailyTotal");
- case Monthly:
- if (plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) {
- return getData().getInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath(atTime));
- }
- return getData().getInt("MonthTotal");
- case Weekly:
- return getUserData().getInt("WeeklyTotal");
- default:
- break;
- }
- return 0;
- }
-
- /**
- * Gets the number of votes for the vote party.
- *
- * @return the number of votes for the vote party
- */
- public int getVotePartyVotes() {
- return getUserData().getInt("VotePartyVotes");
- }
-
- /**
- * Gets the vote shop identifier limit.
- *
- * @param identifier the identifier for the vote shop
- * @return the vote shop identifier limit
- */
- public int getVoteShopIdentifierLimit(String identifier) {
- String path = "VoteShopLimit" + identifier;
- if (usesSharedMysqlPoints()) {
- // Placeholder and GUI rendering may run on the Bukkit main thread or a
- // Folia-owned tick thread. This accessor therefore never performs JDBC for
- // shared MySQL; purchases remain protected by their atomic reservation.
- return getData().getInt(path,
- isCached() ? UserDataFetchMode.CACHE_ONLY : UserDataFetchMode.TEMP_ONLY);
+
+ /**
+ * Checks if the user has voted on almost all sites.
+ *
+ * @return true, if the user has voted on almost all sites
+ */
+ public boolean checkAlmostAllVotes() {
+ if (getSitesNotVotedOn() <= 1) {
+ return true;
}
- return getData().getInt(path);
+ return false;
}
-
- /**
- * Gets the weekly total votes.
- *
- * @return the weekly total votes
- * @deprecated Use getTotal(TopVoter.Weekly) instead
- */
- @Deprecated
- public int getWeeklyTotal() {
- return getTotal(TopVoter.Weekly);
- }
-
- /**
- * Gets the week vote streak.
- *
- * @return the week vote streak
- */
- @Deprecated
- public int getWeekVoteStreak() {
- return getData().getInt("WeekVoteStreak");
- }
-
- /**
- * Gives the daily top voter award.
- *
- * @param place the place of the top voter
- * @param path the path to the reward configuration
- */
- public void giveDailyTopVoterAward(int place, String path) {
- SpecialRewardType type = SpecialRewardType.TOPVOTER;
- type.setType("Daily");
- type.setAmount(1);
- PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null);
- Bukkit.getPluginManager().callEvent(event);
-
- if (event.isCancelled()) {
- return;
- }
- new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
- plugin.getSpecialRewardsConfig().getDailyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
- .withPlaceHolder("topvoter", "Daily").withPlaceHolder("votes", "" + getTotal(TopVoter.Daily))
- .setOnline(isOnline()).send(this);
- }
-
- /**
- * Gives the monthly top voter award.
- *
- * @param place the place of the top voter
- * @param path the path to the reward configuration
- */
- public void giveMonthlyTopVoterAward(int place, String path) {
- SpecialRewardType type = SpecialRewardType.TOPVOTER;
- type.setType("Monthly");
- type.setAmount(1);
- PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null);
- Bukkit.getPluginManager().callEvent(event);
-
- if (event.isCancelled()) {
- return;
- }
- new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
- plugin.getSpecialRewardsConfig().getMonthlyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
- .withPlaceHolder("topvoter", "Monthly").withPlaceHolder("votes", "" + getTotal(TopVoter.Monthly))
- .setOnline(isOnline()).send(this);
- }
-
- /**
- * Gives the weekly top voter award.
- *
- * @param place the place of the top voter
- * @param path the path to the reward configuration
- */
- public void giveWeeklyTopVoterAward(int place, String path) {
- SpecialRewardType type = SpecialRewardType.TOPVOTER;
- type.setType("Weekly");
- type.setAmount(1);
- PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null);
- Bukkit.getPluginManager().callEvent(event);
-
- if (event.isCancelled()) {
- return;
- }
- new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
- plugin.getSpecialRewardsConfig().getWeeklyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
- .withPlaceHolder("topvoter", "Weekly").withPlaceHolder("votes", "" + getTotal(TopVoter.Weekly))
- .setOnline(isOnline()).send(this);
- }
-
- /**
- * Gets how many unique vote sites this user has voted on today.
- *
- * Uses existing LastVotes data (no storage). A site counts if its last-vote
- * timestamp falls on "today" using VotingPlugin's time offset.
- *
- * @return number of unique sites voted on today
- */
- public long getUniqueVoteSitesToday() {
- LocalDateTime now = plugin.getTimeChecker().getTime();
- LocalDate today = now.toLocalDate();
-
- long count = 0;
-
- for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (site == null || !site.isEnabled() || site.isHidden()) {
- continue;
- }
-
- long time = getTime(site);
- if (time <= 0) {
- continue;
- }
-
- // Match the same offset handling as canVoteSite()
- LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault())
- .plusHours(plugin.getOptions().getTimeHourOffSet());
-
- if (lastVote.toLocalDate().equals(today)) {
- count++;
- }
- }
-
- return count;
- }
-
- /**
- * Checks if the user has a percentage of the total votes.
- *
- * @param top the top voter category
- * @param percentage the percentage of the total votes
- * @param time the specific time
- * @return true if the user has the percentage of the total votes, false
- * otherwise
- */
- public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time) {
- int total = getTotal(top, time);
- switch (top) {
- case Daily:
- return (double) total / (double) plugin.getVoteSiteManager().getVoteSitesEnabled().size()
- * 100 > percentage;
- case Monthly:
- return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size()
- * time.getMonth().length(false)) * 100 > percentage;
- case Weekly:
- return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() * 7) * 100 > percentage;
- default:
- return false;
- }
- }
-
- /**
- * Checks if the user is ignored for top voter.
- *
- * @return true if the user is ignored for top voter, false otherwise
- */
- public boolean isTopVoterIgnore() {
- return getUserData().getBoolean("TopVoterIgnore");
- }
-
- /**
- * Gives login rewards to the user.
- */
- public void loginRewards() {
- if (plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LoginRewards")) {
- new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LoginRewards").send(this);
- }
- }
-
- /** Storage preparation stays on its caller; AdvancedCore owns platform reward scheduling. */
- public CompletionStage loginRewardsAsync() {
- if (!plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LoginRewards")) {
- return CompletableFuture.completedFuture(null);
- }
- return new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LoginRewards").sendAsync(this);
- }
-
- /**
- * Gives logout rewards to the user.
- */
- public void logoutRewards() {
- if (plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LogoutRewards")) {
- new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LogoutRewards").send(this);
- }
- }
-
- /**
- * Merges the provided data with the current data.
- *
- * @param toAdd the data to add
- */
- public void mergeData(HashMap toAdd) {
- HashMap currentData = getData().getValues();
- HashMap newData = new HashMap<>();
-
- for (TopVoter top : TopVoter.values()) {
- if (toAdd.containsKey(top.getColumnName()) && currentData.containsKey(top.getColumnName())) {
- newData.put(top.getColumnName(), new DataValueInt(
- currentData.get(top.getColumnName()).getInt() + toAdd.get(top.getColumnName()).getInt()));
- }
- }
-
- if (newData.size() > 0) {
- getData().setValues(newData);
- }
- }
-
- /** Legacy admission-only API; use offVoteAsync for dependent work. */
- public void offVote() {
- if (!plugin.getOptions().isProcessRewards()) return;
- Player player = getPlayer();
- if (!plugin.getOptions().isOnlineMode()) player = Bukkit.getPlayer(getPlayerName());
- offVoteAndThen(player, () -> { });
- }
-
- /**
- * Capture permission on the player owner, then replay on the storage worker.
- * Completion means durable replay finished, including an empty/disabled no-op.
- * A retired player is skipped without touching its entity from a global fallback.
- */
- public CompletionStage offVoteAsync(Player player) {
- if (!plugin.getOptions().isProcessRewards() || player == null) {
- return CompletableFuture.completedFuture(null);
- }
- CompletableFuture confirmed = new CompletableFuture<>();
- plugin.getOfflineVoteOwnerHandoffs().submit(confirmed, begin ->
- BukkitCompletionScheduler.run(plugin, player, () -> {
- if (!begin.getAsBoolean()) return;
- try {
- if (!player.isOnline()) {
- confirmed.complete(null);
- return;
- }
- boolean ignored = player.hasPermission("VotingPlugin.TopVoter.Ignore");
- plugin.getUserManager().getDataManager().getTimer().execute(() -> {
- try {
- cache();
- offVoteWithCapturedTopVoterIgnoreAsync(ignored, () -> { }, true).whenComplete((value, failure) -> {
- if (failure == null) confirmed.complete(null);
- else confirmed.completeExceptionally(failure);
- });
- } catch (RuntimeException | Error failure) {
- confirmed.completeExceptionally(failure);
- }
- });
- } catch (RuntimeException | Error failure) {
- confirmed.completeExceptionally(failure);
- }
- }, () -> { if (begin.getAsBoolean()) confirmed.complete(null); }, () -> {
- if (begin.getAsBoolean()) confirmed.completeExceptionally(
- new IllegalStateException("Offline replay owner scheduling rejected"));
- }));
- return confirmed;
- }
-
- /** Dependent work always returns to storage, even for a no-op replay. */
- public void offVoteAndThen(Player player, Runnable continuation) {
- offVoteAsync(player).whenComplete((ignored, failure) -> {
- if (failure != null) {
- plugin.getLogger().warning("Offline vote replay remains pending for " + getUUID() + ": " + failure);
- return;
- }
- try {
- plugin.getUserManager().getDataManager().getTimer().execute(continuation);
- } catch (RuntimeException rejected) {
- plugin.getLogger().warning("Offline replay continuation rejected for " + getUUID() + ": " + rejected);
- }
- });
- }
-
+
/**
- * Legacy void entry point: enqueue durable replay on the storage worker.
- * Returning means admission, not completed reward delivery. Never wait on a
- * platform thread or clear a queue before the reward completion protocol.
+ * Checks the day vote streak and updates it if necessary.
+ *
+ * @param forceBungee whether to force bungee
*/
- public void offVoteWithCapturedTopVoterIgnore(boolean currentTopVoterIgnore) {
- if (!plugin.getOptions().isProcessRewards()) return;
- offVoteWithCapturedTopVoterIgnoreAndThen(currentTopVoterIgnore, () -> { });
- }
-
- /**
- * Schedules legacy replay and invokes the continuation only after the durable
- * replay completion. The continuation runs on the storage worker.
- */
- public void offVoteWithCapturedTopVoterIgnoreAndThen(boolean currentTopVoterIgnore, Runnable continuation) {
- plugin.getUserManager().getDataManager().getTimer().execute(() -> {
- try {
- cache();
- offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore)
- .whenComplete((ignored, failure) -> {
- if (failure != null) {
- plugin.getLogger().warning("Offline vote replay remains pending for "
- + getUUID() + ": " + failure);
- } else {
- continuation.run();
- }
- });
- } catch (RuntimeException failure) {
- plugin.getLogger().warning("Offline vote replay could not start for " + getUUID() + ": " + failure);
+ @Deprecated
+ public void checkDayVoteStreak(boolean forceBungee) {
+ if (!voteStreakUpdatedToday(LocalDateTime.now())) {
+ if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || hasPercentageTotal(
+ TopVoter.Daily, plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)) {
+ plugin.extraDebug("Adding day vote streak to " + getUUID() + " "
+ + plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + " "
+ + hasPercentageTotal(TopVoter.Daily,
+ plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null));
+ addDayVoteStreak();
+ plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee);
+ setDayVoteStreakLastUpdate(System.currentTimeMillis());
}
- });
+ }
}
/**
- * Process the next offline-reward batch on the storage worker. The original
- * queue and a durable pending snapshot remain available until both reward
- * delivery and a confirmed worker-side commit succeed. No Bukkit-affecting
- * reward facade runs synchronously on the storage worker.
+ * Clears the offline votes.
*/
- public CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore) {
- return offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore, () -> { });
+ public void clearOfflineVotes() {
+ setOfflineVotes(new ArrayList<>());
+ setOfflineRewards(new ArrayList<>());
}
/**
- * The callback marks the first possible nontransactional reward effect.
- * Reads/cache misses before this point remain retryable in ordered VoteUpdate.
+ * Clears the total votes for all top voter categories.
*/
- public CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore,
- Runnable beforeRewardEffects) {
- return offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore, beforeRewardEffects, false);
+ public void clearTotals() {
+ for (TopVoter top : TopVoter.values()) {
+ resetTotals(top);
+ }
}
- /** Login/hook dependents may await the existing batch without starting another reward chain. */
- private CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore,
- Runnable beforeRewardEffects, boolean joinExisting) {
- if (!plugin.getOptions().isProcessRewards()) return CompletableFuture.completedFuture(null);
- String uuid = getUUID();
- CompletableFuture confirmed = new CompletableFuture<>();
- synchronized (IN_FLIGHT_OFFLINE_VOTE_REWARDS) {
- if (offlineVoteBulkClearActive) {
- return CompletableFuture.failedFuture(new IllegalStateException("Offline-vote bulk clear is active"));
- }
- CompletableFuture existing = IN_FLIGHT_OFFLINE_VOTE_REWARDS.putIfAbsent(uuid, confirmed);
- if (existing != null) {
- // A dependent stage cannot cancel or complete the owning replay's future.
- return joinExisting ? existing.thenApply(ignored -> null)
- : CompletableFuture.failedFuture(new IllegalStateException(
- "Offline-vote reward batch is still running for " + uuid));
- }
- }
- confirmed.whenComplete((ignored, failure) -> IN_FLIGHT_OFFLINE_VOTE_REWARDS.remove(uuid, confirmed));
- try {
- if (isTopVoterIgnore() != currentTopVoterIgnore) setTopVoterIgnore(currentTopVoterIgnore);
- if (!getPendingOfflineVoteRewardBatch().isEmpty()) {
- beforeRewardEffects.run();
- throw new IllegalStateException("Earlier offline-vote rewards require reconciliation for " + uuid);
- }
- ArrayList offlineVotes = getOfflineVotes();
- if (offlineVotes.isEmpty()) {
- confirmed.complete(null);
- return confirmed;
- }
+ /**
+ * Gets the all-time total votes.
+ *
+ * @return the all-time total votes
+ * @deprecated Use getTotal(TopVoter.AllTime) when able instead
+ */
+ @Deprecated
+ public int getAllTimeTotal() {
+ return getTotal(TopVoter.AllTime);
+ }
+
+ /**
+ * Gets the best day vote streak.
+ *
+ * @return the best day vote streak
+ */
+ @Deprecated
+ public int getBestDayVoteStreak() {
+ return getData().getInt("BestDayVoteStreak");
+ }
+
+ /**
+ * Gets the best month vote streak.
+ *
+ * @return the best month vote streak
+ */
+ @Deprecated
+ public int getBestMonthVoteStreak() {
+ return getData().getInt("BestMonthVoteStreak");
+ }
+
+ /**
+ * Gets the best week vote streak.
+ *
+ * @return the best week vote streak
+ */
+ @Deprecated
+ public int getBestWeekVoteStreak() {
+ return getData().getInt("BestWeekVoteStreak");
+ }
+
+ /**
+ * Checks if the cooldown check is enabled.
+ *
+ * @return true, if the cooldown check is enabled
+ */
+ public boolean getCoolDownCheck() {
+ return getData().getBoolean(getCoolDownCheckPath());
+ }
+
+ /**
+ * Gets the path for the cooldown check.
+ *
+ * @return the cooldown check path
+ */
+ public String getCoolDownCheckPath() {
+ if (plugin.getBungeeSettings().isUseBungeecoord()) {
+ return "CoolDownCheck_" + plugin.getBungeeSettings().getServerNameStorage();
+ }
+ return "CoolDownCheck";
+ }
+
+ /**
+ * Checks if the cooldown check is enabled for a specific vote site.
+ *
+ * @param site the vote site
+ * @return true, if the cooldown check is enabled for the site
+ */
+ public boolean getCoolDownCheckSite(VoteSite site) {
+ HashMap coolDownChecks = getCoolDownCheckSiteList();
+ if (coolDownChecks.containsKey(site.getKey())) {
+ return coolDownChecks.get(site.getKey()).booleanValue();
+ }
+ return false;
+ }
+
+ /**
+ * Gets the list of cooldown checks for all vote sites.
+ *
+ * @return the list of cooldown checks for all vote sites
+ */
+ public HashMap getCoolDownCheckSiteList() {
+ HashMap coolDownChecks = new HashMap<>();
+ ArrayList coolDownCheck = getData().getStringList(getCoolDownCheckSitePath());
+ for (String str : coolDownCheck) {
+ String[] data = str.split("//");
+ if (data.length > 1 && plugin.getVoteSiteManager().hasVoteSite(data[0])) {
+ VoteSite site = plugin.getVoteSiteManager().getVoteSite(data[0], true);
+ if (site != null) {
+ Boolean b = Boolean.valueOf(data[1]);
+ coolDownChecks.put(site.getKey(), b);
+ }
+ }
+ }
+ return coolDownChecks;
+ }
+
+ /**
+ * Gets the path for the cooldown check site list.
+ *
+ * @return the cooldown check site list path
+ */
+ public String getCoolDownCheckSitePath() {
+ if (plugin.getBungeeSettings().isUseBungeecoord()) {
+ return "CoolDownCheck_" + plugin.getBungeeSettings().getServerNameStorage() + "_Sites";
+ }
+ return "CoolDownCheck" + "_Sites";
+ }
+
+ /**
+ * Gets the daily total votes.
+ *
+ * @return the daily total votes
+ * @deprecated Use getTotal(TopVoter.Daily) instead
+ */
+ @Deprecated
+ public int getDailyTotal() {
+ return getTotal(TopVoter.Daily);
+ }
+
+ /**
+ * Gets the day vote streak.
+ *
+ * @return the day vote streak
+ */
+ @Deprecated
+ public int getDayVoteStreak() {
+ return getData().getInt("DayVoteStreak");
+ }
+
+ /**
+ * Gets the last update time for the day vote streak.
+ *
+ * @return the last update time for the day vote streak
+ */
+ @Deprecated
+ public long getDayVoteStreakLastUpdate() {
+ String str = getData().getString("DayVoteStreakLastUpdate");
+ if (str == null || str.isEmpty() || str.equals("null")) {
+ return 0;
+ }
+ try {
+ return Long.parseLong(str);
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ /**
+ * Checks if the broadcast is disabled.
+ *
+ * @return true if the broadcast is disabled, false otherwise
+ */
+ public boolean getDisableBroadcast() {
+ return getUserData().getBoolean("DisableBroadcast");
+ }
+
+ /**
+ * Gets the day when the user has gotten all sites.
+ *
+ * @return the day when the user has gotten all sites
+ */
+ public int getGottenAllSitesDay() {
+ return getData().getInt(plugin.getVotingPluginUserManager().getGottenAllSitesDayPath(), 0);
+ }
+
+ /**
+ * Gets the day when the user has gotten almost all sites.
+ *
+ * @return the day when the user has gotten almost all sites
+ */
+ public int getGottenAlmostAllSitesDay() {
+ return getData().getInt(plugin.getVotingPluginUserManager().getGottenAlmostAllSitesDayPath(), 0);
+ }
+
+ /**
+ * Gets the highest daily total votes.
+ *
+ * @return the highest daily total votes
+ */
+ public int getHighestDailyTotal() {
+ return getData().getInt("HighestDailyTotal");
+ }
+
+ /**
+ * Returns whether this user has already claimed the NameMC like reward.
+ *
+ * @return true if claimed
+ */
+ public boolean hasClaimedNameMCLikeReward() {
+ return getUserData().getBoolean("NameMCLikeRewardClaimed");
+ }
+
+ /**
+ * Sets whether this user has already claimed the NameMC like reward.
+ *
+ * @param claimed true if claimed
+ */
+ public void setClaimedNameMCLikeReward(boolean claimed) {
+ // A completed NameMC reward must be durably persisted before clearing
+ // its recovery marker; never leave this as an ordinary queued write.
+ getUserData().setBoolean("NameMCLikeRewardClaimed", claimed, false);
+ }
+
+ /** Whether the NameMC grant may have started but is not yet confirmed. */
+ public boolean isNameMCLikeRewardPending() {
+ return getUserData().getBoolean("NameMCLikeRewardPending");
+ }
+
+ /** Persist/clear the NameMC replay fence on the user-data worker. */
+ public void setNameMCLikeRewardPending(boolean pending) {
+ getUserData().setBoolean("NameMCLikeRewardPending", pending, false);
+ }
+
+ /**
+ * Gets the highest monthly total votes.
+ *
+ * @return the highest monthly total votes
+ */
+ public int getHighestMonthlyTotal() {
+ return getData().getInt("HighestMonthlyTotal");
+ }
+
+ /**
+ * Gets the highest weekly total votes.
+ *
+ * @return the highest weekly total votes
+ */
+ public int getHighestWeeklyTotal() {
+ return getData().getInt("HighestWeeklyTotal");
+ }
+
+ /**
+ * Gets the total votes for the last month.
+ *
+ * @return the total votes for the last month
+ */
+ public int getLastMonthTotal() {
+ return getData().getInt("LastMonthTotal");
+ }
+
+ /**
+ * Gets the last votes for each vote site.
+ *
+ * @return a map of vote sites and the last vote time
+ */
+ public HashMap getLastVotes() {
+ HashMap lastVotes = new HashMap<>();
+ ArrayList lastVotesList = getUserData().getStringList("LastVotes");
+
+ for (String str : lastVotesList) {
+ String[] data = str.split("//");
+ if (data.length <= 1) {
+ continue;
+ }
+
+ String rawSiteKey = data[0];
+ String rawTime = data[1];
+
+ if (!plugin.getVoteSiteManager().hasVoteSite(rawSiteKey)) {
+ continue;
+ }
+
+ VoteSite site = plugin.getVoteSiteManager().getVoteSite(rawSiteKey, true);
+ if (site == null) {
+ continue;
+ }
+
+ long time = 0;
+ try {
+ time = Long.parseLong(rawTime);
+ } catch (NumberFormatException ignored) {
+ time = 0;
+ }
+
+ lastVotes.put(site, time);
+ }
+
+ return lastVotes;
+ }
+
+ /**
+ * Gets the time of the last vote.
+ *
+ * @return the time of the last vote
+ */
+ public Long getLastVoteTime() {
+ Long time = Long.valueOf(0);
+ for (Long value : getLastVotes().values()) {
+ if (value.longValue() > time) {
+ time = value;
+ }
+ }
+ return time;
+ }
+
+ /**
+ * Gets the last vote time for a specific vote site.
+ *
+ * @param voteSite the vote site
+ * @return the last vote time for the vote site
+ */
+ public long getLastVoteTimer(VoteSite voteSite) {
+ HashMap times = getLastVotes();
+ if (times.containsKey(voteSite)) {
+ return times.get(voteSite).longValue();
+ }
+ return 0;
+ }
+
+ /**
+ * Gets the last vote times sorted in descending order.
+ *
+ * @return a map of vote sites and the last vote times sorted in descending
+ * order
+ */
+ public HashMap getLastVoteTimesSorted() {
+ LinkedHashMap times = new LinkedHashMap<>();
+
+ for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ times.put(voteSite, getTime(voteSite));
+ }
+ LinkedHashMap sorted = new LinkedHashMap<>(
+ times.entrySet().stream().sorted(Collections.reverseOrder(Map.Entry.comparingByValue()))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
+ return sorted;
+ }
+
+ /**
+ * Gets the total votes for the month.
+ *
+ * @return the total votes for the month
+ * @deprecated Use getTotal(TopVoter.Monthly) instead
+ */
+ @Deprecated
+ public int getMonthTotal() {
+ return getTotal(TopVoter.Monthly);
+ }
+
+ /**
+ * Gets the month vote streak.
+ *
+ * @return the month vote streak
+ */
+ @Deprecated
+ public int getMonthVoteStreak() {
+ return getData().getInt("MonthVoteStreak");
+ }
+
+ /**
+ * Gets the next time all sites are available for voting.
+ *
+ * @return the next time all sites are available for voting
+ */
+ public long getNextTimeAllSitesAvailable() {
+ long longest = 0;
+ for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ long seconds = voteNextDurationTime(site);
+ if (seconds > longest) {
+ longest = seconds;
+ }
+ }
+
+ return longest;
+ }
+
+ /**
+ * Gets the next time the first site is available for voting.
+ *
+ * @return seconds until first site is available, or 0 if none
+ */
+ public long getNextTimeFirstSiteAvailable() {
+ NextSite next = getNextSiteAvailable();
+ return next == null ? 0 : next.getSecondsUntilAvailable();
+ }
+
+ /**
+ * Returns the next VoteSite that will become available, and how many seconds
+ * until it is available.
+ *
+ * - Only considers enabled sites. - Skips hidden sites (matching
+ * canVoteAll/canVoteAny intent for player-facing voting). - Only considers
+ * sites the player CANNOT currently vote on (seconds > 0).
+ *
+ * @return NextSite or null if there is no upcoming site (i.e. can vote all, or
+ * no delays)
+ */
+ public NextSite getNextSiteAvailable() {
+ List sites = plugin.getVoteSiteManager().getVoteSitesEnabled();
+ if (sites == null || sites.isEmpty()) {
+ return null;
+ }
+
+ VoteSite bestSite = null;
+ long bestSeconds = 0;
+
+ for (int i = 0; i < sites.size(); i++) {
+ VoteSite site = sites.get(i);
+ if (site == null) {
+ continue;
+ }
+
+ // Match the same "don't care" sites as your other checks generally do
+ if (!site.isEnabled() || site.isHidden()) {
+ continue;
+ }
+
+ // If you can already vote, it isn't "next"
+ if (canVoteSite(site)) {
+ continue;
+ }
+
+ long seconds = voteNextDurationTime(site); // uses getTime(site) internally
+ if (seconds <= 0) {
+ continue;
+ }
+
+ if (bestSite == null || seconds < bestSeconds) {
+ bestSite = site;
+ bestSeconds = seconds;
+ }
+ }
+
+ return bestSite == null ? null : new NextSite(bestSite, bestSeconds);
+ }
+
+ /**
+ * Gets the number of offline votes for the specified vote site.
+ *
+ * @param site the vote site
+ * @return the number of offline votes for the specified vote site
+ */
+ public int getNumberOfOfflineVotes(VoteSite site) {
+ ArrayList offlineVotes = getOfflineVotes();
+ int num = 0;
+ for (String str : offlineVotes) {
+ if (str.equals(site.getKey())) {
+ num++;
+ }
+ }
+ return num;
+ }
+
+ /**
+ * Gets the list of offline votes.
+ *
+ * @return the list of offline votes
+ */
+ public ArrayList getOfflineVotes() {
+ return getUserData().getStringList("OfflineVotes");
+ }
+
+ /**
+ * Gets the points of the user.
+ *
+ * @return the points of the user
+ */
+ public int getPoints() {
+ return getUserData().getInt(getPointsPath());
+ }
+
+ /**
+ * Gets the path for the points.
+ *
+ * @return the points path
+ */
+ public String getPointsPath() {
+ if (plugin.getBungeeSettings().isPerServerPoints()) {
+ return plugin.getBungeeSettings().getServerNameStorage() + "_Points";
+ }
+ return "Points";
+ }
+
+ /**
+ * Gets the number of sites not voted on.
+ *
+ * @return the number of sites not voted on
+ */
+ public int getSitesNotVotedOn() {
+ int amount = 0;
+ for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ if (!site.isHidden()) {
+ if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) {
+ if (canVoteSite(site)) {
+ amount++;
+ }
+ }
+ }
+ }
+ return amount;
+ }
+
+ /**
+ * Match the existing offline result without resolving a live Bukkit player.
+ * Permission-gated sites are excluded because the normal call uses
+ * {@code hasPermission(permission, false)}, which returns false offline.
+ */
+ public int getSitesNotVotedOnWithoutOnlinePermissions() {
+ int amount = 0;
+ for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ if (!site.isHidden() && site.getPermissionToView().isEmpty() && canVoteSite(site)) amount++;
+ }
+ return amount;
+ }
+
+ public int getTotalNumberOfSites() {
+ int amount = 0;
+ for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ if (!site.isHidden()) {
+ if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) {
+ amount++;
+ }
+ }
+ }
+ return amount;
+ }
+
+ /**
+ * Match the existing offline total without resolving a live Bukkit player.
+ * Permission-gated sites are excluded because the normal call uses
+ * {@code hasPermission(permission, false)}, which returns false offline.
+ */
+ public int getTotalNumberOfSitesWithoutOnlinePermissions() {
+ int amount = 0;
+ for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ if (!site.isHidden() && site.getPermissionToView().isEmpty()) amount++;
+ }
+ return amount;
+ }
+
+ public int getSitesVotedOn() {
+ int amount = 0;
+ for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ if (!canVoteSite(site)) {
+ amount++;
+ }
+ }
+ return amount;
+ }
+
+ /**
+ * Gets the time.
+ *
+ * @param voteSite the vote site
+ * @return the time
+ */
+ public long getTime(VoteSite voteSite) {
+ HashMap lastVotes = getLastVotes();
+ if (lastVotes.containsKey(voteSite)) {
+ return lastVotes.get(voteSite);
+ }
+ return 0;
+ }
+
+ /**
+ * Gets the top voter player.
+ *
+ * @return the top voter player
+ */
+ public TopVoterPlayer getTopVoterPlayer() {
+ return new TopVoterPlayer(UUID.fromString(getUUID()), getPlayerName(), getLastOnline());
+ }
+
+ /**
+ * Gets the total votes for the specified top voter category.
+ *
+ * @param top the top voter category
+ * @return the total votes for the specified top voter category
+ */
+ public int getTotal(TopVoter top) {
+ switch (top) {
+ case AllTime:
+ return getUserData().getInt("AllTimeTotal");
+ case Daily:
+ return getUserData().getInt("DailyTotal");
+ case Monthly:
+ if (plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) {
+ return getData().getInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath());
+ }
+ return getData().getInt("MonthTotal");
+ case Weekly:
+ return getUserData().getInt("WeeklyTotal");
+ default:
+ break;
+ }
+ return 0;
+ }
+
+ /**
+ * Gets the total votes for the specified top voter category at a specific time.
+ *
+ * @param top the top voter category
+ * @param atTime the specific time
+ * @return the total votes for the specified top voter category at the specific
+ * time
+ */
+ public int getTotal(TopVoter top, LocalDateTime atTime) {
+ switch (top) {
+ case AllTime:
+ return getUserData().getInt("AllTimeTotal");
+ case Daily:
+ return getUserData().getInt("DailyTotal");
+ case Monthly:
+ if (plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) {
+ return getData().getInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath(atTime));
+ }
+ return getData().getInt("MonthTotal");
+ case Weekly:
+ return getUserData().getInt("WeeklyTotal");
+ default:
+ break;
+ }
+ return 0;
+ }
+
+ /**
+ * Gets the number of votes for the vote party.
+ *
+ * @return the number of votes for the vote party
+ */
+ public int getVotePartyVotes() {
+ return getUserData().getInt("VotePartyVotes");
+ }
+
+ /**
+ * Gets the vote shop identifier limit.
+ *
+ * @param identifier the identifier for the vote shop
+ * @return the vote shop identifier limit
+ */
+ public int getVoteShopIdentifierLimit(String identifier) {
+ String path = "VoteShopLimit" + identifier;
+ if (usesSharedMysqlPoints()) {
+ // Placeholder and GUI rendering may run on the Bukkit main thread or a
+ // Folia-owned tick thread. This accessor therefore never performs JDBC for
+ // shared MySQL; purchases remain protected by their atomic reservation.
+ return getData().getInt(path,
+ isCached() ? UserDataFetchMode.CACHE_ONLY : UserDataFetchMode.TEMP_ONLY);
+ }
+ return getData().getInt(path);
+ }
+
+ /**
+ * Gets the weekly total votes.
+ *
+ * @return the weekly total votes
+ * @deprecated Use getTotal(TopVoter.Weekly) instead
+ */
+ @Deprecated
+ public int getWeeklyTotal() {
+ return getTotal(TopVoter.Weekly);
+ }
+
+ /**
+ * Gets the week vote streak.
+ *
+ * @return the week vote streak
+ */
+ @Deprecated
+ public int getWeekVoteStreak() {
+ return getData().getInt("WeekVoteStreak");
+ }
+
+ /**
+ * Gives the daily top voter award.
+ *
+ * @param place the place of the top voter
+ * @param path the path to the reward configuration
+ */
+ public void giveDailyTopVoterAward(int place, String path) {
+ SpecialRewardType type = SpecialRewardType.TOPVOTER;
+ type.setType("Daily");
+ type.setAmount(1);
+ PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null);
+ Bukkit.getPluginManager().callEvent(event);
+
+ if (event.isCancelled()) {
+ return;
+ }
+ new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
+ plugin.getSpecialRewardsConfig().getDailyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
+ .withPlaceHolder("topvoter", "Daily").withPlaceHolder("votes", "" + getTotal(TopVoter.Daily))
+ .setOnline(isOnline()).send(this);
+ }
+
+ /**
+ * Gives the monthly top voter award.
+ *
+ * @param place the place of the top voter
+ * @param path the path to the reward configuration
+ */
+ public void giveMonthlyTopVoterAward(int place, String path) {
+ SpecialRewardType type = SpecialRewardType.TOPVOTER;
+ type.setType("Monthly");
+ type.setAmount(1);
+ PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null);
+ Bukkit.getPluginManager().callEvent(event);
+
+ if (event.isCancelled()) {
+ return;
+ }
+ new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
+ plugin.getSpecialRewardsConfig().getMonthlyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
+ .withPlaceHolder("topvoter", "Monthly").withPlaceHolder("votes", "" + getTotal(TopVoter.Monthly))
+ .setOnline(isOnline()).send(this);
+ }
+
+ /**
+ * Gives the weekly top voter award.
+ *
+ * @param place the place of the top voter
+ * @param path the path to the reward configuration
+ */
+ public void giveWeeklyTopVoterAward(int place, String path) {
+ SpecialRewardType type = SpecialRewardType.TOPVOTER;
+ type.setType("Weekly");
+ type.setAmount(1);
+ PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null);
+ Bukkit.getPluginManager().callEvent(event);
+
+ if (event.isCancelled()) {
+ return;
+ }
+ new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
+ plugin.getSpecialRewardsConfig().getWeeklyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
+ .withPlaceHolder("topvoter", "Weekly").withPlaceHolder("votes", "" + getTotal(TopVoter.Weekly))
+ .setOnline(isOnline()).send(this);
+ }
+
+ /**
+ * Gets how many unique vote sites this user has voted on today.
+ *
+ * Uses existing LastVotes data (no storage). A site counts if its last-vote
+ * timestamp falls on "today" using VotingPlugin's time offset.
+ *
+ * @return number of unique sites voted on today
+ */
+ public long getUniqueVoteSitesToday() {
+ LocalDateTime now = plugin.getTimeChecker().getTime();
+ LocalDate today = now.toLocalDate();
+
+ long count = 0;
+
+ for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
+ if (site == null || !site.isEnabled() || site.isHidden()) {
+ continue;
+ }
+
+ long time = getTime(site);
+ if (time <= 0) {
+ continue;
+ }
+
+ // Match the same offset handling as canVoteSite()
+ LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault())
+ .plusHours(plugin.getOptions().getTimeHourOffSet());
+
+ if (lastVote.toLocalDate().equals(today)) {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ /**
+ * Checks if the user has a percentage of the total votes.
+ *
+ * @param top the top voter category
+ * @param percentage the percentage of the total votes
+ * @param time the specific time
+ * @return true if the user has the percentage of the total votes, false
+ * otherwise
+ */
+ public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time) {
+ int total = getTotal(top, time);
+ switch (top) {
+ case Daily:
+ return (double) total / (double) plugin.getVoteSiteManager().getVoteSitesEnabled().size()
+ * 100 > percentage;
+ case Monthly:
+ return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size()
+ * time.getMonth().length(false)) * 100 > percentage;
+ case Weekly:
+ return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() * 7) * 100 > percentage;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Checks if the user is ignored for top voter.
+ *
+ * @return true if the user is ignored for top voter, false otherwise
+ */
+ public boolean isTopVoterIgnore() {
+ return getUserData().getBoolean("TopVoterIgnore");
+ }
+
+ /**
+ * Gives login rewards to the user.
+ */
+ public void loginRewards() {
+ if (plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LoginRewards")) {
+ new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LoginRewards").send(this);
+ }
+ }
+
+ /** Storage preparation stays on its caller; AdvancedCore owns platform reward scheduling. */
+ public CompletionStage loginRewardsAsync() {
+ if (!plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LoginRewards")) {
+ return CompletableFuture.completedFuture(null);
+ }
+ return new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LoginRewards").sendAsync(this);
+ }
+
+ /**
+ * Gives logout rewards to the user.
+ */
+ public void logoutRewards() {
+ if (plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LogoutRewards")) {
+ new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LogoutRewards").send(this);
+ }
+ }
+
+ /**
+ * Merges the provided data with the current data.
+ *
+ * @param toAdd the data to add
+ */
+ public void mergeData(HashMap toAdd) {
+ HashMap currentData = getData().getValues();
+ HashMap newData = new HashMap<>();
+
+ for (TopVoter top : TopVoter.values()) {
+ if (toAdd.containsKey(top.getColumnName()) && currentData.containsKey(top.getColumnName())) {
+ newData.put(top.getColumnName(), new DataValueInt(
+ currentData.get(top.getColumnName()).getInt() + toAdd.get(top.getColumnName()).getInt()));
+ }
+ }
+
+ if (newData.size() > 0) {
+ getData().setValues(newData);
+ }
+ }
+
+ /** Legacy admission-only API; use offVoteAsync for dependent work. */
+ public void offVote() {
+ if (!plugin.getOptions().isProcessRewards()) return;
+ Player player = getPlayer();
+ if (!plugin.getOptions().isOnlineMode()) player = Bukkit.getPlayer(getPlayerName());
+ offVoteAndThen(player, () -> { });
+ }
+
+ /**
+ * Capture permission on the player owner, then replay on the storage worker.
+ * Completion means durable replay finished, including an empty/disabled no-op.
+ * A retired player is skipped without touching its entity from a global fallback.
+ */
+ public CompletionStage offVoteAsync(Player player) {
+ if (!plugin.getOptions().isProcessRewards() || player == null) {
+ return CompletableFuture.completedFuture(null);
+ }
+ CompletableFuture confirmed = new CompletableFuture<>();
+ plugin.getOfflineVoteOwnerHandoffs().submit(confirmed, begin ->
+ BukkitCompletionScheduler.run(plugin, player, () -> {
+ if (!begin.getAsBoolean()) return;
+ try {
+ if (!player.isOnline()) {
+ confirmed.complete(null);
+ return;
+ }
+ boolean ignored = player.hasPermission("VotingPlugin.TopVoter.Ignore");
+ plugin.getUserManager().getDataManager().getTimer().execute(() -> {
+ try {
+ cache();
+ offVoteWithCapturedTopVoterIgnoreAsync(ignored, () -> { }, true).whenComplete((value, failure) -> {
+ if (failure == null) confirmed.complete(null);
+ else confirmed.completeExceptionally(failure);
+ });
+ } catch (RuntimeException | Error failure) {
+ confirmed.completeExceptionally(failure);
+ }
+ });
+ } catch (RuntimeException | Error failure) {
+ confirmed.completeExceptionally(failure);
+ }
+ }, () -> { if (begin.getAsBoolean()) confirmed.complete(null); }, () -> {
+ if (begin.getAsBoolean()) confirmed.completeExceptionally(
+ new IllegalStateException("Offline replay owner scheduling rejected"));
+ }));
+ return confirmed;
+ }
+
+ /** Dependent work always returns to storage, even for a no-op replay. */
+ public void offVoteAndThen(Player player, Runnable continuation) {
+ offVoteAsync(player).whenComplete((ignored, failure) -> {
+ if (failure != null) {
+ plugin.getLogger().warning("Offline vote replay remains pending for " + getUUID() + ": " + failure);
+ return;
+ }
+ try {
+ plugin.getUserManager().getDataManager().getTimer().execute(continuation);
+ } catch (RuntimeException rejected) {
+ plugin.getLogger().warning("Offline replay continuation rejected for " + getUUID() + ": " + rejected);
+ }
+ });
+ }
+
+ /**
+ * Legacy void entry point: enqueue durable replay on the storage worker.
+ * Returning means admission, not completed reward delivery. Never wait on a
+ * platform thread or clear a queue before the reward completion protocol.
+ */
+ public void offVoteWithCapturedTopVoterIgnore(boolean currentTopVoterIgnore) {
+ if (!plugin.getOptions().isProcessRewards()) return;
+ offVoteWithCapturedTopVoterIgnoreAndThen(currentTopVoterIgnore, () -> { });
+ }
+
+ /**
+ * Schedules legacy replay and invokes the continuation only after the durable
+ * replay completion. The continuation runs on the storage worker.
+ */
+ public void offVoteWithCapturedTopVoterIgnoreAndThen(boolean currentTopVoterIgnore, Runnable continuation) {
+ plugin.getUserManager().getDataManager().getTimer().execute(() -> {
+ try {
+ cache();
+ offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore)
+ .whenComplete((ignored, failure) -> {
+ if (failure != null) {
+ plugin.getLogger().warning("Offline vote replay remains pending for "
+ + getUUID() + ": " + failure);
+ } else {
+ continuation.run();
+ }
+ });
+ } catch (RuntimeException failure) {
+ plugin.getLogger().warning("Offline vote replay could not start for " + getUUID() + ": " + failure);
+ }
+ });
+ }
+
+ /**
+ * Process the next offline-reward batch on the storage worker. The original
+ * queue and a durable pending snapshot remain available until both reward
+ * delivery and a confirmed worker-side commit succeed. No Bukkit-affecting
+ * reward facade runs synchronously on the storage worker.
+ */
+ public CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore) {
+ return offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore, () -> { });
+ }
+
+ /**
+ * The callback marks the first possible nontransactional reward effect.
+ * Reads/cache misses before this point remain retryable in ordered VoteUpdate.
+ */
+ public CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore,
+ Runnable beforeRewardEffects) {
+ return offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore, beforeRewardEffects, false);
+ }
+
+ /** Login/hook dependents may await the existing batch without starting another reward chain. */
+ private CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore,
+ Runnable beforeRewardEffects, boolean joinExisting) {
+ if (!plugin.getOptions().isProcessRewards()) return CompletableFuture.completedFuture(null);
+ String uuid = getUUID();
+ CompletableFuture confirmed = new CompletableFuture<>();
+ synchronized (IN_FLIGHT_OFFLINE_VOTE_REWARDS) {
+ if (offlineVoteBulkClearActive) {
+ return CompletableFuture.failedFuture(new IllegalStateException("Offline-vote bulk clear is active"));
+ }
+ CompletableFuture existing = IN_FLIGHT_OFFLINE_VOTE_REWARDS.putIfAbsent(uuid, confirmed);
+ if (existing != null) {
+ // A dependent stage cannot cancel or complete the owning replay's future.
+ return joinExisting ? existing.thenApply(ignored -> null)
+ : CompletableFuture.failedFuture(new IllegalStateException(
+ "Offline-vote reward batch is still running for " + uuid));
+ }
+ }
+ confirmed.whenComplete((ignored, failure) -> IN_FLIGHT_OFFLINE_VOTE_REWARDS.remove(uuid, confirmed));
+ try {
+ if (isTopVoterIgnore() != currentTopVoterIgnore) setTopVoterIgnore(currentTopVoterIgnore);
+ if (!getPendingOfflineVoteRewardBatch().isEmpty()) {
+ beforeRewardEffects.run();
+ throw new IllegalStateException("Earlier offline-vote rewards require reconciliation for " + uuid);
+ }
+ ArrayList offlineVotes = getOfflineVotes();
+ if (offlineVotes.isEmpty()) {
+ confirmed.complete(null);
+ return confirmed;
+ }
getUserData().setStringList(OFFLINE_VOTES_REWARD_PENDING_KEY, new ArrayList<>(offlineVotes), false);
beforeRewardEffects.run();
@@ -2310,384 +2388,419 @@ public void reconcileOfflineVoteRewardBatch(List expectedPending, List(), false);
}
- /**
- * Processes a player vote.
- *
- * @param voteSite the vote site
- * @param online whether the player is online
- * @param bungee whether to use bungee
- */
- public void playerVote(VoteSite voteSite, boolean online, boolean bungee) {
- voteSite.giveRewards(this, online, bungee);
- }
-
- /**
- * Removes points from the user.
- * MySQL calls on platform-owned threads are queued in storage-worker order.
- * Their boolean result is a cached prediction, not proof of a committed debit;
- * use the completion overload for a confirmed result.
- *
- * @param points the number of points to remove
- * @return committed success for synchronous worker calls, otherwise a cached prediction
- */
- public boolean removePoints(int points) {
- SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
- if (sharedPoints.usesMysqlPointMutations()) return sharedPoints.remove(this, points, deferMysqlPointMutation());
- if (getPoints() >= points) {
- setPoints(getPoints() - points);
- return true;
- }
- return false;
+ /**
+ * Processes a player vote.
+ *
+ * @param voteSite the vote site
+ * @param online whether the player is online
+ * @param bungee whether to use bungee
+ */
+ public void playerVote(VoteSite voteSite, boolean online, boolean bungee) {
+ voteSite.giveRewards(this, online, bungee);
+ }
+
+ /**
+ * Removes points from the user.
+ * MySQL calls on platform-owned threads are queued in storage-worker order.
+ * Their boolean result is a cached prediction, not proof of a committed debit;
+ * use the completion overload for a confirmed result.
+ *
+ * @param points the number of points to remove
+ * @return committed success for synchronous worker calls, otherwise a cached prediction
+ */
+ public boolean removePoints(int points) {
+ SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
+ if (sharedPoints.usesMysqlPointMutations()) return sharedPoints.remove(this, points, deferMysqlPointMutation());
+ if (getPoints() >= points) {
+ setPoints(getPoints() - points);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Removes points from the user asynchronously.
+ * Platform-owned MySQL callers always defer, even when async is false.
+ * A deferred call returns a cached prediction; use completion for confirmation.
+ *
+ * @param points the number of points to remove
+ * @param async whether to remove the points asynchronously
+ * @return committed success for synchronous worker calls, otherwise a cached prediction
+ */
+ public boolean removePoints(int points, boolean async) {
+ SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
+ if (sharedPoints.usesMysqlPointMutations()) return sharedPoints.remove(this, points, async || deferMysqlPointMutation());
+ if (getPoints() >= points) {
+ setPoints(getPoints() - points, async);
+ return true;
+ }
+ return false;
+ }
+
+ /** Removes points without performing shared-database I/O on the caller thread. */
+ public void removePoints(int points, Consumer completion) {
+ removePointsOutcome(points, outcome -> completion.accept(outcome == SharedMysqlPointMutator.MutationOutcome.CONFIRMED));
+ }
+
+ void removePointsOutcome(int points, Consumer completion) {
+ SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
+ if (!sharedPoints.usesMysqlPointMutations()) {
+ completion.accept(removePoints(points) ? SharedMysqlPointMutator.MutationOutcome.CONFIRMED
+ : SharedMysqlPointMutator.MutationOutcome.REJECTED);
+ return;
+ }
+ Player player = getPlayer();
+ String pointsColumn = getPointsPath();
+ UUID pointUuid = getJavaUUID();
+ var pointPlaceholders = plugin.getPlaceholders();
+ pointPlaceholders.beginSharedPointDebit(pointUuid);
+ java.util.concurrent.atomic.AtomicBoolean fenceFinished = new java.util.concurrent.atomic.AtomicBoolean();
+ java.util.function.BiConsumer finishFence = (known, trustworthy) -> {
+ if (fenceFinished.compareAndSet(false, true)) {
+ pointPlaceholders.finishSharedPointDebit(pointUuid, known, trustworthy);
+ }
+ };
+ try {
+ String operationId = "remove-points/" + UUID.randomUUID();
+ plugin.getTimer().execute(() -> {
+ SharedMysqlPointMutator.MutationOutcome outcome = SharedMysqlPointMutator.MutationOutcome.INDETERMINATE;
+ boolean trustworthy = false;
+ try {
+ SharedMysqlPointMutator.AddResult result = sharedPoints.removeCommittedFromColumn(this,
+ points, operationId, pointsColumn);
+ outcome = result.outcome();
+ if (outcome == SharedMysqlPointMutator.MutationOutcome.CONFIRMED) {
+ sharedPoints.acknowledgePointAdditionNow(operationId);
+ }
+ var caches = plugin.getUserManager().getDataManager().getUserDataCache();
+ var cache = caches.get(pointUuid);
+ if (cache != null) synchronized (cache) {
+ trustworthy = result.sourceRefreshed()
+ && outcome != SharedMysqlPointMutator.MutationOutcome.INDETERMINATE
+ && plugin.getPlaceholders() == pointPlaceholders && plugin.isEnabled()
+ && pointsColumn.equals(getPointsPath())
+ && caches.get(pointUuid) == cache && cache.hasPublishedStorageSnapshot();
+ }
+ } catch (RuntimeException failure) {
+ outcome = SharedMysqlPointMutator.MutationOutcome.INDETERMINATE;
+ plugin.debug(failure);
+ } finally {
+ finishFence.accept(outcome != SharedMysqlPointMutator.MutationOutcome.INDETERMINATE, trustworthy);
+ }
+ SharedMysqlPointMutator.MutationOutcome completed = outcome;
+ BukkitCompletionScheduler.run(plugin, player, () -> {
+ if (plugin.isEnabled() && plugin.getPlaceholders() == pointPlaceholders) completion.accept(completed);
+ });
+ });
+ } catch (RuntimeException rejected) {
+ finishFence.accept(true, false);
+ plugin.debug(rejected);
+ BukkitCompletionScheduler.run(plugin, player,
+ () -> {
+ if (plugin.isEnabled() && plugin.getPlaceholders() == pointPlaceholders)
+ completion.accept(SharedMysqlPointMutator.MutationOutcome.INDETERMINATE);
+ });
+ }
+ }
+
+ /**
+ * Atomically transfers points to another user when points are shared through
+ * MySQL, reporting completion on the Bukkit thread.
+ *
+ * @param target recipient
+ * @param points positive number of points
+ * @param completion whether the transfer completed
+ */
+ public void transferPoints(VotingPluginUser target, int points, Consumer completion) {
+ transferPointsWithResult(target, points, result -> completion.accept(completesLegacyTransfer(result)));
+ }
+
+ /**
+ * Legacy callers must not retry an indeterminate transfer: its approval hook
+ * has run and its durable journal row is retained for reconciliation.
+ */
+ static boolean completesLegacyTransfer(PointTransferResult result) {
+ return result == PointTransferResult.SUCCESS || result == PointTransferResult.PENDING_CONFIRMATION;
+ }
+
+ /**
+ * Transfers points and reports whether a failed transfer was caused by a
+ * conditional debit or by cancellation/availability.
+ */
+ public void transferPointsWithResult(VotingPluginUser target, int points, Consumer completion) {
+ SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
+ if (sharedPoints.usesMysqlPointMutations()) {
+ sharedPoints.transferWithBukkitApproval(this, target, points, ignored -> {
+ PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points, false);
+ Bukkit.getPluginManager().callEvent(receiveEvent);
+ return receiveEvent.isCancelled() ? null : receiveEvent.getPoints();
+ }, completion);
+ return;
+ }
+ boolean transferred = removePoints(points);
+ if (transferred) {
+ target.addPoints(points);
+ }
+ completion.accept(transferred ? PointTransferResult.SUCCESS : PointTransferResult.INSUFFICIENT_POINTS);
+ }
+
+ /**
+ * Resets the last voted time for all vote sites.
+ */
+ public void resetLastVoted() {
+ HashMap map = getLastVotes();
+ for (Entry e : map.entrySet()) {
+ e.setValue(0l);
+ }
+ setLastVotes(map);
+ }
+
+ /**
+ * Resets the last voted time for a specific vote site.
+ *
+ * @param site the vote site
+ */
+ public void resetLastVoted(VoteSite site) {
+ HashMap map = getLastVotes();
+ map.put(site, 0l);
+ setLastVotes(map);
+ }
+
+ /**
+ * Resets the total votes for a specific top voter category.
+ *
+ * @param topVoter the top voter category
+ */
+ public void resetTotals(TopVoter topVoter) {
+ setTotal(topVoter, 0);
+ }
+
+ /**
+ * Sends vote effects to the user.
+ *
+ * @param online whether the user is online
+ */
+ public void sendVoteEffects(boolean online) {
+ plugin.getRewardHandler().giveReward(this, plugin.getSpecialRewardsConfig().getData(),
+ plugin.getSpecialRewardsConfig().getAnySiteRewardsPath(), new RewardOptions().setOnline(online));
+ }
+
+ /**
+ * Sets the all-time total votes.
+ *
+ * @param allTimeTotal the all-time total votes
+ * @deprecated Use setTotal(TopVoter.AllTime, allTimeTotal) instead
+ */
+ @Deprecated
+ public void setAllTimeTotal(int allTimeTotal) {
+ setTotal(TopVoter.AllTime, allTimeTotal);
+ }
+
+ /**
+ * Sets the best day vote streak.
+ *
+ * @param streak the best day vote streak
+ */
+ @Deprecated
+ public void setBestDayVoteStreak(int streak) {
+ getData().setInt("BestDayVoteStreak", streak);
+ }
+
+ /**
+ * Sets the best month vote streak.
+ *
+ * @param streak the best month vote streak
+ */
+ @Deprecated
+ public void setBestMonthVoteStreak(int streak) {
+ getData().setInt("BestMonthVoteStreak", streak);
+ }
+
+ /**
+ * Sets the best week vote streak.
+ *
+ * @param streak the best week vote streak
+ */
+ @Deprecated
+ public void setBestWeekVoteStreak(int streak) {
+ getData().setInt("BestWeekVoteStreak", streak);
+ }
+
+ /**
+ * Sets the cooldown check.
+ *
+ * @param coolDownCheck whether the cooldown check is enabled
+ */
+ public void setCoolDownCheck(boolean coolDownCheck) {
+ getData().setBoolean(getCoolDownCheckPath(), coolDownCheck);
+ }
+
+ /**
+ * Sets the cooldown check for all vote sites.
+ *
+ * @param coolDownChecks the cooldown checks for all vote sites
+ */
+ public void setCoolDownCheckSite(HashMap coolDownChecks) {
+ ArrayList data = new ArrayList<>();
+ for (Entry entry : coolDownChecks.entrySet()) {
+ String str = entry.getKey() + "//" + entry.getValue().toString();
+ data.add(str);
+ }
+ getUserData().setStringList(getCoolDownCheckSitePath(), data);
+ }
+
+ /**
+ * Sets the cooldown check for a specific vote site.
+ *
+ * @param site the vote site
+ * @param value whether the cooldown check is enabled
+ */
+ public void setCoolDownCheckSite(VoteSite site, boolean value) {
+ HashMap coolDownChecks = getCoolDownCheckSiteList();
+ coolDownChecks.put(site.getKey(), Boolean.valueOf(value));
+ setCoolDownCheckSite(coolDownChecks);
+ }
+
+ /**
+ * Sets the daily total votes.
+ *
+ * @param total the daily total votes
+ * @deprecated Use setTotal(TopVoter.Daily, total) instead
+ */
+ @Deprecated
+ public void setDailyTotal(int total) {
+ setTotal(TopVoter.Daily, total);
+ }
+
+ /**
+ * Sets the day vote streak.
+ *
+ * @param streak the day vote streak
+ */
+ @Deprecated
+ public void setDayVoteStreak(int streak) {
+ getData().setInt("DayVoteStreak", streak);
+ if (getBestDayVoteStreak() < streak) {
+ setBestDayVoteStreak(streak);
+ }
+ }
+
+ /**
+ * Sets the last update time for the day vote streak.
+ *
+ * @param time the last update time for the day vote streak
+ */
+ @Deprecated
+ public void setDayVoteStreakLastUpdate(long time) {
+ getData().setString("DayVoteStreakLastUpdate", "" + time);
+ }
+
+ /**
+ * Sets whether the broadcast is disabled.
+ *
+ * @param value true to disable the broadcast, false otherwise
+ */
+ public void setDisableBroadcast(boolean value) {
+ getUserData().setBoolean("DisableBroadcast", value);
+ }
+
+ /**
+ * Sets the day when the user has gotten all sites.
+ *
+ * @param day the day when the user has gotten all sites
+ */
+ public void setGottenAllSitesDay(int day) {
+ getData().setInt(plugin.getVotingPluginUserManager().getGottenAllSitesDayPath(), day);
}
/**
- * Removes points from the user asynchronously.
- * Platform-owned MySQL callers always defer, even when async is false.
- * A deferred call returns a cached prediction; use completion for confirmation.
+ * Sets the day when the user has gotten almost all sites.
*
- * @param points the number of points to remove
- * @param async whether to remove the points asynchronously
- * @return committed success for synchronous worker calls, otherwise a cached prediction
+ * @param day the day when the user has gotten almost all sites
*/
- public boolean removePoints(int points, boolean async) {
- SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
- if (sharedPoints.usesMysqlPointMutations()) return sharedPoints.remove(this, points, async || deferMysqlPointMutation());
- if (getPoints() >= points) {
- setPoints(getPoints() - points, async);
- return true;
- }
- return false;
+ public void setGottenAlmostAllSitesDay(int day) {
+ getData().setInt(plugin.getVotingPluginUserManager().getGottenAlmostAllSitesDayPath(), day);
}
- /** Removes points without performing shared-database I/O on the caller thread. */
- public void removePoints(int points, Consumer completion) {
- removePointsOutcome(points, outcome -> completion.accept(outcome == SharedMysqlPointMutator.MutationOutcome.CONFIRMED));
+ /**
+ * Sets the highest daily total votes.
+ *
+ * @param total the highest daily total votes
+ */
+ public void setHighestDailyTotal(int total) {
+ getData().setInt("HighestDailyTotal", total);
}
- void removePointsOutcome(int points, Consumer completion) {
- SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
- if (!sharedPoints.usesMysqlPointMutations()) {
- completion.accept(removePoints(points) ? SharedMysqlPointMutator.MutationOutcome.CONFIRMED
- : SharedMysqlPointMutator.MutationOutcome.REJECTED);
- return;
- }
- Player player = getPlayer();
- String pointsColumn = getPointsPath();
- try {
- String operationId = "remove-points/" + UUID.randomUUID();
- plugin.getTimer().execute(() -> {
- SharedMysqlPointMutator.AddResult result = sharedPoints.removeCommittedFromColumn(this,
- points, operationId, pointsColumn);
- if (result.outcome() == SharedMysqlPointMutator.MutationOutcome.CONFIRMED) {
- sharedPoints.acknowledgePointAdditionNow(operationId);
- }
- BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(result.outcome()));
- });
- } catch (RuntimeException rejected) {
- plugin.debug(rejected);
- BukkitCompletionScheduler.run(plugin, player,
- () -> completion.accept(SharedMysqlPointMutator.MutationOutcome.INDETERMINATE));
- }
+ /**
+ * Sets the highest monthly total votes.
+ *
+ * @param total the highest monthly total votes
+ */
+ public void setHighestMonthlyTotal(int total) {
+ getData().setInt("HighestMonthlyTotal", total);
}
/**
- * Atomically transfers points to another user when points are shared through
- * MySQL, reporting completion on the Bukkit thread.
+ * Sets the highest weekly total votes.
*
- * @param target recipient
- * @param points positive number of points
- * @param completion whether the transfer completed
+ * @param total the highest weekly total votes
*/
- public void transferPoints(VotingPluginUser target, int points, Consumer completion) {
- transferPointsWithResult(target, points, result -> completion.accept(completesLegacyTransfer(result)));
+ public void setHighestWeeklyTotal(int total) {
+ getData().setInt("HighestWeeklyTotal", total);
}
/**
- * Legacy callers must not retry an indeterminate transfer: its approval hook
- * has run and its durable journal row is retained for reconciliation.
+ * Sets the total votes for the last month.
+ *
+ * @param total the total votes for the last month
*/
- static boolean completesLegacyTransfer(PointTransferResult result) {
- return result == PointTransferResult.SUCCESS || result == PointTransferResult.PENDING_CONFIRMATION;
+ public void setLastMonthTotal(int total) {
+ getData().setInt("LastMonthTotal", total);
}
/**
- * Transfers points and reports whether a failed transfer was caused by a
- * conditional debit or by cancellation/availability.
+ * Sets the last votes for each vote site.
+ *
+ * @param lastVotes a map of vote sites and the last vote time
*/
- public void transferPointsWithResult(VotingPluginUser target, int points, Consumer completion) {
- SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
- if (sharedPoints.usesMysqlPointMutations()) {
- sharedPoints.transferWithBukkitApproval(this, target, points, ignored -> {
- PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points, false);
- Bukkit.getPluginManager().callEvent(receiveEvent);
- return receiveEvent.isCancelled() ? null : receiveEvent.getPoints();
- }, completion);
- return;
+ public void setLastVotes(HashMap lastVotes) {
+ ArrayList data = new ArrayList<>();
+ for (Entry entry : lastVotes.entrySet()) {
+ String str = entry.getKey().getKey() + "//" + entry.getValue().longValue();
+ data.add(str);
}
- boolean transferred = removePoints(points);
- if (transferred) {
- target.addPoints(points);
+ getUserData().setStringList("LastVotes", data);
+ }
+
+ /**
+ * Sets the total votes for the month.
+ *
+ * @param total the total votes for the month
+ * @deprecated Use setTotal(TopVoter.Monthly, total) instead
+ */
+ @Deprecated
+ public void setMonthTotal(int total) {
+ setTotal(TopVoter.Monthly, total);
+ }
+
+ /**
+ * Sets the month vote streak.
+ *
+ * @param streak the month vote streak
+ */
+ @Deprecated
+ public void setMonthVoteStreak(int streak) {
+ getData().setInt("MonthVoteStreak", streak);
+ if (getBestMonthVoteStreak() < streak) {
+ setBestMonthVoteStreak(streak);
}
- completion.accept(transferred ? PointTransferResult.SUCCESS : PointTransferResult.INSUFFICIENT_POINTS);
}
-
- /**
- * Resets the last voted time for all vote sites.
- */
- public void resetLastVoted() {
- HashMap map = getLastVotes();
- for (Entry e : map.entrySet()) {
- e.setValue(0l);
- }
- setLastVotes(map);
- }
-
- /**
- * Resets the last voted time for a specific vote site.
- *
- * @param site the vote site
- */
- public void resetLastVoted(VoteSite site) {
- HashMap map = getLastVotes();
- map.put(site, 0l);
- setLastVotes(map);
- }
-
- /**
- * Resets the total votes for a specific top voter category.
- *
- * @param topVoter the top voter category
- */
- public void resetTotals(TopVoter topVoter) {
- setTotal(topVoter, 0);
- }
-
- /**
- * Sends vote effects to the user.
- *
- * @param online whether the user is online
- */
- public void sendVoteEffects(boolean online) {
- plugin.getRewardHandler().giveReward(this, plugin.getSpecialRewardsConfig().getData(),
- plugin.getSpecialRewardsConfig().getAnySiteRewardsPath(), new RewardOptions().setOnline(online));
- }
-
- /**
- * Sets the all-time total votes.
- *
- * @param allTimeTotal the all-time total votes
- * @deprecated Use setTotal(TopVoter.AllTime, allTimeTotal) instead
- */
- @Deprecated
- public void setAllTimeTotal(int allTimeTotal) {
- setTotal(TopVoter.AllTime, allTimeTotal);
- }
-
- /**
- * Sets the best day vote streak.
- *
- * @param streak the best day vote streak
- */
- @Deprecated
- public void setBestDayVoteStreak(int streak) {
- getData().setInt("BestDayVoteStreak", streak);
- }
-
- /**
- * Sets the best month vote streak.
- *
- * @param streak the best month vote streak
- */
- @Deprecated
- public void setBestMonthVoteStreak(int streak) {
- getData().setInt("BestMonthVoteStreak", streak);
- }
-
- /**
- * Sets the best week vote streak.
- *
- * @param streak the best week vote streak
- */
- @Deprecated
- public void setBestWeekVoteStreak(int streak) {
- getData().setInt("BestWeekVoteStreak", streak);
- }
-
- /**
- * Sets the cooldown check.
- *
- * @param coolDownCheck whether the cooldown check is enabled
- */
- public void setCoolDownCheck(boolean coolDownCheck) {
- getData().setBoolean(getCoolDownCheckPath(), coolDownCheck);
- }
-
- /**
- * Sets the cooldown check for all vote sites.
- *
- * @param coolDownChecks the cooldown checks for all vote sites
- */
- public void setCoolDownCheckSite(HashMap coolDownChecks) {
- ArrayList data = new ArrayList<>();
- for (Entry entry : coolDownChecks.entrySet()) {
- String str = entry.getKey() + "//" + entry.getValue().toString();
- data.add(str);
- }
- getUserData().setStringList(getCoolDownCheckSitePath(), data);
- }
-
- /**
- * Sets the cooldown check for a specific vote site.
- *
- * @param site the vote site
- * @param value whether the cooldown check is enabled
- */
- public void setCoolDownCheckSite(VoteSite site, boolean value) {
- HashMap coolDownChecks = getCoolDownCheckSiteList();
- coolDownChecks.put(site.getKey(), Boolean.valueOf(value));
- setCoolDownCheckSite(coolDownChecks);
- }
-
- /**
- * Sets the daily total votes.
- *
- * @param total the daily total votes
- * @deprecated Use setTotal(TopVoter.Daily, total) instead
- */
- @Deprecated
- public void setDailyTotal(int total) {
- setTotal(TopVoter.Daily, total);
- }
-
- /**
- * Sets the day vote streak.
- *
- * @param streak the day vote streak
- */
- @Deprecated
- public void setDayVoteStreak(int streak) {
- getData().setInt("DayVoteStreak", streak);
- if (getBestDayVoteStreak() < streak) {
- setBestDayVoteStreak(streak);
- }
- }
-
- /**
- * Sets the last update time for the day vote streak.
- *
- * @param time the last update time for the day vote streak
- */
- @Deprecated
- public void setDayVoteStreakLastUpdate(long time) {
- getData().setString("DayVoteStreakLastUpdate", "" + time);
- }
-
- /**
- * Sets whether the broadcast is disabled.
- *
- * @param value true to disable the broadcast, false otherwise
- */
- public void setDisableBroadcast(boolean value) {
- getUserData().setBoolean("DisableBroadcast", value);
- }
-
- /**
- * Sets the day when the user has gotten all sites.
- *
- * @param day the day when the user has gotten all sites
- */
- public void setGottenAllSitesDay(int day) {
- getData().setInt(plugin.getVotingPluginUserManager().getGottenAllSitesDayPath(), day);
- }
-
- /**
- * Sets the day when the user has gotten almost all sites.
- *
- * @param day the day when the user has gotten almost all sites
- */
- public void setGottenAlmostAllSitesDay(int day) {
- getData().setInt(plugin.getVotingPluginUserManager().getGottenAlmostAllSitesDayPath(), day);
- }
-
- /**
- * Sets the highest daily total votes.
- *
- * @param total the highest daily total votes
- */
- public void setHighestDailyTotal(int total) {
- getData().setInt("HighestDailyTotal", total);
- }
-
- /**
- * Sets the highest monthly total votes.
- *
- * @param total the highest monthly total votes
- */
- public void setHighestMonthlyTotal(int total) {
- getData().setInt("HighestMonthlyTotal", total);
- }
-
- /**
- * Sets the highest weekly total votes.
- *
- * @param total the highest weekly total votes
- */
- public void setHighestWeeklyTotal(int total) {
- getData().setInt("HighestWeeklyTotal", total);
- }
-
- /**
- * Sets the total votes for the last month.
- *
- * @param total the total votes for the last month
- */
- public void setLastMonthTotal(int total) {
- getData().setInt("LastMonthTotal", total);
- }
-
- /**
- * Sets the last votes for each vote site.
- *
- * @param lastVotes a map of vote sites and the last vote time
- */
- public void setLastVotes(HashMap lastVotes) {
- ArrayList data = new ArrayList<>();
- for (Entry entry : lastVotes.entrySet()) {
- String str = entry.getKey().getKey() + "//" + entry.getValue().longValue();
- data.add(str);
- }
- getUserData().setStringList("LastVotes", data);
- }
-
- /**
- * Sets the total votes for the month.
- *
- * @param total the total votes for the month
- * @deprecated Use setTotal(TopVoter.Monthly, total) instead
- */
- @Deprecated
- public void setMonthTotal(int total) {
- setTotal(TopVoter.Monthly, total);
- }
-
- /**
- * Sets the month vote streak.
- *
- * @param streak the month vote streak
- */
- @Deprecated
- public void setMonthVoteStreak(int streak) {
- getData().setInt("MonthVoteStreak", streak);
- if (getBestMonthVoteStreak() < streak) {
- setBestMonthVoteStreak(streak);
- }
- }
-
- /**
- * Sets the list of offline votes.
- *
- * @param offlineVotes the list of offline votes
- */
+
+ /**
+ * Sets the list of offline votes.
+ *
+ * @param offlineVotes the list of offline votes
+ */
public void setOfflineVotes(ArrayList offlineVotes) {
ArrayList pending = getPendingOfflineVoteRewardBatch();
if ((!pending.isEmpty() && (offlineVotes.size() < pending.size()
@@ -2697,8 +2810,8 @@ public void setOfflineVotes(ArrayList offlineVotes) {
+ getUUID() + "; use /av OfflineVoteRecovery before replacing the queue");
}
getUserData().setStringList("OfflineVotes", offlineVotes);
- }
-
+ }
+
private boolean deferMysqlPointMutation() {
return plugin.getUserManager().getDataManager().mustDeferSharedStorageAccess();
}
@@ -2717,14 +2830,14 @@ public void setPoints(int value) {
} else {
getUserData().setInt(getPointsPath(), value, false);
}
- }
-
- /**
- * Sets the points of the user asynchronously.
- *
- * @param value the number of points
- * @param async whether to set the points asynchronously
- */
+ }
+
+ /**
+ * Sets the points of the user asynchronously.
+ *
+ * @param value the number of points
+ * @param async whether to set the points asynchronously
+ */
public void setPoints(int value, boolean async) {
SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
if (sharedPoints.usesMysqlPointMutations()) {
@@ -2734,94 +2847,94 @@ public void setPoints(int value, boolean async) {
} else {
getUserData().setInt(getPointsPath(), value, false, async);
}
- }
-
- /**
- * Sets the current time for the specified vote site.
- *
- * @param voteSite the vote site
- */
- public void setTime(VoteSite voteSite) {
- setTime(voteSite, LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
- }
-
- /**
- * Sets the specified time for the specified vote site.
- *
- * @param voteSite the vote site
- * @param time the time to set
- */
- public void setTime(VoteSite voteSite, Long time) {
- HashMap lastVotes = getLastVotes();
- if (lastVotes != null && lastVotes.containsKey(voteSite)) {
- if (lastVotes.get(voteSite).longValue() == time.longValue()) {
- plugin.debug("Not setting last vote time for " + voteSite.getKey() + ", already set to " + time);
- return;
- }
- }
- lastVotes.put(voteSite, time);
- setLastVotes(lastVotes);
- }
-
- /**
- * Sets whether the user is ignored for top voter.
- *
- * @param topVoterIgnore true to ignore the user for top voter, false otherwise
- */
- public void setTopVoterIgnore(boolean topVoterIgnore) {
- getUserData().setString("TopVoterIgnore", "" + topVoterIgnore);
- }
-
- /**
- * Sets the total votes for the specified top voter category.
- *
- * @param top the top voter category
- * @param value the total votes to set
- */
- public void setTotal(TopVoter top, int value) {
- switch (top) {
- case AllTime:
- getUserData().setInt("AllTimeTotal", value);
- break;
- case Daily:
- getUserData().setInt("DailyTotal", value);
- break;
- case Monthly:
- if (plugin.getConfigFile().isLimitMonthlyVotes()) {
- LocalDateTime time = plugin.getTimeChecker().getTime();
- int days = time.getDayOfMonth();
- if (value >= days * plugin.getVoteSiteManager().getVoteSitesEnabled().size()) {
- value = days * plugin.getVoteSiteManager().getVoteSitesEnabled().size();
- }
- }
- getData().setInt("MonthTotal", value);
- if (plugin.getConfigFile().isStoreMonthTotalsWithDate()) {
- getData().setInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath(), value);
- }
- break;
- case Weekly:
- getUserData().setInt("WeeklyTotal", value);
- break;
- default:
- break;
- }
- }
-
- /**
- * Sets the number of votes for the vote party.
- *
- * @param value the number of votes to set
- */
- public void setVotePartyVotes(int value) {
- getUserData().setInt("VotePartyVotes", value);
- }
-
- /**
- * Sets the vote shop identifier limit.
- *
- * @param identifier the identifier for the vote shop
- * @param value the limit to set
- */
+ }
+
+ /**
+ * Sets the current time for the specified vote site.
+ *
+ * @param voteSite the vote site
+ */
+ public void setTime(VoteSite voteSite) {
+ setTime(voteSite, LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+ }
+
+ /**
+ * Sets the specified time for the specified vote site.
+ *
+ * @param voteSite the vote site
+ * @param time the time to set
+ */
+ public void setTime(VoteSite voteSite, Long time) {
+ HashMap lastVotes = getLastVotes();
+ if (lastVotes != null && lastVotes.containsKey(voteSite)) {
+ if (lastVotes.get(voteSite).longValue() == time.longValue()) {
+ plugin.debug("Not setting last vote time for " + voteSite.getKey() + ", already set to " + time);
+ return;
+ }
+ }
+ lastVotes.put(voteSite, time);
+ setLastVotes(lastVotes);
+ }
+
+ /**
+ * Sets whether the user is ignored for top voter.
+ *
+ * @param topVoterIgnore true to ignore the user for top voter, false otherwise
+ */
+ public void setTopVoterIgnore(boolean topVoterIgnore) {
+ getUserData().setString("TopVoterIgnore", "" + topVoterIgnore);
+ }
+
+ /**
+ * Sets the total votes for the specified top voter category.
+ *
+ * @param top the top voter category
+ * @param value the total votes to set
+ */
+ public void setTotal(TopVoter top, int value) {
+ switch (top) {
+ case AllTime:
+ getUserData().setInt("AllTimeTotal", value);
+ break;
+ case Daily:
+ getUserData().setInt("DailyTotal", value);
+ break;
+ case Monthly:
+ if (plugin.getConfigFile().isLimitMonthlyVotes()) {
+ LocalDateTime time = plugin.getTimeChecker().getTime();
+ int days = time.getDayOfMonth();
+ if (value >= days * plugin.getVoteSiteManager().getVoteSitesEnabled().size()) {
+ value = days * plugin.getVoteSiteManager().getVoteSitesEnabled().size();
+ }
+ }
+ getData().setInt("MonthTotal", value);
+ if (plugin.getConfigFile().isStoreMonthTotalsWithDate()) {
+ getData().setInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath(), value);
+ }
+ break;
+ case Weekly:
+ getUserData().setInt("WeeklyTotal", value);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Sets the number of votes for the vote party.
+ *
+ * @param value the number of votes to set
+ */
+ public void setVotePartyVotes(int value) {
+ getUserData().setInt("VotePartyVotes", value);
+ }
+
+ /**
+ * Sets the vote shop identifier limit.
+ *
+ * @param identifier the identifier for the vote shop
+ * @param value the limit to set
+ */
public void setVoteShopIdentifierLimit(String identifier, int value) {
String path = "VoteShopLimit" + identifier;
// VoteShopLimit columns are not server-suffixed, even when points are. On
@@ -2837,300 +2950,300 @@ private boolean usesSharedMysqlPoints() {
return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType())
&& !plugin.getBungeeSettings().isPerServerPoints();
}
-
- /**
- * Sets the weekly total votes.
- *
- * @param total the weekly total votes
- * @deprecated Use setTotal(TopVoter.Weekly, total) instead
- */
- @Deprecated
- public void setWeeklyTotal(int total) {
- setTotal(TopVoter.Weekly, total);
- }
-
- /**
- * Sets the week vote streak.
- *
- * @param streak the week vote streak
- */
- @Deprecated
- public void setWeekVoteStreak(int streak) {
- getData().setInt("WeekVoteStreak", streak);
- if (getBestWeekVoteStreak() < streak) {
- setBestWeekVoteStreak(streak);
- }
- }
-
- /**
- * Checks if the user should be reminded.
- *
- * @return true if the user should be reminded, false otherwise
- */
- public boolean shouldBeReminded() {
- Player player = getPlayer();
- if (player != null) {
- if (player.hasPermission("VotingPlugin.NoRemind")) {
- return false;
- }
- }
- return true;
- }
-
- /**
- * Gets the last vote date for the specified vote site.
- *
- * @param voteSite the vote site
- * @return the last vote date as a string
- * @deprecated Use getTime(VoteSite) instead
- */
- @Deprecated
- public String voteCommandLastDate(VoteSite voteSite) {
- long time = getTime(voteSite);
- if (time > 0) {
- Date date = new Date(time);
- String timeString = new SimpleDateFormat(plugin.getConfigFile().getFormatTimeFormat()).format(date);
- if (MessageAPI.containsIgnorecase(timeString, "YamlConfiguration")) {
- plugin.getLogger().warning("Detected issue parsing time, check time format");
- }
- return timeString;
- }
- return "";
- }
-
- /**
- * Gets the duration since the last vote for the specified vote site.
- *
- * @param voteSite the vote site
- * @return the duration since the last vote as a string
- */
- public String voteCommandLastDuration(VoteSite voteSite) {
- long time = getTime(voteSite);
- if (time > 0) {
- LocalDateTime now = LocalDateTime.now();
- LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault());
-
- Duration dur = Duration.between(lastVote, now);
-
- long diffSecond = dur.getSeconds();
- int diffDays = (int) (diffSecond / 60 / 60 / 24);
- int diffHours = (int) (diffSecond / 60 / 60 - diffDays * 24);
- int diffMinutes = (int) (diffSecond / 60 - diffHours * 60 - diffDays * 24 * 60);
- int diffSeconds = (int) (diffSecond - diffMinutes * 60 - diffHours * 60 * 60 - diffDays * 24 * 60 * 60);
-
- String info = "";
- if (diffDays == 1) {
- info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
- plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
- plugin.getConfigFile().getFormatTimeFormatsDay()), "amount", "" + diffDays);
- info += " ";
- } else if (diffDays > 1) {
- info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
- plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
- plugin.getConfigFile().getFormatTimeFormatsDays()), "amount", "" + diffDays);
- info += " ";
- }
-
- if (diffHours == 1) {
- info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
- plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
- plugin.getConfigFile().getFormatTimeFormatsHour()), "amount", "" + diffHours);
- info += " ";
- } else if (diffHours > 1) {
- info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
- plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
- plugin.getConfigFile().getFormatTimeFormatsHours()), "amount", "" + diffHours);
- info += " ";
- }
-
- if (diffMinutes == 1) {
- info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
- plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
- plugin.getConfigFile().getFormatTimeFormatsMinute()), "amount", "" + diffMinutes);
- info += " ";
- } else if (diffMinutes > 1) {
- info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
- plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
- plugin.getConfigFile().getFormatTimeFormatsMinutes()), "amount", "" + diffMinutes);
- info += " ";
- }
-
- if (plugin.getConfigFile().isFormatCommandsVoteLastIncludeSeconds()) {
- if (diffSeconds == 1) {
- info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
- plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
- plugin.getConfigFile().getFormatTimeFormatsSecond()), "amount", "" + diffSeconds);
- } else {
- info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
- plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
- plugin.getConfigFile().getFormatTimeFormatsSeconds()), "amount", "" + diffSeconds);
- }
- }
-
- info = PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatCommandsVoteLastLastVoted(),
- "times", info);
-
- return info;
- }
- return plugin.getConfigFile().getFormatCommandsVoteLastNeverVoted();
- }
-
- /**
- * Gets the last vote date and duration for the specified vote site for the GUI.
- *
- * @param voteSite the vote site
- * @return the last vote date and duration as a string for the GUI
- */
- public String voteCommandLastGUILine(VoteSite voteSite) {
- String timeString = voteCommandLastDate(voteSite);
- String timeSince = voteCommandLastDuration(voteSite);
-
- HashMap placeholders = new HashMap<>();
- placeholders.put("time", timeString);
+
+ /**
+ * Sets the weekly total votes.
+ *
+ * @param total the weekly total votes
+ * @deprecated Use setTotal(TopVoter.Weekly, total) instead
+ */
+ @Deprecated
+ public void setWeeklyTotal(int total) {
+ setTotal(TopVoter.Weekly, total);
+ }
+
+ /**
+ * Sets the week vote streak.
+ *
+ * @param streak the week vote streak
+ */
+ @Deprecated
+ public void setWeekVoteStreak(int streak) {
+ getData().setInt("WeekVoteStreak", streak);
+ if (getBestWeekVoteStreak() < streak) {
+ setBestWeekVoteStreak(streak);
+ }
+ }
+
+ /**
+ * Checks if the user should be reminded.
+ *
+ * @return true if the user should be reminded, false otherwise
+ */
+ public boolean shouldBeReminded() {
+ Player player = getPlayer();
+ if (player != null) {
+ if (player.hasPermission("VotingPlugin.NoRemind")) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Gets the last vote date for the specified vote site.
+ *
+ * @param voteSite the vote site
+ * @return the last vote date as a string
+ * @deprecated Use getTime(VoteSite) instead
+ */
+ @Deprecated
+ public String voteCommandLastDate(VoteSite voteSite) {
+ long time = getTime(voteSite);
+ if (time > 0) {
+ Date date = new Date(time);
+ String timeString = new SimpleDateFormat(plugin.getConfigFile().getFormatTimeFormat()).format(date);
+ if (MessageAPI.containsIgnorecase(timeString, "YamlConfiguration")) {
+ plugin.getLogger().warning("Detected issue parsing time, check time format");
+ }
+ return timeString;
+ }
+ return "";
+ }
+
+ /**
+ * Gets the duration since the last vote for the specified vote site.
+ *
+ * @param voteSite the vote site
+ * @return the duration since the last vote as a string
+ */
+ public String voteCommandLastDuration(VoteSite voteSite) {
+ long time = getTime(voteSite);
+ if (time > 0) {
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault());
+
+ Duration dur = Duration.between(lastVote, now);
+
+ long diffSecond = dur.getSeconds();
+ int diffDays = (int) (diffSecond / 60 / 60 / 24);
+ int diffHours = (int) (diffSecond / 60 / 60 - diffDays * 24);
+ int diffMinutes = (int) (diffSecond / 60 - diffHours * 60 - diffDays * 24 * 60);
+ int diffSeconds = (int) (diffSecond - diffMinutes * 60 - diffHours * 60 * 60 - diffDays * 24 * 60 * 60);
+
+ String info = "";
+ if (diffDays == 1) {
+ info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
+ plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
+ plugin.getConfigFile().getFormatTimeFormatsDay()), "amount", "" + diffDays);
+ info += " ";
+ } else if (diffDays > 1) {
+ info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
+ plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
+ plugin.getConfigFile().getFormatTimeFormatsDays()), "amount", "" + diffDays);
+ info += " ";
+ }
+
+ if (diffHours == 1) {
+ info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
+ plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
+ plugin.getConfigFile().getFormatTimeFormatsHour()), "amount", "" + diffHours);
+ info += " ";
+ } else if (diffHours > 1) {
+ info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
+ plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
+ plugin.getConfigFile().getFormatTimeFormatsHours()), "amount", "" + diffHours);
+ info += " ";
+ }
+
+ if (diffMinutes == 1) {
+ info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
+ plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
+ plugin.getConfigFile().getFormatTimeFormatsMinute()), "amount", "" + diffMinutes);
+ info += " ";
+ } else if (diffMinutes > 1) {
+ info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
+ plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
+ plugin.getConfigFile().getFormatTimeFormatsMinutes()), "amount", "" + diffMinutes);
+ info += " ";
+ }
+
+ if (plugin.getConfigFile().isFormatCommandsVoteLastIncludeSeconds()) {
+ if (diffSeconds == 1) {
+ info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
+ plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
+ plugin.getConfigFile().getFormatTimeFormatsSecond()), "amount", "" + diffSeconds);
+ } else {
+ info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder(
+ plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType",
+ plugin.getConfigFile().getFormatTimeFormatsSeconds()), "amount", "" + diffSeconds);
+ }
+ }
+
+ info = PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatCommandsVoteLastLastVoted(),
+ "times", info);
+
+ return info;
+ }
+ return plugin.getConfigFile().getFormatCommandsVoteLastNeverVoted();
+ }
+
+ /**
+ * Gets the last vote date and duration for the specified vote site for the GUI.
+ *
+ * @param voteSite the vote site
+ * @return the last vote date and duration as a string for the GUI
+ */
+ public String voteCommandLastGUILine(VoteSite voteSite) {
+ String timeString = voteCommandLastDate(voteSite);
+ String timeSince = voteCommandLastDuration(voteSite);
+
+ HashMap placeholders = new HashMap<>();
+ placeholders.put("time", timeString);
placeholders.put("SiteName", voteSite.getDisplayName());
- placeholders.put("timesince", timeSince);
-
- return PlaceholderUtils.replacePlaceHolder(plugin.getGui().getChestVoteLastLine(), placeholders);
- }
-
- /**
- * Gets the last vote date and duration for the specified vote site.
- *
- * @param voteSite the vote site
- * @return the last vote date and duration as a string
- */
- public String voteCommandLastLine(VoteSite voteSite) {
- String timeString = voteCommandLastDate(voteSite);
- String timeSince = voteCommandLastDuration(voteSite);
-
- HashMap placeholders = new HashMap<>();
- placeholders.put("time", timeString);
+ placeholders.put("timesince", timeSince);
+
+ return PlaceholderUtils.replacePlaceHolder(plugin.getGui().getChestVoteLastLine(), placeholders);
+ }
+
+ /**
+ * Gets the last vote date and duration for the specified vote site.
+ *
+ * @param voteSite the vote site
+ * @return the last vote date and duration as a string
+ */
+ public String voteCommandLastLine(VoteSite voteSite) {
+ String timeString = voteCommandLastDate(voteSite);
+ String timeSince = voteCommandLastDuration(voteSite);
+
+ HashMap placeholders = new HashMap<>();
+ placeholders.put("time", timeString);
placeholders.put("SiteName", voteSite.getDisplayName());
- placeholders.put("timesince", timeSince);
-
- return PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatCommandsVoteLastLine(),
- placeholders);
- }
-
- /**
- * Gets the next available vote time for the specified vote site.
- *
- * @param voteSite the vote site
- * @return the next available vote time as a string
- */
- public String voteCommandNextInfo(VoteSite voteSite) {
- return voteCommandNextInfo(voteSite, getTime(voteSite));
- }
-
- /**
- * Gets the next available vote time for the specified vote site.
- *
- * @param voteSite the vote site
- * @param time the current time
- * @return the next available vote time as a string
- */
- public String voteCommandNextInfo(VoteSite voteSite, long time) {
- String info = new String();
-
- long nextTime = voteNextDurationTime(voteSite, time);
- if (nextTime == 0) {
- info = plugin.getConfigFile().getFormatCommandsVoteNextInfoCanVote();
- } else {
- int diffHours = (int) (nextTime / (60 * 60));
- long diffMinutes = nextTime / 60 - diffHours * 60;
-
- if (diffHours < 0) {
- diffHours = diffHours * -1;
- }
- if (diffHours >= 24) {
- diffHours = diffHours - 24;
- }
- if (diffMinutes < 0) {
- diffMinutes = diffMinutes * -1;
- }
-
- String timeMsg = plugin.getConfigFile().getFormatCommandsVoteNextInfoVoteDelayDaily();
- timeMsg = MessageAPI.replaceIgnoreCase(timeMsg, "%hours%", Integer.toString(diffHours));
- timeMsg = MessageAPI.replaceIgnoreCase(timeMsg, "%minutes%", Long.toString(diffMinutes));
- info = timeMsg;
- }
-
- return info;
- }
-
- /**
- * Gets the next available vote duration time for the specified vote site.
- *
- * @param voteSite the vote site
- * @return the next available vote duration time in seconds
- */
- public long voteNextDurationTime(VoteSite voteSite) {
- return voteNextDurationTime(voteSite, getTime(voteSite));
- }
-
- /**
- * Gets the next available vote duration time for the specified vote site.
- *
- * @param voteSite the vote site
- * @param time the last vote time (epoch millis)
- * @return the next available vote duration time in seconds
- */
- public long voteNextDurationTime(VoteSite voteSite, long time) {
- LocalDateTime now = plugin.getTimeChecker().getTime();
-
- LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault())
- .plusHours(plugin.getOptions().getTimeHourOffSet());
-
- if (!voteSite.isVoteDelayDaily()) {
- ParsedDuration voteDelay = voteSite.getVoteDelay();
-
- if (time == 0 || voteDelay == null || voteDelay.isEmpty()) {
- return 0;
- }
-
- // Ignore months, use fixed duration only
- LocalDateTime nextVote = lastVote.plus(Duration.ofMillis(voteDelay.getMillis()));
-
- if (now.isAfter(nextVote)) {
- return 0;
- }
-
- return Duration.between(now, nextVote).getSeconds();
- }
-
- // Daily reset logic (unchanged)
- LocalDateTime resetTime = lastVote.withHour(voteSite.getVoteDelayDailyHour()).withMinute(0).withSecond(0);
-
- LocalDateTime resetTimeTomorrow = resetTime.plusHours(24);
-
- if (lastVote.isBefore(resetTime)) {
- if (now.isBefore(resetTime)) {
- return Duration.between(now, resetTime).getSeconds();
- }
- } else {
- if (now.isBefore(resetTimeTomorrow)) {
- return Duration.between(now, resetTimeTomorrow).getSeconds();
- }
- }
-
- return 0;
- }
-
- /**
- * Checks if the vote streak was updated today.
- *
- * @param time the current time
- * @return true if the vote streak was updated today, false otherwise
- */
- @Deprecated
- public boolean voteStreakUpdatedToday(LocalDateTime time) {
- return MiscUtils.getInstance().getTime(getDayVoteStreakLastUpdate()).getDayOfYear() == time.getDayOfYear();
- }
-
- public String getVoteStreakState(String columnName) {
- return getData().getString(columnName);
- }
-
+ placeholders.put("timesince", timeSince);
+
+ return PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatCommandsVoteLastLine(),
+ placeholders);
+ }
+
+ /**
+ * Gets the next available vote time for the specified vote site.
+ *
+ * @param voteSite the vote site
+ * @return the next available vote time as a string
+ */
+ public String voteCommandNextInfo(VoteSite voteSite) {
+ return voteCommandNextInfo(voteSite, getTime(voteSite));
+ }
+
+ /**
+ * Gets the next available vote time for the specified vote site.
+ *
+ * @param voteSite the vote site
+ * @param time the current time
+ * @return the next available vote time as a string
+ */
+ public String voteCommandNextInfo(VoteSite voteSite, long time) {
+ String info = new String();
+
+ long nextTime = voteNextDurationTime(voteSite, time);
+ if (nextTime == 0) {
+ info = plugin.getConfigFile().getFormatCommandsVoteNextInfoCanVote();
+ } else {
+ int diffHours = (int) (nextTime / (60 * 60));
+ long diffMinutes = nextTime / 60 - diffHours * 60;
+
+ if (diffHours < 0) {
+ diffHours = diffHours * -1;
+ }
+ if (diffHours >= 24) {
+ diffHours = diffHours - 24;
+ }
+ if (diffMinutes < 0) {
+ diffMinutes = diffMinutes * -1;
+ }
+
+ String timeMsg = plugin.getConfigFile().getFormatCommandsVoteNextInfoVoteDelayDaily();
+ timeMsg = MessageAPI.replaceIgnoreCase(timeMsg, "%hours%", Integer.toString(diffHours));
+ timeMsg = MessageAPI.replaceIgnoreCase(timeMsg, "%minutes%", Long.toString(diffMinutes));
+ info = timeMsg;
+ }
+
+ return info;
+ }
+
+ /**
+ * Gets the next available vote duration time for the specified vote site.
+ *
+ * @param voteSite the vote site
+ * @return the next available vote duration time in seconds
+ */
+ public long voteNextDurationTime(VoteSite voteSite) {
+ return voteNextDurationTime(voteSite, getTime(voteSite));
+ }
+
+ /**
+ * Gets the next available vote duration time for the specified vote site.
+ *
+ * @param voteSite the vote site
+ * @param time the last vote time (epoch millis)
+ * @return the next available vote duration time in seconds
+ */
+ public long voteNextDurationTime(VoteSite voteSite, long time) {
+ LocalDateTime now = plugin.getTimeChecker().getTime();
+
+ LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault())
+ .plusHours(plugin.getOptions().getTimeHourOffSet());
+
+ if (!voteSite.isVoteDelayDaily()) {
+ ParsedDuration voteDelay = voteSite.getVoteDelay();
+
+ if (time == 0 || voteDelay == null || voteDelay.isEmpty()) {
+ return 0;
+ }
+
+ // Ignore months, use fixed duration only
+ LocalDateTime nextVote = lastVote.plus(Duration.ofMillis(voteDelay.getMillis()));
+
+ if (now.isAfter(nextVote)) {
+ return 0;
+ }
+
+ return Duration.between(now, nextVote).getSeconds();
+ }
+
+ // Daily reset logic (unchanged)
+ LocalDateTime resetTime = lastVote.withHour(voteSite.getVoteDelayDailyHour()).withMinute(0).withSecond(0);
+
+ LocalDateTime resetTimeTomorrow = resetTime.plusHours(24);
+
+ if (lastVote.isBefore(resetTime)) {
+ if (now.isBefore(resetTime)) {
+ return Duration.between(now, resetTime).getSeconds();
+ }
+ } else {
+ if (now.isBefore(resetTimeTomorrow)) {
+ return Duration.between(now, resetTimeTomorrow).getSeconds();
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * Checks if the vote streak was updated today.
+ *
+ * @param time the current time
+ * @return true if the vote streak was updated today, false otherwise
+ */
+ @Deprecated
+ public boolean voteStreakUpdatedToday(LocalDateTime time) {
+ return MiscUtils.getInstance().getTime(getDayVoteStreakLastUpdate()).getDayOfYear() == time.getDayOfYear();
+ }
+
+ public String getVoteStreakState(String columnName) {
+ return getData().getString(columnName);
+ }
+
public void setVoteStreakState(String columnName, String value) {
getData().setString(columnName, value);
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java
index 7aeacd3124..8aa7e36072 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java
@@ -29,6 +29,14 @@
* arbitrary, non-idempotent side effects.
*/
final class SharedMysqlPurchaseJournal {
+ /** Positive proof that this invocation did not attempt a points write. */
+ static final class PreparationFailure extends SQLException {
+ private static final long serialVersionUID = 1L;
+ PreparationFailure(SQLException cause) {
+ super(cause.getMessage(), cause.getSQLState(), cause.getErrorCode(), cause);
+ }
+ }
+
private static final String PENDING = "PENDING";
private static final String HOOK_STARTED = "HOOK_STARTED";
private static final String COMPENSATING = "COMPENSATING";
@@ -119,60 +127,68 @@ private static void expungeInitialized() {
/** Atomically records a pending purchase and conditionally charges it. */
boolean reserve(String purchaseId, String uuid, String pointsColumn, String limitColumn, int cost, int limit,
String limitGeneration, long limitGenerationExpiresAt, long now) throws SQLException {
- String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid")
- + ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", "
- + qi("limit_value") + ", " + qi("limit_generation") + ", "
- + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state")
- + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
- String points = qi(pointsColumn);
- StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
- .append(points).append(" = COALESCE(").append(points).append(", 0) - ?");
- if (limitColumn != null) {
- debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn))
- .append(", 0) + 1");
- }
- debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND COALESCE(").append(points)
- .append(", 0) >= ?");
- if (limitColumn != null) {
- debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?");
- }
- try (Connection connection = connection()) {
- connection.setAutoCommit(false);
- try {
- Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn);
- try (PreparedStatement insertStatement = connection.prepareStatement(insert);
- PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) {
- insertStatement.setString(1, purchaseId);
- insertStatement.setString(2, uuid);
- insertStatement.setString(3, pointsColumn);
- insertStatement.setString(4, limitColumn);
- insertStatement.setInt(5, cost);
- if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER);
- else insertStatement.setInt(6, limit);
- if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR);
- else insertStatement.setString(7, limitGeneration);
- if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT);
- else insertStatement.setLong(8, limitGenerationExpiresAt);
- if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT);
- else insertStatement.setLong(9, limitEpoch.longValue());
- insertStatement.setString(10, PENDING);
- insertStatement.setLong(11, now);
- insertStatement.executeUpdate();
-
- debitStatement.setInt(1, cost);
- debitStatement.setString(2, uuid);
- debitStatement.setInt(3, cost);
- if (limitColumn != null) debitStatement.setInt(4, limit);
- if (debitStatement.executeUpdate() != 1) {
- rollback(connection);
- return false;
+ boolean pointWriteAttempted = false;
+ try {
+ String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid")
+ + ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", "
+ + qi("limit_value") + ", " + qi("limit_generation") + ", "
+ + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state")
+ + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+ String points = qi(pointsColumn);
+ StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
+ .append(points).append(" = COALESCE(").append(points).append(", 0) - ?");
+ if (limitColumn != null) {
+ debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn))
+ .append(", 0) + 1");
+ }
+ debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND COALESCE(").append(points)
+ .append(", 0) >= ?");
+ if (limitColumn != null) {
+ debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?");
+ }
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn);
+ try (PreparedStatement insertStatement = connection.prepareStatement(insert);
+ PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) {
+ insertStatement.setString(1, purchaseId);
+ insertStatement.setString(2, uuid);
+ insertStatement.setString(3, pointsColumn);
+ insertStatement.setString(4, limitColumn);
+ insertStatement.setInt(5, cost);
+ if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER);
+ else insertStatement.setInt(6, limit);
+ if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR);
+ else insertStatement.setString(7, limitGeneration);
+ if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT);
+ else insertStatement.setLong(8, limitGenerationExpiresAt);
+ if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT);
+ else insertStatement.setLong(9, limitEpoch.longValue());
+ insertStatement.setString(10, PENDING);
+ insertStatement.setLong(11, now);
+ insertStatement.executeUpdate();
+
+ debitStatement.setInt(1, cost);
+ debitStatement.setString(2, uuid);
+ debitStatement.setInt(3, cost);
+ if (limitColumn != null) debitStatement.setInt(4, limit);
+ pointWriteAttempted = true;
+ if (debitStatement.executeUpdate() != 1) {
+ rollback(connection);
+ return false;
+ }
+ return commitAndConfirm(connection, purchaseId, PENDING);
}
- return commitAndConfirm(connection, purchaseId, PENDING);
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
}
- } catch (SQLException failure) {
- rollback(connection);
- throw failure;
}
+ } catch (SQLException failure) {
+ if (failure instanceof PreparationFailure) throw failure;
+ if (!pointWriteAttempted) throw new PreparationFailure(failure);
+ throw failure;
}
}
@@ -370,65 +386,73 @@ private boolean setTerminal(String purchaseId, String terminalState, long now, S
private RefundedPurchase setTerminalDetails(String purchaseId, String terminalState, long now,
String... refundableStates)
throws SQLException {
- boolean refund = REFUNDED.equals(terminalState);
- String refundedUuid = null;
- String refundedPointsColumn = null;
- String refundedLimitColumn = null;
- String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column")
- + ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", "
- + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE "
- + qi("purchase_id") + " = ? FOR UPDATE";
- try (Connection connection = connection()) {
- connection.setAutoCommit(false);
- try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
- selectStatement.setString(1, purchaseId);
- try (ResultSet result = selectStatement.executeQuery()) {
- if (!result.next()) {
- rollback(connection);
- return null;
- }
- String state = result.getString(1);
- if (COMPLETED.equals(state) || REFUNDED.equals(state)) {
- rollback(connection);
- return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null;
- }
- if (refund && !isRefundableState(state, refundableStates)) {
- rollback(connection);
- return null;
+ boolean pointWriteAttempted = false;
+ try {
+ boolean refund = REFUNDED.equals(terminalState);
+ String refundedUuid = null;
+ String refundedPointsColumn = null;
+ String refundedLimitColumn = null;
+ String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column")
+ + ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", "
+ + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE "
+ + qi("purchase_id") + " = ? FOR UPDATE";
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
+ selectStatement.setString(1, purchaseId);
+ try (ResultSet result = selectStatement.executeQuery()) {
+ if (!result.next()) {
+ rollback(connection);
+ return null;
+ }
+ String state = result.getString(1);
+ if (COMPLETED.equals(state) || REFUNDED.equals(state)) {
+ rollback(connection);
+ return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null;
+ }
+ if (refund && !isRefundableState(state, refundableStates)) {
+ rollback(connection);
+ return null;
+ }
+ if (!refund && !HOOK_STARTED.equals(state)) {
+ rollback(connection);
+ return null;
+ }
+ String uuid = result.getString(2);
+ String pointsColumn = result.getString(3);
+ String limitColumn = result.getString(4);
+ refundedUuid = uuid;
+ refundedPointsColumn = pointsColumn;
+ refundedLimitColumn = limitColumn;
+ int cost = result.getInt(5);
+ String limitGeneration = result.getString(6);
+ Long limitEpoch = nullableLong(result, 8);
+ if (refund) {
+ boolean refundLimit = shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch);
+ pointWriteAttempted = true;
+ refund(connection, uuid, pointsColumn, limitColumn, cost, refundLimit);
+ }
}
- if (!refund && !HOOK_STARTED.equals(state)) {
+ }
+ String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id")
+ + " = ?";
+ try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
+ updateStatement.setString(1, terminalState);
+ updateStatement.setString(2, purchaseId);
+ if (updateStatement.executeUpdate() != 1) {
rollback(connection);
return null;
}
- String uuid = result.getString(2);
- String pointsColumn = result.getString(3);
- String limitColumn = result.getString(4);
- refundedUuid = uuid;
- refundedPointsColumn = pointsColumn;
- refundedLimitColumn = limitColumn;
- int cost = result.getInt(5);
- String limitGeneration = result.getString(6);
- Long limitEpoch = nullableLong(result, 8);
- if (refund) {
- refund(connection, uuid, pointsColumn, limitColumn, cost,
- shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch));
- }
- }
- }
- String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id")
- + " = ?";
- try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
- updateStatement.setString(1, terminalState);
- updateStatement.setString(2, purchaseId);
- if (updateStatement.executeUpdate() != 1) {
- rollback(connection);
- return null;
}
+ if (!commitAndConfirm(connection, purchaseId, terminalState)) return null;
+ return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn)
+ : new RefundedPurchase(null, null, null);
+ } catch (SQLException failure) {
+ throw failure;
}
- if (!commitAndConfirm(connection, purchaseId, terminalState)) return null;
- return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn)
- : new RefundedPurchase(null, null, null);
} catch (SQLException failure) {
+ if (failure instanceof PreparationFailure) throw failure;
+ if (!pointWriteAttempted) throw new PreparationFailure(failure);
throw failure;
}
}
@@ -442,20 +466,28 @@ private static boolean isRefundableState(String state, String... refundableState
private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost,
boolean refundLimit) throws SQLException {
- if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) {
- throw new SQLException("Unsafe durable purchase column");
- }
- StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
- .append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?");
- if (refundLimit) {
- refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn))
- .append(", 0) - 1, 0)");
- }
- refund.append(" WHERE ").append(qi("uuid")).append(uuidCast());
- try (PreparedStatement statement = connection.prepareStatement(refund.toString())) {
- statement.setInt(1, cost);
- statement.setString(2, uuid);
- if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing");
+ boolean pointWriteAttempted = false;
+ try {
+ if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) {
+ throw new SQLException("Unsafe durable purchase column");
+ }
+ StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
+ .append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?");
+ if (refundLimit) {
+ refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn))
+ .append(", 0) - 1, 0)");
+ }
+ refund.append(" WHERE ").append(qi("uuid")).append(uuidCast());
+ try (PreparedStatement statement = connection.prepareStatement(refund.toString())) {
+ statement.setInt(1, cost);
+ statement.setString(2, uuid);
+ pointWriteAttempted = true;
+ if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing");
+ }
+ } catch (SQLException failure) {
+ if (failure instanceof PreparationFailure) throw failure;
+ if (!pointWriteAttempted) throw new PreparationFailure(failure);
+ throw failure;
}
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java
index 21bf19fbe5..2e0a65f4b4 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java
@@ -186,15 +186,24 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item,
// or entity task runs could pair an old debit with a newly loaded reward.
FileConfiguration shopData = plugin.getShopFile().getData();
HashMap placeholders = purchasePlaceholders(item);
+ var pointPlaceholders = plugin.getPlaceholders();
+ Consumer admission = pointPlaceholders == null ? null
+ : pointPlaceholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID()));
try {
plugin.getTimer().execute(() -> {
try {
SharedPurchaseDebit debit;
- synchronized (purchaseLock(user.getUUID())) {
- // Sample the reset window beside the conditional debit. A queued
- // persistence task may otherwise cross into a new limit period.
- debit = reserveSharedMysqlPurchase(user, item,
- limitGeneration(item, System.currentTimeMillis()));
+ boolean settled = false;
+ try {
+ synchronized (purchaseLock(user.getUUID())) {
+ // Sample the reset window beside the conditional debit. A queued
+ // persistence task may otherwise cross into a new limit period.
+ debit = reserveSharedMysqlPurchase(user, item,
+ limitGeneration(item, System.currentTimeMillis()));
+ }
+ settled = true;
+ } finally {
+ if (admission != null) admission.accept(settled);
}
if (debit.result() != VoteShopPurchaseResult.SUCCESS) {
completePurchaseResult(player, completion, debit.result());
@@ -207,6 +216,7 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item,
}
});
} catch (RuntimeException persistenceRejected) {
+ if (admission != null) admission.accept(false);
plugin.debug(persistenceRejected);
completeFailedPurchase(player, completion);
}
@@ -479,20 +489,29 @@ private void completeClaimedRewardFailure(Player player,
}
private void refundCompensatingMysqlDebit(VotingPluginUser user, SharedPurchaseDebit debit) {
+ var publication = plugin.getPlaceholders();
+ UUID uuid = publication == null ? null : UUID.fromString(user.getUUID());
+ if (publication != null) publication.beginSharedPointDebit(uuid);
+ boolean known = false;
try {
- if (debit.journal().refundCompensatingReward(debit.purchaseId())) {
- refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
- }
+ debit.journal().refundCompensatingReward(debit.purchaseId());
+ known = true;
} catch (SQLException failure) {
+ known = failure instanceof SharedMysqlPurchaseJournal.PreparationFailure;
// A commit/confirmation failure is indeterminate: the refund transaction
// may have committed even though this worker could not observe its terminal
// journal state. Drop the affected snapshots before any later cache dump so
// a stale debit cannot overwrite a durable refund. Recovery will reconcile
// the journal state if the transaction did not commit.
- refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
plugin.getLogger().severe("Unable to refund an incomplete vote shop purchase: "
+ failure.getClass().getSimpleName());
plugin.debug(failure);
+ } finally {
+ try {
+ refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
+ } finally {
+ if (publication != null) publication.finishSharedPointDebit(uuid, known, false);
+ }
}
}
@@ -738,37 +757,50 @@ private static Connection requireConnection(MySQL table) throws SQLException {
private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, VoteShopItem item,
LimitGeneration limitGeneration) {
- MySQL table = plugin.getMysql();
- String pointsColumn = user.getPointsPath();
- String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null;
- ensurePerServerPointsColumn(table, pointsColumn);
- drainPurchaseCache(user, pointsColumn);
- if (limitColumn != null) {
- table.checkColumn(limitColumn, DataType.INTEGER);
- }
+ var publication = plugin.getPlaceholders();
+ UUID pointUuid = UUID.fromString(user.getUUID());
+ if (publication != null) publication.beginSharedPointDebit(pointUuid);
+ boolean known = true;
try {
- SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table);
- String purchaseId = UUID.randomUUID().toString();
- if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(),
- limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis())) {
- // reserve() returns only after its transaction and connection are closed;
- // NO_CACHE reads must not contend with its one-connection pool handle.
+ MySQL table = plugin.getMysql();
+ String pointsColumn = user.getPointsPath();
+ String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null;
+ ensurePerServerPointsColumn(table, pointsColumn);
+ drainPurchaseCache(user, pointsColumn);
+ if (limitColumn != null) {
+ table.checkColumn(limitColumn, DataType.INTEGER);
+ }
+ try {
+ SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table);
+ String purchaseId = UUID.randomUUID().toString();
+ known = false;
+ boolean reserved = journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(),
+ limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis());
+ known = true;
+ if (reserved) {
+ // reserve() returns only after its transaction and connection are closed;
+ // NO_CACHE reads must not contend with its one-connection pool handle.
+ refreshPurchaseCache(user, pointsColumn, limitColumn);
+ return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn,
+ limitColumn);
+ }
+ } catch (SQLException failure) {
+ known |= failure instanceof SharedMysqlPurchaseJournal.PreparationFailure;
+ // reserve() can throw after its commit acknowledgement and confirmation
+ // both fail. The debit may therefore be durable even though this caller
+ // reports FAILED; drop snapshots recreated during that transaction so a
+ // later cache dump cannot restore the pre-reservation values.
refreshPurchaseCache(user, pointsColumn, limitColumn);
- return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn,
- limitColumn);
+ plugin.getLogger().severe("Unable to atomically debit vote shop points: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null);
}
- } catch (SQLException failure) {
- // reserve() can throw after its commit acknowledgement and confirmation
- // both fail. The debit may therefore be durable even though this caller
- // reports FAILED; drop snapshots recreated during that transaction so a
- // later cache dump cannot restore the pre-reservation values.
refreshPurchaseCache(user, pointsColumn, limitColumn);
- plugin.getLogger().severe("Unable to atomically debit vote shop points: "
- + failure.getClass().getSimpleName());
- plugin.debug(failure);
- return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null);
+ return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null);
+ } finally {
+ if (publication != null) publication.finishSharedPointDebit(pointUuid, known, false);
}
- return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null);
}
/** The legacy cache path created server-suffixed points columns before direct SQL used them. */
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/commands/RejectedPointDebitPlaceholderTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/commands/RejectedPointDebitPlaceholderTest.java
new file mode 100644
index 0000000000..cb30ab4c40
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/commands/RejectedPointDebitPlaceholderTest.java
@@ -0,0 +1,153 @@
+package com.bencodez.votingplugin.tests.commands;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledExecutorService;
+
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import com.bencodez.advancedcore.AdvancedCorePlugin;
+import com.bencodez.advancedcore.api.command.CommandHandler;
+import com.bencodez.advancedcore.api.command.PlayerCommandHandler;
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
+import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.commands.CommandLoader;
+import com.bencodez.votingplugin.placeholders.PlaceHolders;
+import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel;
+import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence;
+import com.bencodez.votingplugin.user.VotingPluginUser;
+
+class RejectedPointDebitPlaceholderTest {
+ @Test void rejectedDebitRetiresStalePointPlaceholderBeforeCallback() throws Exception { debitOutcomeAndRefillRecovery(false, false); }
+ @Test void confirmedDebitRecoversAfterRefillFailure() throws Exception { debitOutcomeAndRefillRecovery(true, true); }
+ @Test void rejectedDebitRecoversAfterRefillFailure() throws Exception { debitOutcomeAndRefillRecovery(false, true); }
+
+ private void debitOutcomeAndRefillRecovery(boolean confirmed, boolean failInitialRefill) throws Exception {
+ var uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ var timer = mock(ScheduledExecutorService.class);
+ var pending = new java.util.ArrayDeque();
+ doAnswer(call -> { pending.addLast(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class));
+ var user = mock(VotingPluginUser.class, CALLS_REAL_METHODS);
+ var field = VotingPluginUser.class.getDeclaredField("plugin");
+ field.setAccessible(true);
+ field.set(user, plugin);
+ doReturn(uuid.toString()).when(user).getUUID();
+ doReturn(uuid).when(user).getJavaUUID();
+ doReturn("Points").when(user).getPointsPath();
+ doReturn(false).when(user).isCached();
+ doNothing().when(user).cache();
+ doReturn(mock(Player.class)).when(user).getPlayer();
+ doReturn(4).when(user).getPoints();
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getTimer()).thenReturn(timer);
+ when(plugin.isEnabled()).thenReturn(true);
+ when(plugin.getConfigFile().getPlaceholderCacheLevel()).thenReturn(PlaceholderCacheLevel.AUTOALL);
+ when(plugin.getConfigFile().getCachedPlaceholders()).thenReturn(new ArrayList<>(List.of("points")));
+ when(plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>());
+ when(plugin.getPlaceholderPlayerPresence()).thenReturn(new PlaceholderPlayerPresence());
+ var offline = mock(OfflinePlayer.class);
+ when(offline.getUniqueId()).thenReturn(uuid);
+ when(plugin.getVotingPluginUserManager().getVotingPluginUser(offline)).thenReturn(user);
+ when(plugin.getVotingPluginUserManager().getVotingPluginUser("TestUser")).thenReturn(user);
+ var cache = new UserDataCache(null, uuid);
+ cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(20))));
+ var manager = plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new ConcurrentHashMap<>(Map.of(uuid, cache)));
+ var refillFails = new java.util.concurrent.atomic.AtomicBoolean(failInitialRefill);
+ doAnswer(call -> {
+ if (refillFails.get()) throw new IllegalStateException("synthetic refill unavailable");
+ cache.updateSharedSnapshot(new HashMap<>(Map.of("Points", new DataValueInt(4))), cache.getSharedSnapshotVersion());
+ return null;
+ }).when(manager).cacheUser(uuid, null);
+ var table = mock(MySQL.class);
+ var sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, RETURNS_DEEP_STUBS);
+ var connection = mock(Connection.class);
+ var statement = mock(PreparedStatement.class);
+ when(statement.executeUpdate()).thenReturn(confirmed ? 1 : 0);
+ var read = mock(PreparedStatement.class);
+ var total = mock(ResultSet.class);
+ when(total.next()).thenReturn(true);
+ when(total.getInt(1)).thenReturn(4);
+ when(read.executeQuery()).thenReturn(total);
+ var lookup = mock(PreparedStatement.class);
+ when(lookup.executeQuery()).thenReturn(mock(ResultSet.class));
+ when(plugin.getMysql()).thenReturn(table);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenAnswer(call ->
+ call.getArgument(0).startsWith("SELECT `player_uuid`") ? lookup
+ : call.getArgument(0).startsWith("SELECT `Points` FROM") ? read : statement);
+ var commands = new ArrayList();
+ when(plugin.getAdminVoteCommand()).thenReturn(commands);
+ var oldInstance = AdvancedCorePlugin.getInstance();
+ var oldPlugin = VotingPluginMain.plugin;
+ AdvancedCorePlugin.setInstance(plugin);
+ VotingPluginMain.plugin = plugin;
+ try (var coreCommands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) {
+ var helper = mock(com.bencodez.advancedcore.command.CommandLoader.class);
+ coreCommands.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(helper);
+ when(helper.getBasicAdminCommands("VotingPlugin")).thenReturn(new ArrayList<>());
+ var placeholders = new PlaceHolders(plugin);
+ when(plugin.getPlaceholders()).thenReturn(placeholders);
+ placeholders.load();
+ var points = placeholders.getPlaceholders().stream().filter(p -> p.matches("points")).findFirst().orElseThrow();
+ points.getCache().get("points").put(uuid, "20");
+ try (var bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
+ assertEquals("20", placeholders.getPlaceHolder(offline, "points", false));
+ var register = CommandLoader.class.getDeclaredMethod("loadAdminVoteCommand");
+ register.setAccessible(true);
+ register.invoke(new CommandLoader(plugin));
+ var command = (PlayerCommandHandler) commands.stream()
+ .filter(c -> Arrays.equals(c.getArgs(), new String[] {"User", "(player)", "RemovePoints", "(number)"}))
+ .findFirst().orElseThrow();
+ command.executeSinglePlayer(mock(CommandSender.class), new String[] {"User", "TestUser", "RemovePoints", "16"});
+ assertEquals("...", placeholders.getPlaceHolder(offline, "points", false));
+ verify(user, never()).cache();
+ verify(sql.getConnectionManager(), never()).getConnection();
+ assertEquals(1, pending.size());
+ pending.removeFirst().run();
+ if (failInitialRefill) {
+ assertNull(cache.snapshotIfPublished());
+ assertEquals("...", placeholders.getPlaceHolder(offline, "points_nocache", false));
+ assertEquals(1, pending.size(), "normal request must admit recovery once");
+ refillFails.set(false);
+ pending.removeFirst().run();
+ } else {
+ assertEquals("...", placeholders.getPlaceHolder(offline, "points", false));
+ assertEquals(1, pending.size());
+ pending.removeFirst().run();
+ }
+ assertEquals(4, cache.snapshotIfPublished().get("Points").getInt());
+ assertEquals("4", placeholders.getPlaceHolder(offline, "points", false));
+ assertTrue(pending.isEmpty());
+ }
+ } finally {
+ AdvancedCorePlugin.setInstance(oldInstance);
+ VotingPluginMain.plugin = oldPlugin;
+ }
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/ColdOfflinePlaceholderCacheTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/ColdOfflinePlaceholderCacheTest.java
index 116069cb14..4baafa69ef 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/ColdOfflinePlaceholderCacheTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/ColdOfflinePlaceholderCacheTest.java
@@ -4,6 +4,7 @@
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.anyString;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
@@ -45,6 +46,1182 @@
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
class ColdOfflinePlaceholderCacheTest {
+
+ @org.junit.jupiter.api.io.TempDir java.nio.file.Path transferFallbackData;
+
+
+ @Test void activeRefillCannotBeReopenedByKnownDebitCompletion() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ var refill = f.placeholders.sharedPointRefillCompletion(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true, true);
+ try {
+ f.placeholders.onUpdate(f.user, true);
+ verify(f.user, never()).getPoints();
+ assertEquals("...", f.request("points_nocache"));
+ } finally { refill.accept(true); }
+ }
+
+ @Test void normalPointRequestRecoversKnownUnavailableSnapshotOnWorker() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ when(f.user.getPoints()).thenReturn(4);
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true, false);
+ assertEquals("...", f.request("points"));
+ assertEquals(1, f.pending.size(), "ordinary requests must admit one recovery worker");
+ assertEquals("...", f.request("points_nocache"));
+ f.placeholders.onUpdate(f.user, false);
+ assertEquals(1, f.pending.size(), "requests and onUpdate must coalesce while recovery is pending");
+ f.drain();
+ assertEquals("4", f.request("points_nocache"));
+ }
+
+ @Test void publishedAbsentPerServerPointFieldUsesZeroWithoutStorageRecovery() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.NONE);
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+ when(f.plugin.getBungeeSettings().getServerNameStorage()).thenReturn("serverA");
+ when(f.user.getPointsPath()).thenReturn("serverA_Points");
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Other", new com.bencodez.simpleapi.sql.data.DataValueInt(7))));
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ assertEquals("0", f.request("points_nocache"));
+ assertEquals("0", f.request("points_format_nocache"));
+ assertEquals("false", f.request("enoughpoints_16_nocache"));
+ assertTrue(f.pending.isEmpty());
+ verify(f.user, never()).getPoints();
+ verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any());
+ }
+
+ @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedMalformedPointsAUTO() {
+ assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.AUTO, new com.bencodez.simpleapi.sql.data.DataValueString("invalid"));
+ }
+
+ @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedUnsupportedPointsAUTO() {
+ assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.AUTO, new com.bencodez.simpleapi.sql.data.DataValueBoolean(true));
+ }
+
+ @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedNullPointsAUTO() {
+ assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.AUTO, null);
+ }
+
+ @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedMalformedPointsSPECIFIC() {
+ assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.SPECIFIC, new com.bencodez.simpleapi.sql.data.DataValueString("invalid"));
+ }
+
+ @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedUnsupportedPointsSPECIFIC() {
+ assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.SPECIFIC, new com.bencodez.simpleapi.sql.data.DataValueBoolean(true));
+ }
+
+ @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedNullPointsSPECIFIC() {
+ assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.SPECIFIC, null);
+ }
+
+ private void assertCommonOfflineInvalidPoints(PlaceholderCacheLevel level, com.bencodez.simpleapi.sql.data.DataValue invalid) {
+ Fixture f = new Fixture(level, "total_monthly");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var row = new java.util.HashMap();
+ row.put("Points", invalid);
+ cache.updateCache(row);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> { cache.updateCache(new java.util.HashMap<>(row)); return null; })
+ .when(manager).cacheUser(f.uuid, null);
+ for (int n = 0; n < 3; n++) {
+ assertEquals("...", f.request("total_monthly"));
+ assertEquals(1, f.pending.size());
+ f.pending.removeFirst().run();
+ assertTrue(f.pending.isEmpty());
+ assertFalse(f.placeholder("total_monthly").getCache().get("total_monthly").containsKey(f.uuid));
+ assertEquals("...", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty());
+ }
+ verify(manager, never()).cacheUser(any(), any());
+ verify(f.user, never()).getTotal(TopVoter.Monthly);
+ verify(f.user, never()).getPoints();
+ }
+
+ @Test void commonThenDirectColdRequestsShareQueuedAndExecutingRecovery() {
+ assertCommonAndDirectColdRecovery(false);
+ }
+
+ @Test void directThenCommonColdRequestsShareQueuedAndExecutingRecovery() {
+ assertCommonAndDirectColdRecovery(true);
+ }
+
+ private void assertCommonAndDirectColdRecovery(boolean directFirst) {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points", "total_monthly");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ // Reentrant public requests model admission while the worker owns its token/refill.
+ assertEquals("...", f.request("total_monthly"));
+ assertEquals("...", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty(), "executing common/direct recovery must not queue nested work");
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ assertEquals("...", f.request(directFirst ? "points_nocache" : "total_monthly"));
+ assertEquals("...", f.request(directFirst ? "total_monthly" : "points_nocache"));
+ assertEquals("...", f.request("total_monthly"));
+ assertEquals(1, f.pending.size(), "queued common/direct requests must share admission");
+ f.pending.removeFirst().run();
+ assertTrue(f.pending.isEmpty());
+ assertEquals("4", f.request("points_nocache"));
+ assertEquals("42", f.request("total_monthly"), "ordinary nonpoint warming must still complete");
+ verify(manager, times(1)).cacheUser(f.uuid, null);
+ verify(f.user, never()).getPoints();
+ }
+
+ @Test void commonWorkerRepairsKnownTransientSnapshotWithoutNestedTask() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true, false);
+ assertEquals("...", f.request("total_monthly"));
+ f.pending.removeFirst().run();
+ assertTrue(f.pending.isEmpty());
+ assertEquals("4", f.request("points_nocache"));
+ assertEquals("42", f.request("total_monthly"));
+ verify(manager, times(1)).cacheUser(f.uuid, null);
+ }
+
+ @Test void commonWorkerWarmsNonPointsWithoutRefillingPendingOrUnknownPoints() {
+ for (boolean unknown : List.of(false, true)) {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ if (unknown) f.placeholders.finishSharedPointDebit(f.uuid, false, false);
+ try {
+ assertEquals("...", f.request("total_monthly"));
+ f.pending.removeFirst().run();
+ assertEquals("42", f.request("total_monthly"));
+ assertEquals("...", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty());
+ verify(manager, never()).cacheUser(any(), any());
+ } finally {
+ if (!unknown) f.placeholders.finishSharedPointDebit(f.uuid, true, false);
+ }
+ }
+ }
+
+ @Test void staleCommonWorkersDoNotReadStorageOrWarmAfterDisableOrReplacement() {
+ for (boolean disabled : List.of(false, true)) {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ assertEquals("...", f.request("total_monthly"));
+ if (disabled) when(f.plugin.isEnabled()).thenReturn(false);
+ else when(f.plugin.getPlaceholders()).thenReturn(mock(PlaceHolders.class));
+ f.pending.removeFirst().run();
+ assertTrue(f.pending.isEmpty());
+ verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any());
+ verify(f.user, never()).getTotal(TopVoter.Monthly);
+ }
+ }
+
+ @Test void staleReloadedCommonWorkerCannotRemoveNewRecoveryAdmission() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ assertEquals("...", f.request("total_monthly"));
+ Runnable retired = f.pending.removeFirst();
+ try (var bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of());
+ f.placeholders.reload();
+ }
+ assertEquals("...", f.request("total_monthly"));
+ assertEquals(1, f.pending.size());
+ retired.run();
+ verify(manager, never()).cacheUser(any(), any());
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size(), "stale token retirement must preserve new worker admission");
+ f.pending.removeFirst().run();
+ assertEquals("4", f.request("points_nocache"));
+ assertEquals("42", f.request("total_monthly"));
+ assertTrue(f.pending.isEmpty());
+ verify(manager, times(1)).cacheUser(f.uuid, null);
+ }
+
+ @Test void rejectedCommonWorkerAdmissionAllowsLaterDirectRecovery() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ doThrow(new java.util.concurrent.RejectedExecutionException("closed"))
+ .doAnswer(call -> { f.pending.addLast(call.getArgument(0)); return null; })
+ .when(f.timer).execute(any(Runnable.class));
+ assertEquals("...", f.request("total_monthly"));
+ assertTrue(f.pending.isEmpty());
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size());
+ f.pending.removeFirst().run();
+ assertEquals("4", f.request("points_nocache"));
+ assertEquals("42", f.request("total_monthly"));
+ assertTrue(f.pending.isEmpty());
+ verify(manager, times(1)).cacheUser(f.uuid, null);
+ }
+
+ @Test void lateAutomaticNonPointAdmissionIsNotLostWhileCommonWorkerWarms() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly");
+ when(f.user.getLastMonthTotal()).thenReturn(7);
+ doAnswer(call -> {
+ assertEquals("..", f.request("lastmonthtotal"));
+ return 42;
+ }).doReturn(42).when(f.user).getTotal(TopVoter.Monthly);
+ assertEquals("...", f.request("total_monthly"));
+ f.pending.removeFirst().run();
+ assertEquals(1, f.pending.size(), "late new identifier needs a retained warming continuation");
+ f.drain();
+ assertEquals("7", f.request("lastmonthtotal"));
+ assertEquals("42", f.request("total_monthly"));
+ assertTrue(f.pending.isEmpty());
+ }
+
+ @Test void disableDuringCommonStorageRecoveryPreventsNonPointWarming() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ when(f.plugin.isEnabled()).thenReturn(false);
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ assertEquals("...", f.request("total_monthly"));
+ f.pending.removeFirst().run();
+ assertTrue(f.pending.isEmpty());
+ verify(manager, times(1)).cacheUser(f.uuid, null);
+ verify(f.user, never()).getTotal(TopVoter.Monthly);
+ }
+
+ @Test void completedWorkerPublishedMalformedPointsDoNotLoopRecovery() throws Exception {
+ assertCompletedInvalidSnapshot(new com.bencodez.simpleapi.sql.data.DataValueString("invalid"));
+ }
+ @Test void completedWorkerPublishedUnsupportedPointsDoNotLoopRecovery() throws Exception {
+ assertCompletedInvalidSnapshot(new com.bencodez.simpleapi.sql.data.DataValueBoolean(true));
+ }
+ @Test void completedWorkerPublishedNullPointsDoNotBecomeZeroOrLoopRecovery() throws Exception {
+ assertCompletedInvalidSnapshot(null);
+ }
+
+ private void assertCompletedInvalidSnapshot(com.bencodez.simpleapi.sql.data.DataValue invalid) {
+ Fixture f = new Fixture(PlaceholderCacheLevel.NONE);
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ var values = new java.util.HashMap();
+ values.put("Points", invalid); cache.updateCache(values); return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size());
+ f.pending.removeFirst().run();
+ assertTrue(f.pending.isEmpty());
+ for (int n = 0; n < 5; n++) {
+ assertEquals("...", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty(), "a completed invalid published row must not be reloaded per request");
+ }
+ verify(manager, times(1)).cacheUser(f.uuid, null);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ assertEquals("4", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty(), "new valid published data needs no extra storage query");
+ verify(f.user, never()).getPoints();
+ }
+
+ @Test void publicKnownUnstartedSetAcceptsCompletePublishedAbsentPointField() throws Exception {
+ Fixture f = mysqlPublicFixture();
+ when(f.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+ when(f.plugin.getBungeeSettings().getServerNameStorage()).thenReturn("serverA");
+ when(f.user.getPointsPath()).thenReturn("serverA_Points");
+ when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(null);
+ var manager = f.plugin.getUserManager().getDataManager();
+ doAnswer(call -> {
+ var complete = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ complete.updateCache(new java.util.HashMap<>());
+ manager.getUserDataCache().put(f.uuid, complete); return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ org.mockito.Mockito.doCallRealMethod().when(f.user).setPointsStorageAware(eq(4), any());
+ var results = new java.util.ArrayList();
+ f.user.setPointsStorageAware(4, results::add);
+ f.pending.removeFirst().run();
+ assertEquals(List.of(false), results);
+ assertEquals("0", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty(), "published absence after known completion must not start another recovery");
+ verify(manager, times(1)).cacheUser(f.uuid, null);
+ verify(f.user, never()).getPoints();
+ }
+
+ @Test void publishedInvalidThenValidSnapshotUpdatesCachedPointRendering() throws Exception {
+ Fixture f = mysqlPublicFixture();
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueString("invalid"))));
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ assertEquals("...", f.request("points"));
+ assertTrue(f.pending.isEmpty());
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ f.placeholders.onUpdate(f.user, true);
+ assertEquals("4", f.request("points"));
+ assertTrue(f.pending.isEmpty());
+ verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any());
+ verify(f.user, never()).getPoints();
+ }
+
+ @Test void unpublishedAbsenceAndGenuineUncertaintyNeverDefaultToZero() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.NONE);
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size());
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ cache.updateCache(new java.util.HashMap<>());
+ assertEquals("...", f.request("points_nocache"), "an active mutation cannot publish default zero");
+ f.placeholders.finishSharedPointDebit(f.uuid, false, true);
+ f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true);
+ assertEquals("...", f.request("points_nocache"), "published absence cannot clear transaction uncertainty");
+ try (var bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); f.placeholders.reload();
+ }
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size(), "unknown state must not add recovery work");
+ verify(f.user, never()).getPoints();
+ }
+
+ @Test void perServerNumericStringSnapshotPreservesPointValue() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.NONE);
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+ when(f.plugin.getBungeeSettings().getServerNameStorage()).thenReturn("serverA");
+ when(f.user.getPointsPath()).thenReturn("serverA_Points");
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("serverA_Points", new com.bencodez.simpleapi.sql.data.DataValueString("17"))));
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ assertEquals("17", f.request("points_nocache"));
+ assertEquals("17", f.request("points_format_nocache"));
+ assertEquals("true", f.request("enoughpoints_16_nocache"));
+ }
+
+ @Test void reorderedRefillsCannotReopenBeforeEveryReadRetires() {
+ for (boolean reverse : List.of(false, true)) {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ var first = f.placeholders.sharedPointRefillCompletion(f.uuid);
+ var second = f.placeholders.sharedPointRefillCompletion(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true, true);
+ (reverse ? second : first).accept(true);
+ assertEquals("...", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty(), "an active storage read must not admit recovery");
+ (reverse ? first : second).accept(true);
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size(), "an obsolete completion requires a fresh coalesced read");
+ }
+ }
+
+ @Test void rejectedRecoveryAdmissionCanBeRetriedWithoutRetryingDebit() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true, false);
+ doThrow(new java.util.concurrent.RejectedExecutionException("stopped"))
+ .doAnswer(call -> { f.pending.add(call.getArgument(0)); return null; })
+ .when(f.timer).execute(any(Runnable.class));
+ assertEquals("...", f.request("points"));
+ assertTrue(f.pending.isEmpty());
+ assertEquals("...", f.request("points"));
+ assertEquals(1, f.pending.size());
+ verify(f.user, never()).removePoints(org.mockito.ArgumentMatchers.anyInt(), any(java.util.function.Consumer.class));
+ }
+
+ @Test void recoveryQueuedBeforeReloadCannotReadStorageInNewLifecycle() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true, false);
+ assertEquals("...", f.request("points"));
+ try (var bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of());
+ f.placeholders.reload();
+ }
+ f.pending.removeFirst().run();
+ verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any());
+ assertEquals("...", f.request("points"));
+ assertEquals(1, f.pending.size());
+ }
+
+ @Test void malformedNumericStringRemainsUnavailableWithoutOwnerStorageRead() {
+ for (String text : List.of("invalid", "", "2147483648")) {
+ Fixture f = new Fixture(PlaceholderCacheLevel.NONE);
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueString(text))));
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ assertEquals("...", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty(), "published invalid data must not schedule storage recovery");
+ verify(f.user, never()).getPoints();
+ verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any());
+ }
+ }
+
+ @Test void publicReplayAdditionClosesRenderingBeforeWorkerAdmission() throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.user.getPointsPath()).thenReturn("Points");
+ org.mockito.Mockito.doReturn(f.player).when(f.user).getPlayer();
+ var field = VotingPluginUser.class.getDeclaredField("plugin");
+ field.setAccessible(true);
+ field.set(f.user, f.plugin);
+ org.mockito.Mockito.doCallRealMethod().when(f.user).addPointsStorageAwareAsync(org.mockito.ArgumentMatchers.anyInt(), anyString());
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ var completion = f.user.addPointsStorageAwareAsync(16, "synthetic-public-entry-" + UUID.randomUUID());
+ try { assertEquals("...", f.request("points")); }
+ finally { completion.toCompletableFuture().cancel(false); }
+ }
+
+ @Test void publicSharedAndPerServerReplayFailuresRemainUnavailableAfterRefill() throws Exception {
+ for (boolean perServer : new boolean[]{false, true}) {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(perServer);
+ when(f.plugin.getBungeeSettings().getServerNameStorage()).thenReturn("Synthetic");
+ String column = perServer ? "Synthetic_Points" : "Points";
+ when(f.user.getPointsPath()).thenReturn(column);
+ org.mockito.Mockito.doReturn(f.player).when(f.user).getPlayer();
+ var field = VotingPluginUser.class.getDeclaredField("plugin");
+ field.setAccessible(true);
+ field.set(f.user, f.plugin);
+ org.mockito.Mockito.doCallRealMethod().when(f.user).addPointsStorageAwareAsync(org.mockito.ArgumentMatchers.anyInt(), anyString());
+ when(f.plugin.getMysql().getTableName()).thenReturn("SyntheticPoints");
+ when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection())
+ .thenThrow(new IllegalStateException("synthetic unavailable acknowledgement"));
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of(column, new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ var completion = f.user.addPointsStorageAwareAsync(16, "synthetic-replay-failure-" + UUID.randomUUID()).toCompletableFuture();
+ assertEquals("...", f.request("points"));
+ f.pending.removeFirst().run();
+ assertTrue(completion.isCompletedExceptionally());
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of(column, new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true);
+ assertEquals("...", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty(), "uncertain completion must not schedule an automatic retry/refill");
+ verify(f.user, never()).getPoints();
+ }
+ }
+
+ @Test void completedTransferRefreshesSourceAndTargetRender() throws Exception { assertTransferRender("COMPLETED"); }
+ @Test void refundedTransferRefreshesSourceAndTargetRender() throws Exception { assertTransferRender("REFUNDED"); }
+ @Test void indeterminateTransferDoesNotPublishRefillAsConfirmation() throws Exception { assertTransferRender("INDETERMINATE"); }
+
+ private void assertTransferRender(String outcome) throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.user.getPointsPath()).thenReturn("Points");
+ UUID targetUuid = UUID.fromString("00000000-0000-0000-0000-000000000002");
+ var target = mock(VotingPluginUser.class);
+ when(target.getUUID()).thenReturn(targetUuid.toString());
+ when(target.getJavaUUID()).thenReturn(targetUuid);
+ when(target.getPointsPath()).thenReturn("Points");
+ var offlineTarget = mock(OfflinePlayer.class);
+ when(f.plugin.getVotingPluginUserManager().getVotingPluginUser(offlineTarget)).thenReturn(target);
+ var caches = new java.util.concurrent.ConcurrentHashMap();
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(caches);
+ for (UUID uuid : List.of(f.uuid, targetUuid)) {
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(uuid.equals(f.uuid) ? 20 : 0))));
+ caches.put(uuid, cache);
+ }
+ doAnswer(call -> {
+ UUID uuid = call.getArgument(0);
+ int balance = uuid.equals(f.uuid) ? (outcome.equals("REFUNDED") ? 20 : 4) : (outcome.equals("REFUNDED") ? 0 : 16);
+ caches.get(uuid).updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(balance))));
+ return null;
+ }).when(manager).cacheUser(any(UUID.class), org.mockito.ArgumentMatchers.isNull());
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ f.placeholder("points").getCache().get("points").put(targetUuid, "0");
+ var journal = com.bencodez.votingplugin.tests.user.SharedPointTransferJournalAccess.mock();
+ journal.stub(org.mockito.Mockito.doReturn(com.bencodez.votingplugin.tests.support.ReflectiveAccess.field(
+ "com.bencodez.votingplugin.user.SharedPointTransferJournal$SettlementOutcome", outcome)))
+ .settleWithConfirmation("synthetic-transfer", "owner", f.uuid.toString(), "Points", targetUuid.toString(), "Points", 16, 16);
+ doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
+ .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class));
+ var completions = new java.util.ArrayList();
+ java.util.function.Consumer completion = completions::add;
+ com.bencodez.votingplugin.tests.support.ReflectiveAccess.call("com.bencodez.votingplugin.user.SharedMysqlPointMutator",
+ new com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess(f.plugin), "settleTransfer",
+ new String[]{"VotingPluginUser", "VotingPluginUser", "Player", "int", "Consumer",
+ "SharedPointTransferJournal", "String", "String", "String", "String", "Integer"},
+ new Object[]{f.user, target, null, 16, completion, journal, "synthetic-transfer", "owner", "Points", "Points", 16});
+ assertEquals(1, completions.size());
+ journal.verify(org.mockito.Mockito.times(1)).settleWithConfirmation("synthetic-transfer", "owner", f.uuid.toString(), "Points", targetUuid.toString(), "Points", 16, 16);
+ if (outcome.equals("INDETERMINATE")) {
+ assertEquals(com.bencodez.votingplugin.user.PointTransferResult.PENDING_CONFIRMATION, completions.getFirst());
+ assertEquals("...", f.request("points_nocache"));
+ } else {
+ assertEquals(outcome.equals("COMPLETED") ? com.bencodez.votingplugin.user.PointTransferResult.SUCCESS
+ : com.bencodez.votingplugin.user.PointTransferResult.CANCELLED, completions.getFirst());
+ assertEquals("...", f.request("points"));
+ assertEquals(outcome.equals("REFUNDED") ? "20" : "4", f.request("points_nocache"));
+ assertEquals(outcome.equals("REFUNDED") ? "0" : "16", f.placeholders.getPlaceholderValue(offlineTarget, "points", false, true, false));
+ }
+ }
+
+ @Test void confirmedRefundRetiresStaleRenderedBalance() throws Exception { assertRefundRender(false); }
+ @Test void ambiguousRefundCannotPublishAsConfirmedBalance() throws Exception { assertRefundRender(true); }
+
+ private void assertRefundRender(boolean ambiguous) throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.user.getPointsPath()).thenReturn("Points");
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ var journal = com.bencodez.votingplugin.tests.user.SharedPointTransferJournalAccess.mock();
+ if (ambiguous) when(journal.refundHookStarted("synthetic-refund", f.uuid.toString(), "Points", 16))
+ .thenThrow(new java.sql.SQLException("synthetic acknowledgement lost"));
+ else when(journal.refundHookStarted("synthetic-refund", f.uuid.toString(), "Points", 16)).thenReturn(true);
+ doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
+ .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class));
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ var completions = new java.util.ArrayList();
+ new com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess(f.plugin).refundClaimedAfterSchedulingFailure(
+ f.user, null, completions::add, journal, "synthetic-refund", "Points", 16, new java.util.concurrent.RejectedExecutionException("synthetic retired owner"));
+ assertEquals(List.of(com.bencodez.votingplugin.user.PointTransferResult.UNAVAILABLE), completions);
+ journal.verify(org.mockito.Mockito.times(1)).refundHookStarted("synthetic-refund", f.uuid.toString(), "Points", 16);
+ if (ambiguous) assertEquals("...", f.request("points_nocache"));
+ else {
+ assertEquals("...", f.request("points"));
+ assertEquals("4", f.request("points_nocache"));
+ }
+ }
+
+ @Test void publicSetAdmissionRetiresRenderedValueBeforeWorkerExecution() throws Exception {
+ Fixture f = mysqlPublicFixture();
+ org.mockito.Mockito.doCallRealMethod().when(f.user).setPointsStorageAware(eq(4), any());
+ f.user.setPointsStorageAware(4, ignored -> { });
+ assertEquals(1, f.pending.size());
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size(), "pending mutation must not admit a competing recovery");
+ }
+
+ @Test void publicPurchaseAdmissionRetiresRenderedValueBeforeWorkerExecution() throws Exception {
+ Fixture f = mysqlPublicFixture();
+ startPublicPurchase(f, ignored -> { });
+ assertEquals(1, f.pending.size());
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size(), "pending reservation must not admit a competing recovery");
+ }
+
+ @Test void publicSetNullConnectionIsRecoverableWithoutExecutingUpdate() throws Exception { assertPublicSetPhase("acquire"); }
+ @Test void publicSetPreparationFailureIsRecoverableWithoutExecutingUpdate() throws Exception { assertPublicSetPhase("prepare"); }
+ @Test void publicSetBindFailureIsRecoverableWithoutExecutingUpdate() throws Exception { assertPublicSetPhase("bind"); }
+ @Test void publicSetExecutionFailureStaysUncertain() throws Exception { assertPublicSetPhase("execute"); }
+ @Test void publicSetConfirmedUpdatePublishesFreshSnapshot() throws Exception { assertPublicSetPhase("confirmed"); }
+
+ private void assertPublicSetPhase(String phase) throws Exception {
+ Fixture f = mysqlPublicFixture();
+ var connection = mock(java.sql.Connection.class);
+ var statement = mock(java.sql.PreparedStatement.class);
+ when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(phase.equals("acquire") ? null : connection);
+ if (phase.equals("prepare")) when(connection.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("synthetic prepare failure"));
+ else when(connection.prepareStatement(anyString())).thenReturn(statement);
+ if (phase.equals("bind")) doThrow(new java.sql.SQLException("synthetic bind failure")).when(statement).setInt(1, 4);
+ if (phase.equals("execute")) when(statement.executeUpdate()).thenThrow(new java.sql.SQLException("synthetic acknowledgement failure"));
+ else when(statement.executeUpdate()).thenReturn(1);
+ org.mockito.Mockito.doCallRealMethod().when(f.user).setPointsStorageAware(eq(4), any());
+ var results = new java.util.ArrayList();
+ f.user.setPointsStorageAware(4, results::add);
+ assertEquals(1, f.pending.size());
+ f.pending.removeFirst().run();
+ assertEquals(List.of(phase.equals("confirmed")), results);
+ boolean attempted = phase.equals("execute") || phase.equals("confirmed");
+ verify(statement, times(attempted ? 1 : 0)).executeUpdate();
+ assertEquals("...", f.request("points"));
+ if (!phase.equals("execute")) {
+ f.drain();
+ assertEquals("4", f.request("points_nocache"), "known unstarted writes must recover through ordinary requests");
+ } else {
+ assertTrue(f.pending.isEmpty(), "ambiguous writes must not be retried/reopened");
+ assertEquals("...", f.request("points_nocache"));
+ }
+ verify(f.user, never()).getPoints();
+ }
+
+ private Fixture mysqlPublicFixture() throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(f.user, f.plugin);
+ org.mockito.Mockito.doReturn(f.player).when(f.user).getPlayer();
+ when(f.scheduler.getFoliaLib()).thenReturn(null);
+ doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
+ .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class), eq(f.player));
+ doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
+ .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class));
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ var caches = new java.util.concurrent.ConcurrentHashMap();
+ caches.put(f.uuid, cache);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(caches);
+ doAnswer(call -> {
+ var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ caches.put(f.uuid, fresh); return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ when(f.plugin.getMysql().getTableName()).thenReturn("SyntheticPoints");
+ when(f.plugin.getMysql().qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`");
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ return f;
+ }
+
+ @Test void publicTransferRejectedReservationRepairsRecreatedSourceBeforeCallback() throws Exception { assertPublicTransferReserve("rejected"); }
+ @Test void publicTransferAmbiguousReservationCannotPublishRefillAsConfirmation() throws Exception { assertPublicTransferReserve("ambiguous"); }
+
+ @Test void publicTransferPreparationFailureRecoversWithoutDebit() throws Exception { assertPublicTransferReserve("prepare"); }
+ @Test void publicTransferAcquisitionFailureRecoversWithoutDebit() throws Exception { assertPublicTransferReserve("acquire"); }
+
+ private void assertPublicTransferReserve(String phase) throws Exception {
+ boolean ambiguous = phase.equals("ambiguous");
+ Fixture f = mysqlPublicFixture();
+ var target = mock(VotingPluginUser.class);
+ when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
+ when(target.getPointsPath()).thenReturn("Points");
+ org.mockito.Mockito.doCallRealMethod().when(f.user).transferPointsWithResult(eq(target), eq(16), any());
+ var connection = mock(java.sql.Connection.class);
+ when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(phase.equals("acquire") ? null : connection);
+ var updates = new java.util.concurrent.atomic.AtomicInteger();
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String sql = call.getArgument(0);
+ if (phase.equals("prepare") && sql.startsWith("INSERT INTO")) throw new java.sql.SQLException("synthetic reservation preparation failure");
+ var statement = mock(java.sql.PreparedStatement.class);
+ when(statement.executeQuery()).thenReturn(mock(java.sql.ResultSet.class));
+ if (sql.startsWith("UPDATE `SyntheticPoints` SET")) when(statement.executeUpdate()).thenAnswer(update -> {
+ updates.incrementAndGet();
+ var recreated = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ recreated.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ f.plugin.getUserManager().getDataManager().getUserDataCache().put(f.uuid, recreated);
+ if (ambiguous) throw new java.sql.SQLException("synthetic unconfirmed reservation");
+ return 0;
+ });
+ return statement;
+ });
+ var results = new java.util.ArrayList();
+ var callbackBalances = new java.util.ArrayList();
+ f.user.transferPointsWithResult(target, 16, result -> {
+ results.add(result); callbackBalances.add(f.request("points_nocache"));
+ });
+ f.pending.removeFirst().run();
+ assertEquals(List.of(phase.equals("rejected") ? com.bencodez.votingplugin.user.PointTransferResult.INSUFFICIENT_POINTS
+ : com.bencodez.votingplugin.user.PointTransferResult.UNAVAILABLE), results);
+ assertEquals(phase.equals("prepare") || phase.equals("acquire") ? 0 : 1, updates.get());
+ assertEquals(List.of(ambiguous ? "..." : "4"), callbackBalances);
+ verify(target, never()).addPoints(org.mockito.ArgumentMatchers.anyInt());
+ verify(f.plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any());
+ if (ambiguous) {
+ f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true);
+ assertEquals("...", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty(), "ambiguous reservation is never automatically retried");
+ }
+ }
+
+ @Test void publicTransferApprovalRejectionConfirmedRefundRepairsBeforeCallback() throws Exception { assertPublicTransferApprovalRejection("confirmed"); }
+ @Test void publicTransferApprovalRejectionAmbiguousRefundRemainsUnavailable() throws Exception { assertPublicTransferApprovalRejection("ambiguous"); }
+ @Test void publicTransferApprovalRejectionUnstartedRefundRemainsRecoverable() throws Exception { assertPublicTransferApprovalRejection("prepare"); }
+
+ private void assertPublicTransferApprovalRejection(String phase) throws Exception {
+ Fixture f = mysqlPublicFixture();
+ var target = mock(VotingPluginUser.class);
+ when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
+ when(target.getPointsPath()).thenReturn("Points");
+ org.mockito.Mockito.doCallRealMethod().when(f.user).transferPointsWithResult(eq(target), eq(16), any());
+ var storage = new java.util.concurrent.atomic.AtomicInteger(20);
+ var debits = new java.util.concurrent.atomic.AtomicInteger();
+ var refunds = new java.util.concurrent.atomic.AtomicInteger();
+ var manager = f.plugin.getUserManager().getDataManager();
+ doAnswer(call -> {
+ var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(storage.get()))));
+ manager.getUserDataCache().put(f.uuid, fresh); return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ var connection = mock(java.sql.Connection.class);
+ when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String sql = call.getArgument(0);
+ boolean points = sql.startsWith("UPDATE `SyntheticPoints` SET");
+ boolean refund = points && sql.contains(" + ?");
+ if (refund && phase.equals("prepare")) throw new java.sql.SQLException("synthetic refund prepare failure");
+ var statement = mock(java.sql.PreparedStatement.class);
+ when(statement.executeQuery()).thenAnswer(query -> {
+ var result = mock(java.sql.ResultSet.class);
+ if (sql.contains("FOR UPDATE")) {
+ when(result.next()).thenReturn(true, false); when(result.getString(1)).thenReturn("RESERVED");
+ }
+ return result;
+ });
+ when(statement.executeUpdate()).thenAnswer(update -> {
+ if (points) {
+ if (refund) { refunds.incrementAndGet(); storage.set(20); }
+ else { debits.incrementAndGet(); storage.set(4); }
+ if (refund && phase.equals("ambiguous")) throw new java.sql.SQLException("synthetic unconfirmed refund");
+ }
+ return 1;
+ });
+ return statement;
+ });
+ doThrow(new java.util.concurrent.RejectedExecutionException("synthetic approval rejected"))
+ .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class));
+ var results = new java.util.ArrayList();
+ var balances = new java.util.ArrayList();
+ f.user.transferPointsWithResult(target, 16, result -> { results.add(result); balances.add(f.request("points_nocache")); });
+ f.pending.removeFirst().run();
+ assertEquals(List.of(com.bencodez.votingplugin.user.PointTransferResult.UNAVAILABLE), results);
+ assertEquals(1, debits.get()); assertEquals(phase.equals("prepare") ? 0 : 1, refunds.get());
+ assertEquals(List.of(phase.equals("ambiguous") ? "..." : phase.equals("prepare") ? "4" : "20"), balances);
+ verify(target, never()).addPoints(org.mockito.ArgumentMatchers.anyInt());
+ assertTrue(f.pending.isEmpty(), "no retry may be scheduled for a refund outcome");
+ }
+
+ @Test void publicClaimedTransferRefundAcquisitionFailureRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("acquire"); }
+ @Test void publicClaimedTransferRefundSelectPreparationRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("select"); }
+ @Test void publicClaimedTransferRefundReadFailureRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("read"); }
+ @Test void publicClaimedTransferRefundStatementPreparationRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("prepare"); }
+ @Test void publicClaimedTransferRefundBindFailureRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("bind"); }
+ @Test void publicClaimedTransferRefundSelectCloseFailureRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("select-close"); }
+ @Test void publicClaimedTransferRefundConfirmedRestoresBalanceOnce() throws Exception { assertPublicClaimedTransferRefund("confirmed"); }
+ @Test void publicClaimedTransferRefundExecutionFailureStaysUnavailable() throws Exception { assertPublicClaimedTransferRefund("execute"); }
+ @Test void publicClaimedTransferRefundCommitFailureStaysUnavailable() throws Exception { assertPublicClaimedTransferRefund("commit"); }
+ @Test void publicClaimedTransferRefundPostWriteCloseFailureStaysUnavailable() throws Exception { assertPublicClaimedTransferRefund("close"); }
+
+ @Test void publicMarkerFailureFallbackExecutionRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("execute", "marker-failure"); }
+ @Test void publicMarkerFailureFallbackCommitRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("commit", "marker-failure"); }
+ @Test void publicMarkerFailureFallbackConfirmedRefundRemainsUsable() throws Exception { assertPublicClaimedTransferRefund("confirmed", "marker-failure"); }
+ @Test void publicMarkerFailureFallbackUnstartedRefundRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("prepare", "marker-failure"); }
+ @Test void publicMarkerFailureFallbackRefusedRefundDoesNotMutate() throws Exception { assertPublicClaimedTransferRefund("refused", "marker-failure"); }
+ @Test void publicIndeterminateClaimExecutionRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("execute", "indeterminate"); }
+ @Test void publicIndeterminateClaimCommitRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("commit", "indeterminate"); }
+ @Test void publicIndeterminateClaimConfirmedRefundRemainsUsable() throws Exception { assertPublicClaimedTransferRefund("confirmed", "indeterminate"); }
+ @Test void publicIndeterminateClaimUnstartedRefundRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("prepare", "indeterminate"); }
+ @Test void publicIndeterminateClaimRefusedRefundDoesNotMutate() throws Exception { assertPublicClaimedTransferRefund("refused", "indeterminate"); }
+ @Test void publicIndeterminateClaimMarkerFallbackExecutionRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("execute", "indeterminate-marker-failure"); }
+ @Test void publicIndeterminateClaimMarkerFallbackCommitRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("commit", "indeterminate-marker-failure"); }
+ @Test void publicIndeterminateClaimMarkerFallbackConfirmedRefundRemainsUsable() throws Exception { assertPublicClaimedTransferRefund("confirmed", "indeterminate-marker-failure"); }
+ @Test void publicIndeterminateClaimMarkerFallbackUnstartedRefundRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("prepare", "indeterminate-marker-failure"); }
+ @Test void publicIndeterminateClaimMarkerFallbackRefusedRefundDoesNotMutate() throws Exception { assertPublicClaimedTransferRefund("refused", "indeterminate-marker-failure"); }
+
+ private void assertPublicClaimedTransferRefund(String phase) throws Exception {
+ assertPublicClaimedTransferRefund(phase, "normal");
+ }
+
+ private void assertPublicClaimedTransferRefund(String phase, String branch) throws Exception {
+ Fixture f = mysqlPublicFixture();
+ boolean indeterminateClaim = branch.startsWith("indeterminate");
+ boolean failedMarker = branch.contains("marker-failure");
+ when(f.plugin.getDataFolder()).thenReturn(transferFallbackData.toFile());
+ var target = mock(VotingPluginUser.class);
+ var targetPlayer = mock(Player.class);
+ when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
+ when(target.getPointsPath()).thenReturn("Points"); when(target.getPlayer()).thenReturn(targetPlayer);
+ org.mockito.Mockito.doCallRealMethod().when(f.user).transferPointsWithResult(eq(target), eq(16), any());
+ var state = new java.util.concurrent.atomic.AtomicReference("RESERVED");
+ var storage = new java.util.concurrent.atomic.AtomicInteger(20);
+ var marked = new java.util.concurrent.atomic.AtomicBoolean();
+ var debits = new java.util.concurrent.atomic.AtomicInteger();
+ var refunds = new java.util.concurrent.atomic.AtomicInteger();
+ var manager = f.plugin.getUserManager().getDataManager();
+ doAnswer(call -> {
+ var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(storage.get()))));
+ manager.getUserDataCache().put(f.uuid, fresh); return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ var connection = mock(java.sql.Connection.class);
+ when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenAnswer(call ->
+ marked.get() && phase.equals("acquire") ? null : connection);
+ doAnswer(call -> {
+ if (refunds.get() > 0 && phase.equals("commit")) throw new java.sql.SQLException("synthetic unconfirmed refund commit");
+ if (state.get().equals("COMPENSATING")) marked.set(true);
+ return null;
+ }).when(connection).commit();
+ doAnswer(call -> {
+ if (refunds.get() > 0 && phase.equals("close")) throw new java.sql.SQLException("synthetic post-write connection close");
+ return null;
+ }).when(connection).close();
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String sql = call.getArgument(0);
+ boolean refundRead = marked.get() && sql.contains("FOR UPDATE");
+ boolean points = sql.startsWith("UPDATE `SyntheticPoints` SET");
+ boolean refund = points && sql.contains(" + ?");
+ if (refundRead && phase.equals("select")) throw new java.sql.SQLException("synthetic pre-write select preparation");
+ if (refund && phase.equals("prepare")) throw new java.sql.SQLException("synthetic pre-write refund preparation");
+ var statement = mock(java.sql.PreparedStatement.class);
+ var bound = new java.util.HashMap();
+ doAnswer(bind -> { bound.put(bind.getArgument(0), bind.getArgument(1)); return null; })
+ .when(statement).setString(org.mockito.ArgumentMatchers.anyInt(), any());
+ if (refund && phase.equals("bind")) doThrow(new java.sql.SQLException("synthetic pre-write bind"))
+ .when(statement).setInt(1, 16);
+ if (refundRead && phase.equals("select-close")) doThrow(new java.sql.SQLException("synthetic pre-write select close"))
+ .when(statement).close();
+ when(statement.executeQuery()).thenAnswer(query -> {
+ if (refundRead && phase.equals("read")) throw new java.sql.SQLException("synthetic pre-write read");
+ var result = mock(java.sql.ResultSet.class);
+ if (sql.contains("FOR UPDATE")) {
+ if (indeterminateClaim && !marked.get() && sql.contains("hook_owner")) state.set("HOOK_STARTED");
+ when(result.next()).thenReturn(true, false);
+ when(result.getString(1)).thenReturn(marked.get() && phase.equals("refused") ? "COMPLETED" : state.get());
+ if (indeterminateClaim && !marked.get()) when(result.getString(2)).thenReturn("synthetic-other-claim");
+ }
+ // Other journal lookups/recovery remain empty, including confirmation after a lost commit.
+ return result;
+ });
+ when(statement.executeUpdate()).thenAnswer(update -> {
+ if (points) {
+ if (refund) {
+ refunds.incrementAndGet(); storage.set(20);
+ if (phase.equals("execute")) {
+ // Durable terminal evidence becomes visible only to the later guarded fallback,
+ // while this invocation still loses its execution acknowledgement.
+ state.set("REFUNDED");
+ throw new java.sql.SQLException("synthetic unconfirmed refund execution");
+ }
+ } else { debits.incrementAndGet(); storage.set(4); }
+ } else if (sql.startsWith("UPDATE") && bound.containsKey(1)) {
+ if (bound.get(1).equals("COMPENSATING")) {
+ marked.set(true);
+ if (failedMarker) throw new java.sql.SQLException("synthetic compensation marker acknowledgement lost");
+ }
+ state.set(bound.get(1));
+ }
+ return 1;
+ });
+ return statement;
+ });
+ // First global task admits the claim. After the real claim succeeds, both approval lanes reject.
+ var globals = new java.util.concurrent.atomic.AtomicInteger();
+ doAnswer(call -> {
+ if (globals.incrementAndGet() == 2) throw new java.util.concurrent.RejectedExecutionException("synthetic approval fallback rejected");
+ call.getArgument(1, Runnable.class).run(); return null;
+ }).when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class));
+ doThrow(new java.util.concurrent.RejectedExecutionException("synthetic target entity retired"))
+ .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class), eq(targetPlayer));
+ var results = new java.util.ArrayList();
+ f.user.transferPointsWithResult(target, 16, results::add);
+ f.pending.removeFirst().run(); // reserve -> first global admission
+ assertEquals(1, f.pending.size());
+ f.pending.removeFirst().run(); // real claim -> rejected entity/fallback -> compensation admission
+ if (!indeterminateClaim) {
+ assertEquals("HOOK_STARTED", state.get());
+ assertEquals(1, f.pending.size());
+ f.pending.removeFirst().run(); // real markCompensating -> refundHookStarted -> completion
+ }
+ assertTrue(marked.get(), "must reach the post-claim durable compensation path");
+ assertEquals(List.of(com.bencodez.votingplugin.user.PointTransferResult.UNAVAILABLE), results);
+ assertEquals(1, debits.get());
+ boolean attempted = List.of("confirmed", "execute", "commit", "close").contains(phase);
+ assertEquals(attempted ? 1 : 0, refunds.get());
+ assertTrue(f.pending.isEmpty(), "no automatic transaction retry may be scheduled");
+ if (attempted && !phase.equals("confirmed")) {
+ com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(f.plugin, f.uuid.toString(), "Points");
+ assertEquals("...", f.request("points_nocache"));
+ f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true);
+ assertEquals("...", f.request("points_nocache"));
+ assertTrue(f.pending.isEmpty());
+ } else {
+ f.request("points_nocache"); f.drain();
+ assertEquals(phase.equals("confirmed") ? "20" : "4", f.request("points_nocache"));
+ }
+
+ if (phase.equals("refused") || failedMarker && !phase.equals("confirmed")) {
+ assertTrue(java.nio.file.Files.isDirectory(transferFallbackData.resolve(".point-transfer-compensations")),
+ "the original local compensation proof must survive an unresolved fallback");
+ }
+ verify(target, never()).addPoints(org.mockito.ArgumentMatchers.anyInt());
+ verify(f.plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any());
+ verify(f.plugin.getRewardHandler(), never()).giveRewardAsync(any(), any(), any(), any());
+ }
+
+ @Test void publicPurchaseNullConnectionRemainsRecoverableWithoutDebit() throws Exception { assertPublicPurchasePreparation("acquire"); }
+ @Test void publicPurchaseStatementPreparationRemainsRecoverableWithoutDebit() throws Exception { assertPublicPurchasePreparation("prepare"); }
+
+ @Test void publicPurchaseReservePreparationHasNoDebitAndRemainsRecoverable() throws Exception { assertPublicPurchasePreparation("reserve-prepare"); }
+ @Test void publicPurchaseReserveBindHasNoDebitAndRemainsRecoverable() throws Exception { assertPublicPurchasePreparation("reserve-bind"); }
+
+ private void assertPublicPurchasePreparation(String phase) throws Exception {
+ Fixture f = mysqlPublicFixture();
+ var connection = mock(java.sql.Connection.class);
+ when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(phase.equals("acquire") ? null : connection);
+ var pointUpdates = new java.util.concurrent.atomic.AtomicInteger();
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String sql = call.getArgument(0);
+ if (phase.equals("prepare") || (phase.equals("reserve-prepare") && sql.startsWith("INSERT INTO")))
+ throw new java.sql.SQLException("synthetic preparation failed");
+ var statement = mock(java.sql.PreparedStatement.class);
+ if (phase.equals("reserve-bind") && sql.startsWith("UPDATE `SyntheticPoints` SET"))
+ doThrow(new java.sql.SQLException("synthetic bind failure")).when(statement).setInt(1, 16);
+ when(statement.executeUpdate()).thenAnswer(update -> { if (sql.startsWith("UPDATE `SyntheticPoints` SET")) pointUpdates.incrementAndGet(); return 1; });
+ return statement;
+ });
+ var results = new java.util.ArrayList();
+ startPublicPurchase(f, results::add);
+ f.pending.removeFirst().run();
+ assertEquals(List.of(com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult.FAILED), results);
+ assertEquals("...", f.request("points"));
+ f.drain();
+ assertEquals("4", f.request("points_nocache"));
+ assertEquals(0, pointUpdates.get(), "no points UPDATE may be attempted in preparation failures");
+ verify(f.plugin.getRewardHandler(), never()).giveRewardAsync(any(), any(), any(), any());
+ }
+
+ @Test void publicPurchaseCompensationConfirmedRefundRestoresBalance() throws Exception { assertPublicCompensation("confirmed"); }
+ @Test void publicPurchaseCompensationGuardDoesNotRefundOrReward() throws Exception { assertPublicCompensation("guard"); }
+ @Test void publicPurchaseCompensationUnconfirmedRefundStaysUnavailable() throws Exception { assertPublicCompensation("ambiguous"); }
+ @Test void publicPurchaseCompensationPreparationFailureDoesNotInventUncertainty() throws Exception { assertPublicCompensation("prepare"); }
+
+ @Test void publicLimitedPurchaseEpochReadFailureIsRecoverableBeforeRefundUpdate() throws Exception { assertPublicCompensation("epoch"); }
+ @Test void publicLimitedPurchaseConfirmedRefundKeepsEpochGuard() throws Exception { assertPublicCompensation("epoch-confirmed"); }
+ @Test void publicLimitedPurchaseUnconfirmedRefundStaysUnavailable() throws Exception { assertPublicCompensation("epoch-ambiguous"); }
+
+ private void assertPublicCompensation(String phase) throws Exception {
+ Fixture f = mysqlPublicFixture();
+ boolean limited = phase.startsWith("epoch");
+ boolean ambiguous = phase.equals("ambiguous") || phase.equals("epoch-ambiguous");
+ var storage = new java.util.concurrent.atomic.AtomicInteger(20);
+ var state = new java.util.concurrent.atomic.AtomicReference("PENDING");
+ var refunds = new java.util.concurrent.atomic.AtomicInteger();
+ var debits = new java.util.concurrent.atomic.AtomicInteger();
+ var connection = mock(java.sql.Connection.class);
+ when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(connection);
+ doAnswer(call -> {
+ if (ambiguous && refunds.get() > 0) throw new java.sql.SQLException("synthetic refund acknowledgement lost");
+ return null;
+ }).when(connection).commit();
+ var manager = f.plugin.getUserManager().getDataManager();
+ doAnswer(call -> {
+ var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(storage.get()))));
+ manager.getUserDataCache().put(f.uuid, fresh); return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String sql = call.getArgument(0);
+ if (phase.equals("epoch") && state.get().equals("COMPENSATING") && sql.startsWith("SELECT `epoch`"))
+ throw new java.sql.SQLException("synthetic epoch prerequisite failure");
+ boolean pointUpdate = sql.startsWith("UPDATE `SyntheticPoints` SET");
+ boolean refund = pointUpdate && sql.contains(" + ?");
+ if (refund && phase.equals("prepare")) throw new java.sql.SQLException("synthetic refund preparation failure");
+ var statement = mock(java.sql.PreparedStatement.class);
+ var bound = new java.util.HashMap();
+ doAnswer(bind -> { bound.put(bind.getArgument(0), bind.getArgument(1)); return null; }).when(statement).setString(org.mockito.ArgumentMatchers.anyInt(), any());
+ when(statement.executeUpdate()).thenAnswer(update -> {
+ if (pointUpdate) {
+ if (refund) { refunds.incrementAndGet(); storage.set(20); }
+ else { debits.incrementAndGet(); storage.set(4); }
+ } else if (sql.startsWith("UPDATE") && bound.containsKey(1)) state.set(bound.get(1));
+ return 1;
+ });
+ when(statement.executeQuery()).thenAnswer(query -> {
+ var result = mock(java.sql.ResultSet.class);
+ if (sql.contains("FOR UPDATE") || (ambiguous && sql.startsWith("SELECT"))) {
+ when(result.next()).thenReturn(true, false);
+ when(result.getString(1)).thenReturn(phase.equals("guard") ? "HOOK_STARTED"
+ : ambiguous && refunds.get() > 0 ? "COMPENSATING" : state.get());
+ when(result.getString(2)).thenReturn(f.uuid.toString());
+ when(result.getString(3)).thenReturn("Points");
+ when(result.getInt(5)).thenReturn(16);
+ if (limited) {
+ when(result.getString(4)).thenReturn("VoteShopLimitsynthetic-public-item");
+ when(result.getObject(8)).thenReturn(7L);
+ when(result.getLong(1)).thenReturn(7L);
+ }
+ when(result.wasNull()).thenReturn(!limited);
+ }
+ return result;
+ });
+ return statement;
+ });
+ var asyncAdmissions = new java.util.concurrent.atomic.AtomicInteger();
+ doAnswer(call -> {
+ if (asyncAdmissions.incrementAndGet() == 2) throw new java.util.concurrent.RejectedExecutionException("synthetic claim scheduler stopped");
+ call.getArgument(1, Runnable.class).run(); return null;
+ }).when(f.scheduler).runTaskAsynchronously(eq(f.plugin), any(Runnable.class));
+ var results = new java.util.ArrayList();
+ startPublicPurchase(f, results::add, limited ? 3 : 0);
+ f.pending.removeFirst().run(); // reserve, then rejected claim schedules compensation
+ assertEquals(1, f.pending.size());
+ f.pending.removeFirst().run();
+ assertEquals(List.of(com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult.FAILED), results);
+ assertEquals(1, debits.get());
+ assertEquals(phase.equals("prepare") || phase.equals("guard") || phase.equals("epoch") ? 0 : 1, refunds.get());
+ assertTrue(f.pending.isEmpty(), "compensation must not retry its mutation automatically");
+ if (ambiguous) {
+ assertEquals("...", f.request("points_nocache"), "an unconfirmed refund cannot reopen numeric publication");
+ f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true);
+ assertEquals("...", f.request("points_nocache"));
+ } else {
+ f.request("points_nocache"); f.drain();
+ assertEquals(phase.equals("confirmed") || phase.equals("epoch-confirmed") ? "20" : "4", f.request("points_nocache"));
+ }
+ verify(f.plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any());
+ verify(f.plugin.getRewardHandler(), never()).giveRewardAsync(any(), any(), any(), any());
+ }
+
+ private void startPublicPurchase(Fixture f, java.util.function.Consumer completion) {
+ startPublicPurchase(f, completion, 0);
+ }
+
+ private void startPublicPurchase(Fixture f, java.util.function.Consumer completion, int limit) {
+ var definition = mock(com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition.class);
+ when(definition.isEnabled()).thenReturn(true);
+ var item = mock(com.bencodez.votingplugin.voteshop.shop.VoteShopItem.class);
+ when(item.getPermission()).thenReturn(""); when(item.getCost()).thenReturn(16); when(item.getLimit()).thenReturn(limit);
+ when(item.getIdentifier()).thenReturn("synthetic-public-item");
+ new com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService(f.plugin, definition).purchase(f.player, f.user, item, completion);
+ }
+
+ @Test void confirmedSetRetiresRenderedBalanceAndPublishesRefill() throws Exception { assertSetOutcome(1, false); }
+ @Test void zeroRowSetRetiresRenderedBalanceAndPublishesRefill() throws Exception { assertSetOutcome(0, false); }
+ @Test void ambiguousSetDoesNotPublishAsAConfirmedBalance() throws Exception { assertSetOutcome(0, true); }
+
+ private void assertSetOutcome(int affectedRows, boolean ambiguous) throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.user.getPointsPath()).thenReturn("Points");
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ var table = f.plugin.getMysql();
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`");
+ var connection = mock(java.sql.Connection.class);
+ var statement = mock(java.sql.PreparedStatement.class);
+ when(table.getMysql().getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ if (ambiguous) when(statement.executeUpdate()).thenThrow(new java.sql.SQLException("synthetic acknowledgement lost"));
+ else when(statement.executeUpdate()).thenReturn(affectedRows);
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ boolean result = new com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess(f.plugin).setCommitted(f.user, 4);
+ assertEquals(!ambiguous && affectedRows == 1, result);
+ verify(statement, org.mockito.Mockito.times(1)).executeUpdate();
+ if (ambiguous) assertEquals("...", f.request("points_nocache"));
+ else {
+ assertEquals("...", f.request("points"));
+ f.drain();
+ assertEquals("4", f.request("points"));
+ }
+ }
+
+ @Test void falseVoteShopReservationDoesNotRewardAndRetiresRecreatedStaleRender() throws Exception { assertFalseShopReservation(true); }
+ @Test void falseVoteShopReservationRecoversFromExplicitAuthoritativeRefill() throws Exception { assertFalseShopReservation(false); }
+
+ private void assertFalseShopReservation(boolean requireImmediateInvalidation) throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.user.getPointsPath()).thenReturn("Points");
+ when(f.user.isCached()).thenReturn(true);
+ var old = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ old.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ var caches = new java.util.concurrent.ConcurrentHashMap();
+ caches.put(f.uuid, old);
+ when(f.user.getCache()).thenReturn(old);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(caches);
+ doAnswer(call -> { caches.remove(f.uuid); return null; }).when(manager).removeCache(f.uuid, null);
+ doAnswer(call -> {
+ var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ caches.put(f.uuid, fresh);
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ var table = f.plugin.getMysql();
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`");
+ var connection = mock(java.sql.Connection.class);
+ when(table.getMysql().getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ var statement = mock(java.sql.PreparedStatement.class);
+ if (call.getArgument(0).startsWith("UPDATE `VotingPlugin_Users` SET")) {
+ when(statement.executeUpdate()).thenAnswer(update -> {
+ // Recreate a stale read snapshot while the authoritative conditional debit rejects.
+ var recreated = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ recreated.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ caches.put(f.uuid, recreated);
+ return 0;
+ });
+ }
+ return statement;
+ });
+ when(f.scheduler.getFoliaLib()).thenReturn(null);
+ doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
+ .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class), eq(f.player));
+ var definition = mock(com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition.class);
+ when(definition.isEnabled()).thenReturn(true);
+ var item = mock(com.bencodez.votingplugin.voteshop.shop.VoteShopItem.class);
+ when(item.getPermission()).thenReturn("");
+ when(item.getCost()).thenReturn(16);
+ when(item.getLimit()).thenReturn(0);
+ when(item.getIdentifier()).thenReturn("synthetic-item");
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ var result = new java.util.concurrent.atomic.AtomicReference();
+ new com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService(f.plugin, definition)
+ .purchase(f.player, f.user, item, result::set);
+ assertEquals(1, f.pending.size());
+ f.pending.removeFirst().run();
+ assertEquals(com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult.NOT_ENOUGH_POINTS, result.get());
+ verify(f.plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any());
+ verify(f.plugin.getRewardHandler(), never()).giveRewardAsync(any(), any(), any(), any());
+ if (requireImmediateInvalidation) assertEquals("...", f.request("points"), "a failed reservation must retire its stale rendered balance");
+ com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(f.plugin, f.uuid.toString(), "Points");
+ assertEquals("4", f.request("points_nocache"));
+ }
private VotingPluginMain previousPlugin;
@BeforeEach void rememberPlugin() { previousPlugin = VotingPluginMain.plugin; }
@@ -127,6 +1304,277 @@ class ColdOfflinePlaceholderCacheTest {
}
}
+ @Test void reconciledDebitRetiresTheStaleRenderedBalance() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ assertEquals("20", f.request("points"));
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ assertEquals("...", f.request("points"));
+ assertEquals("...", f.request("points_nocache"));
+ when(f.user.getPoints()).thenReturn(4);
+ f.placeholders.finishSharedPointDebit(f.uuid, true);
+ assertEquals("...", f.request("points"));
+ f.drain();
+ assertEquals("4", f.request("points"));
+ assertEquals("4", f.request("points_nocache"));
+ }
+
+ @Test void uncertainOrUnavailableSnapshotNeverPublishesOldPoints() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, false);
+ assertEquals("...", f.request("points"));
+ assertEquals("...", f.request("points_nocache"));
+ f.placeholders.onUpdate(f.user, true);
+ verify(f.user, never()).getPoints();
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true);
+ assertEquals("...", f.request("points"), "a later debit cannot resolve an uncertain earlier outcome");
+ }
+
+ @Test void confirmedDebitCanRecoverAfterTransientRefillFailure() {
+ assertDebitRefillRecovery(true);
+ }
+
+ @Test void rejectedDebitCanRecoverAfterTransientRefillFailure() {
+ assertDebitRefillRecovery(true);
+ }
+
+ @Test void unresolvedTransactionIsNotClearedByVerifiedRefill() { assertDebitRefillRecovery(false); }
+
+ private static void assertDebitRefillRecovery(boolean known) {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, known, false);
+ assertEquals("...", f.request("points_nocache"));
+ when(f.user.getPoints()).thenReturn(4);
+ com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(f.plugin, f.uuid.toString(), "Points");
+ assertEquals(known ? "4" : "...", f.request("points_nocache"));
+ }
+
+ @Test void workerPointReconciliationRetiresSiblingMutationRenderedCache() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ when(f.user.getPoints()).thenReturn(4);
+ com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(f.plugin, f.uuid.toString(), "Points");
+ assertEquals("...", f.request("points"));
+ f.drain();
+ assertEquals("4", f.request("points"));
+ }
+
+ @Test void asyncPointReconciliationRetiresRenderedBalanceBeforeRefill() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefresh(f.plugin, f.uuid.toString(), "Points");
+ assertEquals("...", f.request("points"));
+ assertEquals("...", f.request("points_nocache"));
+ verify(f.user, never()).getPoints();
+ }
+
+ @Test void mysqlNoCachePlaceholderUsesPublishedSnapshotWithoutStorageGetter() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.NONE);
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.user.getPointsPath()).thenReturn("Points");
+ when(f.user.getUserDataFetchMode()).thenReturn(com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_CACHE);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doThrow(new AssertionError("platform placeholder entered storage-backed getter")).when(f.user).getPoints();
+ assertEquals("4", f.request("points_nocache"));
+ }
+
+ @Test void coldMysqlNoCacheReadQueuesWorkerWithoutEnteringStorage() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.NONE);
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.user.getPointsPath()).thenReturn("Points");
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>());
+ doThrow(new AssertionError("cold platform read entered storage")).when(f.user).getPoints();
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size());
+ verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any());
+ }
+
+ @Test void rejectedBulkAdmissionRetiresStaleRenderedPoints() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.user.getPointsPath()).thenReturn("Points");
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ doThrow(new java.util.concurrent.RejectedExecutionException("full")).when(f.timer).execute(any(Runnable.class));
+ VotingPluginUser.removePointsStorageAware(f.plugin, List.of(f.user), new java.util.IdentityHashMap<>(),
+ 16, "synthetic-batch", (user, success) -> { });
+ assertEquals("...", f.request("points"));
+ }
+
+ @Test void rejectedDuplicateBulkAdmissionsEachRetireAndPermitWorkerRecovery() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ doThrow(new java.util.concurrent.RejectedExecutionException("full"))
+ .doAnswer(call -> { f.pending.addLast(call.getArgument(0)); return null; })
+ .when(f.timer).execute(any(Runnable.class));
+ VotingPluginUser.removePointsStorageAware(f.plugin, List.of(f.user, f.user), new java.util.IdentityHashMap<>(),
+ 16, "synthetic-duplicate-batch", (user, success) -> { });
+ assertEquals("...", f.request("points_nocache"));
+ assertEquals(1, f.pending.size(), "all rejected occurrences must retire before coalesced recovery");
+ f.pending.removeFirst().run();
+ assertEquals("4", f.request("points_nocache"));
+ }
+
+ @Test void acceptedBulkAdmissionClosesRenderingUntilItsWorkerSettles() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ when(f.plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ VotingPluginUser.removePointsStorageAware(f.plugin, List.of(f.user), new java.util.IdentityHashMap<>(),
+ 16, "synthetic-accepted-batch", (user, success) -> { });
+ assertEquals(1, f.pending.size());
+ assertEquals("...", f.request("points"));
+ assertEquals(1, f.pending.size(), "an owned mutation must not admit a separate recovery");
+ }
+
+ @Test void indeterminateAdditionCannotPublishFromAnUnrelatedSuccessfulRefill() throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL);
+ when(f.user.getPointsPath()).thenReturn("Points");
+ when(f.plugin.getMysql().getTableName()).thenReturn("VotingPlugin_Users");
+ when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection())
+ .thenReturn((java.sql.Connection) null);
+ var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20))));
+ var manager = f.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache)));
+ doAnswer(call -> {
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4))));
+ return null;
+ }).when(manager).cacheUser(f.uuid, null);
+ var result = new com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess(f.plugin)
+ .addCommitted(f.user, 16, "synthetic-addition");
+ assertEquals(com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess.MutationOutcome.INDETERMINATE, result.outcome());
+ assertEquals("...", f.request("points_nocache"));
+ }
+
+ @Test void stoppedPlaceholderWorkerDoesNotExposeRetiredBalanceOrThrow() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true);
+ doThrow(new java.util.concurrent.RejectedExecutionException("stopped"))
+ .when(f.timer).execute(any(Runnable.class));
+ assertEquals("...", f.request("points"));
+ verify(f.user, never()).getPoints();
+ }
+
+ @Test void overlappingDebitsRemainUnavailableUntilBothHaveSettled() {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ f.placeholder("points").getCache().get("points").put(f.uuid, "20");
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true);
+ assertEquals("...", f.request("points"));
+ f.placeholders.finishSharedPointDebit(f.uuid, false);
+ assertEquals("...", f.request("points"));
+ verify(f.user, never()).getPoints();
+ }
+
+ @Test void oldRenderCannotPublishAfterNewerDebit() throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ var entered = new java.util.concurrent.CountDownLatch(1);
+ var resume = new java.util.concurrent.CountDownLatch(1);
+ when(f.user.getPoints()).thenAnswer(call -> {
+ entered.countDown();
+ assertTrue(resume.await(5, TimeUnit.SECONDS));
+ return 20;
+ });
+ try (var worker = Executors.newSingleThreadExecutor()) {
+ var old = worker.submit(() -> f.placeholders.onUpdate(f.user, true));
+ assertTrue(entered.await(5, TimeUnit.SECONDS));
+ f.placeholders.beginSharedPointDebit(f.uuid);
+ f.placeholders.finishSharedPointDebit(f.uuid, true);
+ resume.countDown();
+ old.get(5, TimeUnit.SECONDS);
+ } finally { resume.countDown(); }
+ assertFalse(f.placeholder("points").getCache().get("points").containsKey(f.uuid));
+ when(f.user.getPoints()).thenReturn(4);
+ assertEquals("...", f.request("points"));
+ f.drain();
+ assertEquals("4", f.request("points"));
+ }
+
+ @Test void anotherPlayersDebitDoesNotDiscardThisPlayersRender() throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ var entered = new java.util.concurrent.CountDownLatch(1);
+ var resume = new java.util.concurrent.CountDownLatch(1);
+ when(f.user.getPoints()).thenAnswer(call -> {
+ entered.countDown();
+ assertTrue(resume.await(5, TimeUnit.SECONDS));
+ return 17;
+ });
+ try (var worker = Executors.newSingleThreadExecutor()) {
+ var render = worker.submit(() -> f.placeholders.onUpdate(f.user, true));
+ assertTrue(entered.await(5, TimeUnit.SECONDS));
+ UUID other = UUID.randomUUID();
+ f.placeholders.beginSharedPointDebit(other);
+ f.placeholders.finishSharedPointDebit(other, true);
+ resume.countDown();
+ render.get(5, TimeUnit.SECONDS);
+ } finally { resume.countDown(); }
+ assertEquals("17", f.request("points"));
+ }
+
+ @Test void renderStartedBeforeReloadCannotPublishAfterReload() throws Exception {
+ Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points");
+ var entered = new java.util.concurrent.CountDownLatch(1);
+ var resume = new java.util.concurrent.CountDownLatch(1);
+ when(f.user.getPoints()).thenAnswer(call -> {
+ entered.countDown();
+ assertTrue(resume.await(5, TimeUnit.SECONDS));
+ return 20;
+ });
+ try (var worker = Executors.newSingleThreadExecutor()) {
+ var render = worker.submit(() -> f.placeholders.onUpdate(f.user, true));
+ assertTrue(entered.await(5, TimeUnit.SECONDS));
+ try (var bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of());
+ f.placeholders.reload();
+ }
+ resume.countDown();
+ render.get(5, TimeUnit.SECONDS);
+ } finally { resume.countDown(); }
+ assertFalse(f.placeholder("points").getCache().get("points").containsKey(f.uuid));
+ }
+
@Test void specificAllDoesNotAutomaticallyAdmitAnUnconfiguredIdentifier() {
Fixture f = new Fixture(PlaceholderCacheLevel.SPECIFICALL);
assertEquals("..", f.request("points"));
@@ -339,6 +1787,7 @@ private static final class Fixture {
when(plugin.getVotingPluginUserManager().getVotingPluginUser(offline)).thenReturn(user);
when(user.getJavaUUID()).thenReturn(uuid);
when(user.getUUID()).thenReturn(uuid.toString());
+ when(user.getPointsPath()).thenReturn("Points");
when(user.getTotal(TopVoter.Monthly)).thenReturn(42);
when(user.getPoints()).thenReturn(17);
when(user.getTime(site)).thenReturn(1234L);
@@ -346,6 +1795,7 @@ private static final class Fixture {
doThrow(new AssertionError("worker read live player state")).when(user).isOnline();
doThrow(new AssertionError("worker requested live player")).when(user).getPlayer();
placeholders = new PlaceHolders(plugin);
+ when(plugin.getPlaceholders()).thenReturn(placeholders);
VotingPluginMain.plugin = plugin;
placeholders.load();
}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointSchedulingTest.java
index d3206a359f..1ec7e0f17f 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointSchedulingTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointSchedulingTest.java
@@ -58,6 +58,7 @@
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
import com.bencodez.simpleapi.folialib.FoliaLib;
@@ -108,6 +109,7 @@ void ordinaryBulkPointArithmeticUsesAuthoritativeUserReads() {
void sharedBulkPointMutationsUseOnePersistenceSubmission() throws Exception {
PointFixture fixture = pointFixture();
VotingPluginUser second = mock(VotingPluginUser.class);
+ when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
java.util.List users = java.util.List.of(fixture.user, second);
try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
@@ -137,6 +139,7 @@ void capturedPlayerSetPointsDoesNotLookupPlayerOnStorageCaller() throws Exceptio
void rejectedSharedBulkMutationCompletesEveryUserAsFailed() throws Exception {
PointFixture fixture = pointFixture();
VotingPluginUser second = mock(VotingPluginUser.class);
+ when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
Player secondPlayer = mock(Player.class);
when(second.getPlayer()).thenReturn(secondPlayer);
java.util.List results = new java.util.ArrayList<>();
@@ -161,6 +164,7 @@ void rejectedSharedBulkMutationCompletesEveryUserAsFailed() throws Exception {
void sharedBulkMutationCapturesPlayersBeforePersistenceWork() throws Exception {
PointFixture fixture = pointFixture();
VotingPluginUser second = mock(VotingPluginUser.class);
+ when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
Player secondPlayer = mock(Player.class);
when(second.getPlayer()).thenReturn(secondPlayer);
PluginManager pluginManager = mock(PluginManager.class);
@@ -1058,6 +1062,21 @@ void rejectedSingleUserPointCallbacksCompleteAsFailures() throws Exception {
verify(fixture.sql.getConnectionManager(), never()).getConnection();
}
+ @Test
+ void rejectedDebitCallbackDoesNotRunAfterDisable() throws Exception {
+ PointFixture fixture = pointFixture();
+ AtomicReference result = new AtomicReference<>();
+ doThrow(new RejectedExecutionException("stopped")).when(fixture.persistence).execute(any(Runnable.class));
+ fixture.user.removePoints(5, result::set);
+ verify(fixture.plugin.getPlaceholders()).finishSharedPointDebit(fixture.user.getJavaUUID(), true, false);
+ ArgumentCaptor entity = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), entity.capture(), eq(fixture.player));
+ when(fixture.plugin.isEnabled()).thenReturn(false);
+ entity.getValue().run();
+ assertEquals(null, result.get());
+ verify(fixture.sql.getConnectionManager(), never()).getConnection();
+ }
+
@Test
void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception {
PointFixture fixture = pointFixture();
@@ -1361,6 +1380,42 @@ void sharedRemoveConsumerRunsJdbcOnPersistenceExecutorAndReportsOnEntity() throw
verify(fixture.sql.getConnectionManager(), org.mockito.Mockito.atLeastOnce()).getConnection();
}
+ @Test
+ void rejectedDebitRefreshesPublishedSnapshotBeforeCompletionAndDoesNotLogDatabaseFailure() throws Exception {
+ PointFixture fixture = pointFixture();
+ var uuid = fixture.user.getJavaUUID();
+ var cache = new UserDataCache(null, uuid);
+ cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new DataValueInt(20))));
+ var manager = fixture.plugin.getUserManager().getDataManager();
+ when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, cache)));
+ doAnswer(call -> {
+ assertFalse(cache.hasPublishedStorageSnapshot());
+ cache.updateSharedSnapshot(new java.util.HashMap<>(java.util.Map.of("Points", new DataValueInt(4))),
+ cache.getSharedSnapshotVersion());
+ return null;
+ }).when(manager).cacheUser(uuid, null);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(lookup.executeQuery()).thenReturn(missing);
+ when(fixture.connection.prepareStatement(anyString())).thenAnswer(call ->
+ call.getArgument(0).startsWith("SELECT `player_uuid`") ? lookup : fixture.statement);
+ AtomicReference result = new AtomicReference<>();
+ fixture.user.removePoints(16, result::set);
+ verify(fixture.plugin.getPlaceholders()).beginSharedPointDebit(uuid);
+ verify(fixture.sql.getConnectionManager(), never()).getConnection();
+ ArgumentCaptor worker = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(worker.capture());
+ worker.getValue().run();
+ assertEquals(4, cache.snapshotIfPublished().get("Points").getInt());
+ verify(fixture.plugin.getPlaceholders(), org.mockito.Mockito.times(2)).finishSharedPointDebit(uuid, true, true);
+ verify(fixture.plugin.getLogger(), never()).severe(anyString());
+ assertEquals(null, result.get());
+ ArgumentCaptor entity = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), entity.capture(), eq(fixture.player));
+ entity.getValue().run();
+ assertEquals(Boolean.FALSE, result.get());
+ }
+
@Test
void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() throws Exception {
TransferSchedulingFixture fixture = transferSchedulingFixture();
@@ -1985,6 +2040,7 @@ private static void runTransferApprovalGate(ScheduledExecutorService persistence
private static PointFixture pointFixture() throws Exception {
PointFixture fixture = new PointFixture();
fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(fixture.plugin.isEnabled()).thenReturn(true);
fixture.persistence = mock(ScheduledExecutorService.class);
fixture.scheduler = mock(BukkitScheduler.class);
fixture.entityScheduler = configureEntityScheduler(fixture.plugin, fixture.scheduler);
@@ -2010,6 +2066,8 @@ private static PointFixture pointFixture() throws Exception {
pluginField.setAccessible(true);
ReflectiveAccess.set(pluginField, fixture.user, fixture.plugin);
doReturn("00000000-0000-0000-0000-000000000001").when(fixture.user).getUUID();
+ doReturn(java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"))
+ .when(fixture.user).getJavaUUID();
doReturn("Points").when(fixture.user).getPointsPath();
doReturn(fixture.player).when(fixture.user).getPlayer();
doReturn(false).when(fixture.user).isCached();
diff --git a/docs/point-shop-integrations.md b/docs/point-shop-integrations.md
new file mode 100644
index 0000000000..81afebfcbb
--- /dev/null
+++ b/docs/point-shop-integrations.md
@@ -0,0 +1,19 @@
+# Point balances and external shops
+
+`%votingplugin_points_nocache%` bypasses VotingPlugin's rendered-placeholder
+cache. It does not guarantee a new MySQL read or reserve points, and another
+server may change a shared balance immediately after the value is read.
+
+An external shop that grants a reward before a separate point-removal command
+has confirmed payment cannot make the two actions atomic. Concurrent clicks or
+requests on different servers can grant rewards even when later debits fail.
+Use VotingPlugin's transaction-backed VoteShop when confirmed reservation is
+required, or use an integration that grants rewards only after an authoritative
+payment succeeds.
+
+Point placeholders fail closed while a point mutation's outcome is uncertain.
+They can remain unavailable even after a reload, a later successful operation or
+a snapshot refresh: those events do not confirm the original mutation. The
+current recovery interface does not provide the operation-bound terminal
+evidence needed to clear that condition automatically. Do not repeat an uncertain
+debit as a cache workaround.