diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
index 9d9c78d314..17b0a0207a 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
@@ -736,12 +736,12 @@ public void onPostLoad() {
loadVoteBroadcast();
+ specialRewards = new SpecialRewards(this);
loadVoteShopManager();
loadDirectlyDefined();
checkUpdate = new CheckUpdate(this);
checkUpdate.startUp();
- specialRewards = new SpecialRewards(this);
signs = new Signs(this);
coolDownCheck.checkEnabled();
@@ -2016,7 +2016,8 @@ private void registerEvents() {
pm.registerEvents(new PlayerJoinEvent(this), this);
if (isVotifierLoaded()) {
VotiferEvent votifierEvent = new VotiferEvent(this);
- votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, votifierEvent::processVote);
+ votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this,
+ (serviceSite, username, voteId) -> votifierEvent.processQueuedVoteOutcome(serviceSite, username, voteId));
pm.registerEvents(votifierEvent, this);
}
pm.registerEvents(new PlayerVoteListener(this), this);
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendOrderedVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendOrderedVoteOverflowQueue.java
index 1f40192cf3..21bf3a55c5 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendOrderedVoteOverflowQueue.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendOrderedVoteOverflowQueue.java
@@ -341,11 +341,26 @@ PendingEnvelope peekDurable() {
/** Persists completion off the platform thread before removing the durable head. */
void acknowledgeAsync(PendingEnvelope expected, Consumer completion) {
+ completeHeadAsync(expected, null, completion);
+ }
+
+ /** Atomically replaces the durable head with follow-up work. */
+ void replaceAsync(PendingEnvelope expected, JsonEnvelope replacement, Consumer completion) {
+ PendingEnvelope pendingReplacement = pending(replacement);
+ if (pendingReplacement == null) {
+ completion.accept(false);
+ return;
+ }
+ completeHeadAsync(expected, pendingReplacement, completion);
+ }
+
+ private void completeHeadAsync(PendingEnvelope expected, PendingEnvelope replacement,
+ Consumer completion) {
PendingAcknowledgement request = null;
synchronized (lock) {
if (!closeRequested.get() && !closed && !loadFailed && expected != null && entries.peekFirst() == expected
&& pendingAcknowledgement == null && pendingFailure == null) {
- request = new PendingAcknowledgement(expected, completion);
+ request = new PendingAcknowledgement(expected, replacement, completion);
pendingAcknowledgement = request;
}
}
@@ -382,6 +397,7 @@ private Boolean acknowledge(PendingAcknowledgement request) {
}
List active = payloadSnapshotLocked();
active.remove(0);
+ if (request.replacement != null) active.add(0, request.replacement.payload);
try {
writeSnapshotLocked(active, failedSnapshotLocked());
} catch (IOException failure) {
@@ -389,6 +405,7 @@ private Boolean acknowledge(PendingAcknowledgement request) {
return false;
}
entries.removeFirst();
+ if (request.replacement != null) entries.addFirst(request.replacement);
durableVersion = ++stateVersion;
request.stored = true;
pendingAcknowledgement = null;
@@ -464,7 +481,8 @@ private static boolean isOrderedVoteMessage(JsonEnvelope envelope) {
return VotingPluginWire.SUB_VOTE.equals(subChannel)
|| VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)
|| VotingPluginWire.SUB_VOTE_UPDATE.equals(subChannel)
- || VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE.equals(subChannel);
+ || VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE.equals(subChannel)
+ || BackendProxyHandler.isLegacyDeliveryRetirement(envelope);
}
private void requestPersistenceLocked() {
@@ -689,7 +707,12 @@ private void closeBlocking() {
&& (closingFailure.expected == null || entries.peekFirst() == closingFailure.expected);
canStoreAcknowledgement = closingAcknowledgement != null && !closingAcknowledgement.stored
&& entries.peekFirst() == closingAcknowledgement.expected;
- if (canStoreAcknowledgement) snapshot.remove(0);
+ if (canStoreAcknowledgement) {
+ snapshot.remove(0);
+ if (closingAcknowledgement.replacement != null) {
+ snapshot.add(0, closingAcknowledgement.replacement.payload);
+ }
+ }
if (canStoreFailure) {
if (closingFailure.expected != null) snapshot.remove(0);
if (!closingFailureAlreadyStored) failures.add(closingFailure.failed.payload);
@@ -733,6 +756,9 @@ private void closeBlocking() {
if (saved && canStoreAcknowledgement) {
synchronized (lock) {
entries.removeFirst();
+ if (closingAcknowledgement.replacement != null) {
+ entries.addFirst(closingAcknowledgement.replacement);
+ }
closingAcknowledgement.stored = true;
}
}
@@ -756,12 +782,15 @@ enum CloseState { OPEN, CLOSING, WAITING_TO_PERSIST, SNAPSHOT_REPLACED, COMPLETE
private static final class PendingAcknowledgement {
private final PendingEnvelope expected;
+ private final PendingEnvelope replacement;
private final Consumer completion;
private final AtomicBoolean completed = new AtomicBoolean();
private boolean stored;
- private PendingAcknowledgement(PendingEnvelope expected, Consumer completion) {
+ private PendingAcknowledgement(PendingEnvelope expected, PendingEnvelope replacement,
+ Consumer completion) {
this.expected = expected;
+ this.replacement = replacement;
this.completion = completion;
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java
index c1e6b9726a..6006ad5277 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java
@@ -31,6 +31,8 @@
import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync;
import com.bencodez.votingplugin.proxy.BungeeMethod;
import com.bencodez.votingplugin.proxy.VotingPluginWire;
+import com.bencodez.votingplugin.util.VoteTaskAdmission;
+import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService;
import lombok.Getter;
@@ -38,6 +40,7 @@
* Coordinates backend/proxy communication components.
*/
public class BackendProxyHandler implements Listener {
+ private static final String SUB_LEGACY_DELIVERY_RETIREMENT = "LegacyVoteDeliveryRetirement";
private final VotingPluginMain plugin;
static final int MAX_ORDERED_VOTE_QUEUE = 256;
@@ -360,7 +363,8 @@ private void scheduleOrderedVoteDispatchLocked() {
}
orderedVoteDispatchActive = true;
try {
- plugin.getBukkitScheduler().runTaskAsynchronously(plugin, this::runNextOrderedVoteDispatch);
+ plugin.getBukkitScheduler().runTaskAsynchronously(plugin,
+ VoteTaskAdmission.ownedTask(this::runNextOrderedVoteDispatch));
} catch (RuntimeException schedulingFailure) {
orderedVoteDispatchActive = false;
orderedVoteDispatch.notifyAll();
@@ -433,6 +437,16 @@ private void runNextOrderedVoteDispatch() {
}
};
try {
+ if (isLegacyDeliveryRetirement(next)) {
+ try {
+ completeLegacyDeliveryRetirement(next);
+ complete.accept(OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT);
+ } catch (RuntimeException retirementFailure) {
+ plugin.debug(retirementFailure);
+ complete.accept(OrderedVoteOutcome.RETRY);
+ }
+ return;
+ }
if (dispatchDurableReceiptRelease(messageRouter, next)) {
complete.accept(OrderedVoteOutcome.COMPLETE);
return;
@@ -464,16 +478,30 @@ private void finishOrderedVoteDispatch(BackendOrderedVoteOverflowQueue.PendingEn
}
return;
}
- boolean successful = outcome == OrderedVoteOutcome.COMPLETE;
+ boolean successful = outcome == OrderedVoteOutcome.COMPLETE
+ || outcome == OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT;
+ boolean retireDelivery = outcome == OrderedVoteOutcome.COMPLETE;
+ if (successful && retireDelivery && requiresLegacyDeliveryRetirement(envelope)) {
+ JsonEnvelope retirement = legacyDeliveryRetirement(envelope);
+ if (overflowEntry != null && orderedVoteOverflow != null) {
+ orderedVoteOverflow.replaceAsync(overflowEntry, retirement,
+ stored -> completeOrderedVoteAcknowledgement(stored, retirement, false));
+ } else {
+ replaceInMemoryVoteWithRetirement(envelope, retirement);
+ }
+ return;
+ }
if (successful && overflowEntry != null && orderedVoteOverflow != null) {
orderedVoteOverflow.acknowledgeAsync(overflowEntry,
- stored -> completeOrderedVoteAcknowledgement(stored, envelope));
+ stored -> completeOrderedVoteAcknowledgement(stored, envelope, retireDelivery));
return;
}
+ boolean completedInMemory = false;
synchronized (orderedVoteDispatch) {
if (successful && overflowEntry == null
&& orderedVoteDispatchQueue.peekFirst() == orderedVoteDispatchInFlight) {
orderedVoteDispatchQueue.removeFirst();
+ completedInMemory = true;
}
orderedVoteDispatchInFlight = null;
orderedVoteOverflowInFlight = null;
@@ -482,10 +510,52 @@ private void finishOrderedVoteDispatch(BackendOrderedVoteOverflowQueue.PendingEn
if (successful) scheduleOrderedVoteDispatchLocked();
else retryOrderedVoteDispatchLocked();
}
- if (successful) sendVoteDeliveryAcknowledgement(envelope);
+ if (completedInMemory && retireDelivery) completeVoteDelivery(envelope);
}
- private void completeOrderedVoteAcknowledgement(boolean stored, JsonEnvelope envelope) {
+ private boolean requiresLegacyDeliveryRetirement(JsonEnvelope envelope) {
+ if (envelope == null || VotingPluginWire.requestsVoteDeliveryAcknowledgement(envelope)) return false;
+ if (!VotingPluginWire.SUB_VOTE.equals(envelope.getSubChannel())
+ && !VotingPluginWire.SUB_VOTE_ONLINE.equals(envelope.getSubChannel())) return false;
+ return VotingPluginWire.resolveVoteId(VotingPluginWire.readVote(envelope)) != null;
+ }
+
+ static boolean isLegacyDeliveryRetirement(JsonEnvelope envelope) {
+ return envelope != null && SUB_LEGACY_DELIVERY_RETIREMENT.equals(envelope.getSubChannel());
+ }
+
+ private JsonEnvelope legacyDeliveryRetirement(JsonEnvelope envelope) {
+ UUID voteId = VotingPluginWire.resolveVoteId(VotingPluginWire.readVote(envelope));
+ return JsonEnvelope.builder(SUB_LEGACY_DELIVERY_RETIREMENT)
+ .put(VotingPluginWire.K_VOTE_ID, voteId.toString()).build();
+ }
+
+ private void completeLegacyDeliveryRetirement(JsonEnvelope envelope) {
+ VoteShopPurchaseService.completeVoteDelivery(plugin,
+ UUID.fromString(envelope.getFields().get(VotingPluginWire.K_VOTE_ID)));
+ }
+
+ private void replaceInMemoryVoteWithRetirement(JsonEnvelope envelope, JsonEnvelope retirement) {
+ synchronized (orderedVoteDispatch) {
+ if (orderedVoteDispatchInFlight != envelope || orderedVoteDispatchQueue.peekFirst() != envelope) {
+ orderedVoteQuarantineFailed = true;
+ orderedVoteDispatchPaused = true;
+ if (plugin != null && plugin.getLogger() != null) {
+ plugin.getLogger().severe("Unable to retain legacy proxy vote retirement; processing has stopped");
+ }
+ } else {
+ orderedVoteDispatchQueue.removeFirst();
+ orderedVoteDispatchQueue.addFirst(retirement);
+ }
+ orderedVoteDispatchInFlight = null;
+ orderedVoteOverflowInFlight = null;
+ orderedVoteDispatchActive = false;
+ orderedVoteDispatch.notifyAll();
+ scheduleOrderedVoteDispatchLocked();
+ }
+ }
+
+ private void completeOrderedVoteAcknowledgement(boolean stored, JsonEnvelope envelope, boolean retireDelivery) {
synchronized (orderedVoteDispatch) {
if (stored) {
orderedVoteDispatchInFlight = null;
@@ -501,7 +571,16 @@ private void completeOrderedVoteAcknowledgement(boolean stored, JsonEnvelope env
orderedVoteDispatch.notifyAll();
if (stored) scheduleOrderedVoteDispatchLocked();
}
- if (stored) sendVoteDeliveryAcknowledgement(envelope);
+ if (stored && retireDelivery) completeVoteDelivery(envelope);
+ }
+
+ private void completeVoteDelivery(JsonEnvelope envelope) {
+ String subChannel = envelope == null ? null : envelope.getSubChannel();
+ if (!VotingPluginWire.SUB_VOTE.equals(subChannel)
+ && !VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)) return;
+ VoteShopPurchaseService.completeVoteDelivery(plugin,
+ VotingPluginWire.resolveVoteId(VotingPluginWire.readVote(envelope)));
+ sendVoteDeliveryAcknowledgement(envelope);
}
private void sendVoteDeliveryAcknowledgement(JsonEnvelope envelope) {
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java
index ccc8f8cdf0..c08b51b88f 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java
@@ -99,6 +99,11 @@ public boolean reserve(UUID voteId) {
}
}
+ /** Releases an admission that failed before any vote side effects ran. */
+ public void release(UUID voteId) {
+ if (voteId != null) processedVotes.remove(voteId);
+ }
+
/** Persists successful processing before the backend emits a delivery acknowledgement. */
public boolean complete(UUID voteId) {
if (voteId == null) return true;
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java
index f796276487..e9b8b2aa89 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java
@@ -24,6 +24,7 @@
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
import com.bencodez.simpleapi.sql.data.DataValue;
import com.bencodez.simpleapi.sql.data.DataValueBoolean;
+import com.bencodez.simpleapi.sql.data.DataValueString;
import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigSpigot;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.proxy.VotingPluginWire;
@@ -264,6 +265,10 @@ private boolean checkGlobalDataTime(TimeType type, HashMap da
expectedHandler.setBoolean(plugin.getBungeeSettings().getServer(), type.toString(), false);
return false;
}
+ String transitionId = data.containsKey(VotingPluginWire.timeChangeTransitionKey(type.toString()))
+ ? data.get(VotingPluginWire.timeChangeTransitionKey(type.toString())).getString() : "";
+ boolean confirmsBoundary = transitionId != null && !transitionId.isBlank()
+ && !VotingPluginWire.LEGACY_TIME_CHANGE_TRANSITION.equals(transitionId);
String serverName = plugin.getBungeeSettings().getServer();
GlobalDataHandler transitionHandler = admitTimeChange(type, serverName, expectedHandler);
if (transitionHandler == null) return false;
@@ -284,7 +289,7 @@ private boolean checkGlobalDataTime(TimeType type, HashMap da
return;
}
try {
- finishTimeChange(transitionHandler, type, serverName);
+ finishTimeChange(transitionHandler, type, serverName, transitionId, confirmsBoundary);
} catch (RuntimeException failure) {
// finishTimeChange releases the pinned handler in its finally block.
plugin.debug(failure);
@@ -332,10 +337,19 @@ private GlobalDataHandler admitTimeChange(TimeType type, String serverName,
}
}
- private void finishTimeChange(GlobalDataHandler handler, TimeType type, String serverName) {
+ private void finishTimeChange(GlobalDataHandler handler, TimeType type, String serverName,
+ String transitionId, boolean confirmsBoundary) {
boolean completed = false;
try {
- handler.setBoolean(serverName, type.toString(), false);
+ if (confirmsBoundary) {
+ HashMap completion = new HashMap<>();
+ completion.put(type.toString(), new DataValueBoolean(false));
+ completion.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()),
+ new DataValueString(transitionId));
+ handler.setData(serverName, completion);
+ } else {
+ handler.setBoolean(serverName, type.toString(), false);
+ }
JsonEnvelope.Builder builder = JsonEnvelope.builder("TimeChangeFinished")
.schema(VotingPluginWire.SCHEMA_VERSION);
builder.put("server", serverName);
@@ -500,6 +514,14 @@ public void load() {
"ForceUpdate", "VARCHAR(5)").entrySet()) {
loadedHandler.getGlobalMysql().alterColumnType(column.getKey(), column.getValue());
}
+ for (TimeType type : TimeType.values()) {
+ loadedHandler.getGlobalMysql().alterColumnType(
+ VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), "VARCHAR(36)");
+ loadedHandler.getGlobalMysql().alterColumnType(
+ VotingPluginWire.timeChangeTransitionKey(type.toString()), "VARCHAR(36)");
+ }
+ loadedHandler.getGlobalMysql().alterColumnType(
+ VotingPluginWire.TIME_CHANGE_BOUNDARY_PROTOCOL_KEY, "VARCHAR(32)");
acceptingWork = true;
timer = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "VotingPlugin-GlobalData");
@@ -507,10 +529,26 @@ public void load() {
return thread;
});
timer.scheduleWithFixedDelay(this::checkGlobalData, 60, 10, TimeUnit.SECONDS);
- timer.scheduleWithFixedDelay(this::updateLastOnline, 1, 60, TimeUnit.MINUTES);
+ publishBoundaryProtocolHeartbeat();
+ timer.scheduleWithFixedDelay(this::publishBoundaryProtocolHeartbeat, 1, 60, TimeUnit.MINUTES);
plugin.getTimeChecker().setProcessingEnabled(false);
}
+ private void publishBoundaryProtocolHeartbeat() {
+ GlobalDataHandler pollingHandler = acquireHandlerReference();
+ if (pollingHandler == null) return;
+ try {
+ String heartbeat = "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli();
+ HashMap state = new HashMap<>();
+ state.put("LastOnline", new DataValueString(heartbeat));
+ state.put(VotingPluginWire.TIME_CHANGE_BOUNDARY_PROTOCOL_KEY,
+ new DataValueString(VotingPluginWire.timeChangeBoundaryProtocolHeartbeat(heartbeat)));
+ pollingHandler.setData(plugin.getBungeeSettings().getServer(), state);
+ } finally {
+ releaseHandlerReference(pollingHandler);
+ }
+ }
+
public void close() {
close(CLOSE_GRACE_SECONDS, TimeUnit.SECONDS);
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java
index d015c5de18..2fb722a93a 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java
@@ -17,6 +17,7 @@
import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync;
import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager;
import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync;
+import com.bencodez.votingplugin.core.vote.SharedVoteAdmissionException;
import com.bencodez.votingplugin.proxy.BungeeMethod;
import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot;
import com.bencodez.votingplugin.proxy.VotingPluginWire;
@@ -28,7 +29,7 @@
* Registers and handles backend-side proxy message routes.
*/
public class BackendProxyMessageRouter {
- public enum OrderedVoteOutcome { COMPLETE, RETRY, QUARANTINE }
+ public enum OrderedVoteOutcome { COMPLETE, COMPLETE_WITHOUT_RETIREMENT, RETRY, QUARANTINE }
private final VotingPluginMain plugin;
private final BackendPresenceManager presenceManager;
@@ -148,11 +149,15 @@ public void handleOrderedVote(JsonEnvelope msg, Consumer com
completion.accept(OrderedVoteOutcome.RETRY);
return;
}
+ completion.accept(result != null && result.effectsComplete()
+ ? OrderedVoteOutcome.COMPLETE : OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT);
+ } catch (SharedVoteAdmissionException retryableAdmission) {
+ completion.accept(OrderedVoteOutcome.RETRY);
+ return;
} catch (RuntimeException | Error failure) {
completion.accept(OrderedVoteOutcome.QUARANTINE);
throw failure;
}
- completion.accept(OrderedVoteOutcome.COMPLETE);
return;
}
completion.accept(OrderedVoteOutcome.QUARANTINE);
@@ -395,8 +400,7 @@ private WireVoteResult handleWireVote(JsonEnvelope msg) {
+ ServiceSiteValidator.sanitizeForLog(vote.uuid) + " on "
+ ServiceSiteValidator.sanitizeForLog(vote.service));
VoteTotalsSnapshot totals = VoteTotalsSnapshot.parseStorage(vote.totals == null ? "" : vote.totals);
- @SuppressWarnings("deprecation")
- UUID voteId = vote.voteId != null ? vote.voteId : totals.getVoteUUID();
+ UUID voteId = VotingPluginWire.resolveVoteId(vote);
if (!processedVoteCache.reserve(voteId)) {
plugin.debug("Ignoring duplicate wire vote " + voteId + " for "
+ ServiceSiteValidator.sanitizeForLog(vote.player) + " on "
@@ -416,8 +420,11 @@ private WireVoteResult handleWireVote(JsonEnvelope msg) {
votePartySync.replace(totals.getVotePartyCurrent(), totals.getVotePartyRequired());
user.cache();
boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : user.isOnline();
- user.bungeeVotePluginMessaging(vote.service, vote.time, totals, !vote.manageTotals,
- wasOnline, vote.broadcast, vote.num);
+ if (!user.bungeeVotePluginMessagingAccepted(vote.service, vote.time, totals, !vote.manageTotals,
+ wasOnline, vote.broadcast, vote.num, voteId)) {
+ processedVoteCache.release(voteId);
+ throw new SharedVoteAdmissionException("Proxy vote accounting admission failed");
+ }
if (plugin.getBungeeSettings().isPerServerPoints()) {
user.addPoints(plugin.getConfigFile().getPointsOnVote());
}
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 20931d6ae2..544d28fcaa 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java
@@ -92,8 +92,8 @@
import com.bencodez.votingplugin.topvoter.TopVoter;
import com.bencodez.votingplugin.user.VotingPluginUser;
import com.bencodez.votingplugin.user.PointTransferResult;
-import com.bencodez.votingplugin.util.VoteTaskAdmission;
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
+import com.bencodez.votingplugin.util.BukkitVoteEventDispatcher;
import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult;
import com.bencodez.votingplugin.voteshop.shop.VoteShopEntry;
import com.bencodez.votingplugin.voteshop.shop.VoteShopItem;
@@ -130,6 +130,26 @@ void runForVotingUser(VotingPluginUser user, Runnable task) {
BukkitCompletionScheduler.run(plugin, user.getPlayer(), task);
}
+ private void callVoteAndReport(CommandSender sender, PlayerVoteEvent event, Runnable success) {
+ BukkitVoteEventDispatcher.dispatch(plugin, event);
+ event.getProcessingCompletion().whenComplete((completed, failure) -> runForCommandSender(sender, () -> {
+ if (failure != null || completed.isProcessingIncomplete()) {
+ sender.sendMessage(MessageAPI.colorize(
+ "&cVote could not be processed because shared storage is unavailable."));
+ } else if (success != null) {
+ success.run();
+ }
+ }));
+ }
+
+ private void submitVoteAndReport(CommandSender sender, PlayerVoteEvent event) {
+ submitVoteAndReport(sender, event, null);
+ }
+
+ private void submitVoteAndReport(CommandSender sender, PlayerVoteEvent event, Runnable success) {
+ callVoteAndReport(sender, event, success);
+ }
+
void runBulkStorageMutation(CommandSender sender, Runnable mutation, Runnable success) {
try {
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
@@ -1381,31 +1401,15 @@ public void execute(CommandSender sender, String[] args) {
@Override
public void execute(CommandSender sender, String[] args) {
sendMessage(sender, "&cTriggering vote for all voting sites...");
- int rejected = 0;
for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
- if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), new Runnable() {
-
- @Override
- public void run() {
- PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, args[1], site.getServiceSite(),
- false);
- if (voteEvent.getVoteSite() != null) {
- if (!voteEvent.getVoteSite().isVaidServiceSite()
- && !plugin.getConfigFile().isDisableNoServiceSiteMessage()) {
- sendMessage(sender,
- "&cPossible issue with service site, has the server gotten the vote from "
- + voteEvent.getServiceSite() + "?");
- }
- }
- plugin.getServer().getPluginManager().callEvent(voteEvent);
- }
- })) {
- rejected++;
+ PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, args[1], site.getServiceSite(), false, false);
+ if (!voteEvent.getVoteSite().isVaidServiceSite()
+ && !plugin.getConfigFile().isDisableNoServiceSiteMessage()) {
+ sendMessage(sender,
+ "&cPossible issue with service site, has the server gotten the vote from "
+ + voteEvent.getServiceSite() + "?");
}
- }
- if (rejected > 0) {
- sendMessage(sender, "&cCould not trigger " + rejected
- + " vote(s) because vote processing is busy; please try again later.");
+ submitVoteAndReport(sender, voteEvent);
}
if (plugin.isYmlError()) {
@@ -1421,7 +1425,7 @@ public void run() {
@Override
public void execute(CommandSender sender, String[] args) {
PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(args[2], true),
- args[1], args[2], false);
+ args[1], args[2], false, false);
if (voteEvent.getVoteSite() != null) {
if (!voteEvent.getVoteSite().isVaidServiceSite()
&& !plugin.getConfigFile().isDisableNoServiceSiteMessage()) {
@@ -1432,15 +1436,7 @@ public void execute(CommandSender sender, String[] args) {
sendMessage(sender, "&cTriggering vote...");
}
- if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), new Runnable() {
-
- @Override
- public void run() {
- plugin.getServer().getPluginManager().callEvent(voteEvent);
- }
- })) {
- sendMessage(sender, "&cCould not trigger the vote because vote processing is busy; please try again later.");
- }
+ submitVoteAndReport(sender, voteEvent);
if (plugin.isYmlError()) {
sendMessage(sender, "&3Detected yml error, please check server log for details");
@@ -1458,7 +1454,7 @@ public void run() {
@Override
public void execute(CommandSender sender, String[] args) {
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
- plugin.getVoteSiteManager().getVoteSite(args[2], true), args[1], args[2], false);
+ plugin.getVoteSiteManager().getVoteSite(args[2], true), args[1], args[2], false, false);
if (!plugin.getConfigFile().isDisableNoServiceSiteMessage() && !isPlayer(sender)) {
sendMessage(sender, "&cTriggering vote...");
}
@@ -1470,15 +1466,7 @@ public void execute(CommandSender sender, String[] args) {
+ voteEvent.getServiceSite() + "?");
}
}
- if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), new Runnable() {
-
- @Override
- public void run() {
- plugin.getServer().getPluginManager().callEvent(voteEvent);
- }
- })) {
- sendMessage(sender, "&cCould not trigger the vote because vote processing is busy; please try again later.");
- }
+ submitVoteAndReport(sender, voteEvent);
if (plugin.isYmlError()) {
sendMessage(sender, "&3Detected yml error, please check server log for details");
@@ -1514,7 +1502,7 @@ public void execute(CommandSender sender, String[] args) {
sendMessage(sender, "&cTriggering vote for all voting sites...");
for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, args[1], site.getServiceSite(),
- false);
+ false, false);
if (voteEvent.getVoteSite() != null) {
if (!voteEvent.getVoteSite().isVaidServiceSite()
&& !plugin.getConfigFile().isDisableNoServiceSiteMessage()) {
@@ -1523,7 +1511,7 @@ public void execute(CommandSender sender, String[] args) {
+ voteEvent.getServiceSite() + "?");
}
}
- plugin.getServer().getPluginManager().callEvent(voteEvent);
+ submitVoteAndReport(sender, voteEvent);
}
if (plugin.isYmlError()) {
@@ -1540,7 +1528,7 @@ public void execute(CommandSender sender, String[] args) {
@Override
public void execute(CommandSender sender, String[] args) {
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
- plugin.getVoteSiteManager().getVoteSite(args[3], true), args[1], args[3], false);
+ plugin.getVoteSiteManager().getVoteSite(args[3], true), args[1], args[3], false, false);
if (!plugin.getConfigFile().isDisableNoServiceSiteMessage() && !isPlayer(sender)) {
sendMessage(sender, "&cTriggering vote...");
}
@@ -1552,7 +1540,7 @@ public void execute(CommandSender sender, String[] args) {
+ voteEvent.getServiceSite() + "?");
}
}
- plugin.getServer().getPluginManager().callEvent(voteEvent);
+ submitVoteAndReport(sender, voteEvent);
}
});
@@ -3208,16 +3196,14 @@ public void onClick(ClickEvent clickEvent) {
@Override
public void onInput(Player player, String value) {
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
- plugin.getVoteSiteManager().getVoteSite(value, true),
- UserGUI.getInstance().getCurrentPlayer(player),
- plugin.getVoteSiteManager()
- .getVoteSiteServiceSite(value),
- false);
- plugin.getServer().getPluginManager().callEvent(voteEvent);
-
- player.sendMessage("Forced vote for "
- + UserGUI.getInstance().getCurrentPlayer(player)
- + " on " + value);
+ plugin.getVoteSiteManager().getVoteSite(value, true),
+ UserGUI.getInstance().getCurrentPlayer(player),
+ plugin.getVoteSiteManager()
+ .getVoteSiteServiceSite(value),
+ false, false);
+ String target = UserGUI.getInstance().getCurrentPlayer(player);
+ submitVoteAndReport(player, voteEvent, () -> player
+ .sendMessage("Forced vote for " + target + " on " + value));
}
});
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java
index cec807d6f8..983f25658c 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java
@@ -33,7 +33,7 @@
import com.bencodez.votingplugin.commands.gui.admin.milestones.AdminVoteVoteMilestones;
import com.bencodez.votingplugin.commands.gui.admin.voteshop.AdminVoteVoteShop;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
-import com.bencodez.votingplugin.util.VoteTaskAdmission;
+import com.bencodez.votingplugin.util.BukkitVoteEventDispatcher;
import com.bencodez.votingplugin.votesites.VoteSite;
/**
@@ -324,17 +324,15 @@ public void onInput(Player player, String value) {
Object ob = PlayerUtils.getPlayerMeta(plugin, player, "VoteSite");
if (ob != null) {
VoteSite site = (VoteSite) ob;
- PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, value, site.getServiceSite(), false);
- if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), new Runnable() {
-
- @Override
- public void run() {
- plugin.getServer().getPluginManager().callEvent(voteEvent);
- }
- })) {
- player.sendMessage(
- "Vote could not be triggered because vote processing is busy; please try again later.");
- }
+ PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, value, site.getServiceSite(), false, false);
+ BukkitVoteEventDispatcher.dispatch(plugin, voteEvent);
+ voteEvent.getProcessingCompletion().whenComplete((completed, failure) -> {
+ if (failure == null && !completed.isProcessingIncomplete()) return;
+ plugin.getBukkitScheduler().runTask(plugin,
+ () -> player.sendMessage(
+ "Vote could not be processed because shared storage is unavailable."),
+ player);
+ });
}
}
});
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java
index ec12934bd9..271121e0b5 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayer.java
@@ -12,6 +12,7 @@
import com.bencodez.advancedcore.api.inventory.editgui.EditGUI;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
+import com.bencodez.votingplugin.util.BukkitVoteEventDispatcher;
import com.bencodez.votingplugin.votesites.VoteSite;
/**
@@ -60,7 +61,7 @@ public void onChest(Player player) {
@Override
public void onClick(ClickEvent clickEvent) {
VoteSite site = (VoteSite) getData("site");
- PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, playerName, site.getServiceSite(), false);
+ PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, playerName, site.getServiceSite(), false, false);
sendMessage(clickEvent.getPlayer(), "&cTriggering vote...");
if (voteEvent.getVoteSite() != null) {
if (!voteEvent.getVoteSite().isVaidServiceSite()) {
@@ -69,7 +70,7 @@ public void onClick(ClickEvent clickEvent) {
+ voteEvent.getServiceSite() + "?");
}
}
- plugin.getServer().getPluginManager().callEvent(voteEvent);
+ dispatchVote(clickEvent.getPlayer(), voteEvent);
if (plugin.isYmlError()) {
sendMessage(clickEvent.getPlayer(),
@@ -81,6 +82,16 @@ public void onClick(ClickEvent clickEvent) {
inv.openInventory(player);
}
+
+ void dispatchVote(Player player, PlayerVoteEvent voteEvent) {
+ BukkitVoteEventDispatcher.dispatch(plugin, voteEvent);
+ voteEvent.getProcessingCompletion().whenComplete((completed, failure) -> {
+ if (failure == null && !completed.isProcessingIncomplete()) return;
+ plugin.getBukkitScheduler().runTask(plugin,
+ () -> sendMessage("&cVote could not be processed because shared storage is unavailable."),
+ player);
+ });
+ }
@Override
public void onDialog(Player player) {
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java
index 7a2e6e306a..2c47735d33 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAccounting.java
@@ -18,11 +18,22 @@ public static void apply(SharedVoteInput input, SharedVotePolicy policy, Boolean
Objects.requireNonNull(addTotalWeekly, "addTotalWeekly");
Objects.requireNonNull(addPoints, "addPoints");
if (!policy.shouldAwardConfiguredPoints(input)) return;
- if (policy.shouldCountTotals(input, currentlyOnline)) {
+ applyAdmitted(policy.shouldCountTotals(input, currentlyOnline), true, addTotal, addTotalDaily,
+ addTotalWeekly, addPoints);
+ }
+
+ /** Applies an accounting decision that was durably admitted before vote side effects began. */
+ public static void applyAdmitted(boolean countTotals, boolean awardPoints,
+ Runnable addTotal, Runnable addTotalDaily, Runnable addTotalWeekly, Runnable addPoints) {
+ Objects.requireNonNull(addTotal, "addTotal");
+ Objects.requireNonNull(addTotalDaily, "addTotalDaily");
+ Objects.requireNonNull(addTotalWeekly, "addTotalWeekly");
+ Objects.requireNonNull(addPoints, "addPoints");
+ if (countTotals) {
addTotal.run();
addTotalDaily.run();
addTotalWeekly.run();
}
- addPoints.run();
+ if (awardPoints) addPoints.run();
}
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAdmissionException.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAdmissionException.java
new file mode 100644
index 0000000000..6a0d3cd0df
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteAdmissionException.java
@@ -0,0 +1,10 @@
+package com.bencodez.votingplugin.core.vote;
+
+/** Signals that durable accounting admission failed before vote side effects began. */
+public final class SharedVoteAdmissionException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ public SharedVoteAdmissionException(String message) {
+ super(message);
+ }
+}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java
index 80e50216bc..ab5d1e4900 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java
@@ -15,6 +15,8 @@ public static boolean isQueuedVoteAlreadyRecorded(boolean proxyVote, long messag
public record Validation(boolean valid, String normalizedName, String source, String reason, boolean bedrock) { }
public record Name(String value, String rationale) { }
+ public record AccountingAdmission(boolean countTotals, boolean awardPoints, boolean votePartyEligible,
+ int pointAmount, int pointCap, String pointColumn, boolean replayUnsafe) { }
public interface Operations {
boolean enabled();
@@ -58,10 +60,19 @@ public interface Operations {
boolean hasBroadcastHandler();
void broadcast(UUID uuid, String name, String siteDisplayName, boolean online);
boolean hasProxyTextTotals();
- UUID proxyVoteId();
+ UUID incomingVoteId();
+ int configuredPointAmount();
+ int configuredPointCap();
+ AccountingAdmission prepareAccounting(U user, UUID voteId, boolean countTotals, boolean awardPoints,
+ int pointAmount, int pointCap);
+ void finishAccounting(UUID voteId);
void cache(U user);
void updateName(U user);
- void voteParty(U user, boolean realVote, boolean forceProxyRouting, boolean onlineAtVoteTime);
+ void voteParty(U user, boolean forceProxyRouting, UUID voteId, boolean eligible);
+ void markReplayUnsafe(UUID voteId);
+ default boolean deferDeliveryCompletion() { return false; }
+ default void completeDelivery(UUID voteId) { }
+ void restoreReplayUnsafe();
long incomingTime();
void setTime(U user, S site, long time);
void setTimeNow(U user, S site);
@@ -76,11 +87,11 @@ public interface Operations {
int offlineVotesLimitAmount();
void addOfflineVote(U user, String siteKey);
SharedVotePolicy countingPolicy();
- void addTotal(U user);
- void addTotalDaily(U user);
- void addTotalWeekly(U user);
- void addPoints(U user);
- void checkDayVoteStreak(U user, boolean forceProxyRouting);
+ void addTotal(U user, UUID voteId);
+ void addTotalDaily(U user, UUID voteId);
+ void addTotalWeekly(U user, UUID voteId);
+ void addPoints(U user, UUID voteId, int amount, int cap, String pointColumn);
+ void checkDayVoteStreak(U user, boolean forceProxyRouting, UUID voteId);
boolean limitMonthlyVotes();
int proxyMonthTotal();
int userMonthTotal(U user);
@@ -161,73 +172,94 @@ public static void process(Operations ops) {
ops.debug("Allowing queued proxy vote for " + ops.userName(user) + " on " + ops.siteKey(site)
+ "; proxy vote time already matches LastVotes: " + ops.incomingTime());
}
- UUID voteId = UUID.randomUUID();
- if (ops.proxyVote() && ops.hasProxyTextTotals()) {
- voteId = ops.proxyVoteId();
- if (voteId == null) voteId = UUID.randomUUID();
- }
+ UUID candidateVoteId = ops.incomingVoteId();
+ if (candidateVoteId == null) candidateVoteId = UUID.randomUUID();
+ final UUID voteId = candidateVoteId;
String userId = ops.userId(user);
- ops.cache(user);
- ops.updateName(user);
- boolean onlineAtVoteTime = ops.proxyVote() ? ops.wasOnline() : ops.userOnline(user);
- ops.voteParty(user, ops.realVote(), ops.forceProxyRouting(), onlineAtVoteTime);
- if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) {
- boolean currentOnline = ops.userOnline(user);
- boolean online = currentOnline;
- if (ops.proxyVote()) online = ops.wasOnline();
- if (!ops.userVanished(user)) {
- SharedVoteIdentity identity = new SharedVoteIdentity(ops.userUuid(user), playerName, currentOnline);
- ops.broadcast(identity.uuid(), identity.playerName(), ops.siteDisplayName(site), online);
+ SharedVotePolicy policy = ops.countingPolicy();
+ SharedVoteInput accountingInput = new SharedVoteInput(voteId, playerName, ops.serviceSite(),
+ ops.incomingTime(), ops.realVote(), ops.addTotals(), ops.proxyVote(),
+ ops.forceProxyRouting(), ops.wasOnline());
+ boolean proposedCountTotals = policy.shouldCountTotals(accountingInput, () -> ops.userOnline(user));
+ boolean proposedAwardPoints = policy.shouldAwardConfiguredPoints(accountingInput);
+ AccountingAdmission admission = ops.prepareAccounting(user, voteId, proposedCountTotals, proposedAwardPoints,
+ ops.configuredPointAmount(), ops.configuredPointCap());
+ boolean countTotals = admission.countTotals();
+ boolean awardPoints = admission.awardPoints();
+ boolean votePartyEligible = admission.votePartyEligible();
+ if (admission.replayUnsafe()) {
+ ops.restoreReplayUnsafe();
+ throw new SharedVoteReplayUnsafeException("Vote delivery already crossed its durable effect boundary");
+ }
+ try {
+ ops.cache(user);
+ ops.updateName(user);
+ // VoteParty can execute rewards and commands. Commit the replay fence
+ // before it, along with every later effect that lacks its own receipt.
+ ops.markReplayUnsafe(voteId);
+ ops.voteParty(user, ops.forceProxyRouting(), voteId, votePartyEligible);
+ if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) {
+ boolean currentOnline = ops.userOnline(user);
+ boolean online = currentOnline;
+ if (ops.proxyVote()) online = ops.wasOnline();
+ if (!ops.userVanished(user)) {
+ SharedVoteIdentity identity = new SharedVoteIdentity(ops.userUuid(user), playerName, currentOnline);
+ ops.broadcast(identity.uuid(), identity.playerName(), ops.siteDisplayName(site), online);
+ } else {
+ ops.debug("Not broadcasting vote for vanished user: " + ops.userName(user));
+ }
+ }
+ long voteTime;
+ if (ops.incomingTime() != 0) {
+ ops.setTime(user, site, ops.incomingTime());
+ voteTime = ops.incomingTime();
} else {
- ops.debug("Not broadcasting vote for vanished user: " + ops.userName(user));
+ ops.setTimeNow(user, site);
+ voteTime = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
- }
- long voteTime;
- if (ops.incomingTime() != 0) {
- ops.setTime(user, site, ops.incomingTime());
- voteTime = ops.incomingTime();
- } else {
- ops.setTimeNow(user, site);
- voteTime = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
- }
- boolean cached = false;
- if (SharedVoteDelivery.shouldDeliverNow(ops::proxyVote, () -> ops.userOnline(user),
- () -> ops.giveOfflineRewards(site), ops::processRewards)) {
- boolean online = true;
- if (ops.proxyVote()) online = ops.wasOnline();
- ops.playerVote(user, site, online, ops.forceProxyRouting());
- if (ops.voteNumber() == 1) ops.sendVoteEffects(user, online);
- if (ops.closeInventoryOnVote()) ops.closeInventory(user);
- } else if (!ops.offlineVotesLimitEnabled() || ops.offlineVotes(user, site) <= ops.offlineVotesLimitAmount()) {
- ops.addOfflineVote(user, ops.siteKey(site));
- cached = true;
- ops.debug("Offline vote set for " + playerName + " (" + ops.userId(user) + ") on " + ops.siteKey(site));
- } else {
- ops.debug("Not setting offline vote, offline vote limit reached");
- }
- SharedVotePolicy policy = ops.countingPolicy();
- SharedVoteInput input = new SharedVoteInput(voteId, playerName, ops.serviceSite(), voteTime,
- ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline());
- SharedVoteAccounting.apply(input, policy, () -> ops.userOnline(user), () -> ops.addTotal(user),
- () -> ops.addTotalDaily(user), () -> ops.addTotalWeekly(user), () -> ops.addPoints(user));
- ops.checkDayVoteStreak(user, ops.forceProxyRouting());
- if (ops.limitMonthlyVotes() && (!ops.proxyVote() || ops.hasProxyTextTotals())) {
- int value = ops.proxyVote() ? ops.proxyMonthTotal() : ops.userMonthTotal(user);
- int days = ops.currentDayOfMonth();
- ops.extraDebug("Current day of month: " + days + " Current total: " + value);
- if (value >= days * ops.enabledSiteCount()) {
- ops.debug("Detected higher month total, changing. Current Total: " + value + " Days: " + days
- + " New Total: " + days * ops.enabledSiteCount());
- ops.setMonthTotal(user, days * ops.enabledSiteCount());
+ boolean cached = false;
+ if (SharedVoteDelivery.shouldDeliverNow(ops::proxyVote, () -> ops.userOnline(user),
+ () -> ops.giveOfflineRewards(site), ops::processRewards)) {
+ boolean online = true;
+ if (ops.proxyVote()) online = ops.wasOnline();
+ ops.playerVote(user, site, online, ops.forceProxyRouting());
+ if (ops.voteNumber() == 1) ops.sendVoteEffects(user, online);
+ if (ops.closeInventoryOnVote()) ops.closeInventory(user);
+ } else if (!ops.offlineVotesLimitEnabled() || ops.offlineVotes(user, site) <= ops.offlineVotesLimitAmount()) {
+ ops.addOfflineVote(user, ops.siteKey(site));
+ cached = true;
+ ops.debug("Offline vote set for " + playerName + " (" + ops.userId(user) + ") on " + ops.siteKey(site));
+ } else {
+ ops.debug("Not setting offline vote, offline vote limit reached");
+ }
+ SharedVoteInput input = new SharedVoteInput(voteId, playerName, ops.serviceSite(), voteTime,
+ ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline());
+ SharedVoteAccounting.applyAdmitted(countTotals, awardPoints, () -> ops.addTotal(user, voteId),
+ () -> ops.addTotalDaily(user, voteId), () -> ops.addTotalWeekly(user, voteId),
+ () -> ops.addPoints(user, voteId, admission.pointAmount(), admission.pointCap(),
+ admission.pointColumn()));
+ ops.checkDayVoteStreak(user, ops.forceProxyRouting(), voteId);
+ if (ops.limitMonthlyVotes() && (!ops.proxyVote() || ops.hasProxyTextTotals())) {
+ int value = ops.proxyVote() ? ops.proxyMonthTotal() : ops.userMonthTotal(user);
+ int days = ops.currentDayOfMonth();
+ ops.extraDebug("Current day of month: " + days + " Current total: " + value);
+ if (value >= days * ops.enabledSiteCount()) {
+ ops.debug("Detected higher month total, changing. Current Total: " + value + " Days: " + days
+ + " New Total: " + days * ops.enabledSiteCount());
+ ops.setMonthTotal(user, days * ops.enabledSiteCount());
+ }
}
+ ops.milestones(user, voteId, ops.forceProxyRouting());
+ ops.cooldown(user, site);
+ ops.voteStreak(user, voteTime, voteId);
+ ops.postVote(site, user, playerName, voteTime, voteId, cached);
+ if (ops.userOnline(user) || ops.placeholderCacheAlways()) ops.updatePlaceholders(user);
+ if (!ops.userOnline(user)) ops.clearCache(user);
+ ops.setUpdate();
+ ops.extraDebug("Finished vote processing: " + playerName + "/" + userId);
+ if (!ops.deferDeliveryCompletion()) ops.completeDelivery(voteId);
+ } finally {
+ ops.finishAccounting(voteId);
}
- ops.milestones(user, voteId, ops.forceProxyRouting());
- ops.cooldown(user, site);
- ops.voteStreak(user, voteTime, voteId);
- ops.postVote(site, user, playerName, voteTime, voteId, cached);
- if (ops.userOnline(user) || ops.placeholderCacheAlways()) ops.updatePlaceholders(user);
- if (!ops.userOnline(user)) ops.clearCache(user);
- ops.setUpdate();
- ops.extraDebug("Finished vote processing: " + playerName + "/" + userId);
}
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteReplayUnsafeException.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteReplayUnsafeException.java
new file mode 100644
index 0000000000..8efdab5bdc
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteReplayUnsafeException.java
@@ -0,0 +1,10 @@
+package com.bencodez.votingplugin.core.vote;
+
+/** Signals that a durable delivery may already have executed platform effects. */
+public final class SharedVoteReplayUnsafeException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ public SharedVoteReplayUnsafeException(String message) {
+ super(message);
+ }
+}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java
index a255b8d581..0f9b6ef2af 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java
@@ -1,35 +1,88 @@
package com.bencodez.votingplugin.data;
import java.time.temporal.WeekFields;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.Location;
-import org.bukkit.configuration.ConfigurationSection;
-
+import org.bukkit.configuration.ConfigurationSection;
+
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
+import com.bencodez.advancedcore.api.time.TimeType;
import com.bencodez.simpleapi.array.ArrayUtils;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.signs.SignHandler;
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
import com.bencodez.votingplugin.topvoter.TopVoter;
-public class ServerData {
+public class ServerData {
+ public enum TimeChangeRewardState {
+ UNCLAIMED, CLAIMED, COMPLETE
+ }
+
+ public record TimeChangeUserProgress(String uuid, int streakTarget, boolean rewardRequired,
+ boolean rewardComplete) { }
+ public record TimeChangeUserPolicy(boolean voteStreaks, boolean highestTotals,
+ boolean monthDateTotalsPrimary, boolean streakUsesPercentage,
+ double dayPercentage, double weekPercentage, double monthPercentage,
+ boolean proxyOwnsResets, boolean waitForProxy, boolean rewardForceProxy,
+ int enabledSiteCount) {
+ public TimeChangeUserPolicy(boolean voteStreaks, boolean highestTotals,
+ boolean monthDateTotalsPrimary, boolean streakUsesPercentage,
+ double dayPercentage, double weekPercentage, double monthPercentage,
+ boolean proxyOwnsResets, boolean waitForProxy, boolean rewardForceProxy) {
+ this(voteStreaks, highestTotals, monthDateTotalsPrimary, streakUsesPercentage,
+ dayPercentage, weekPercentage, monthPercentage, proxyOwnsResets,
+ waitForProxy, rewardForceProxy, 0);
+ }
+ }
+ public record TimeChangeTopPolicy(boolean rewardsEnabled, boolean awardTies,
+ boolean ignorePermission, boolean archiveRequired, List rewardPlaces,
+ List blacklistedPlayers) {
+ public TimeChangeTopPolicy {
+ rewardPlaces = List.copyOf(rewardPlaces);
+ blacklistedPlayers = List.copyOf(blacklistedPlayers);
+ }
+ }
+ public record TimeChangeRewardTarget(String uuid, String playerName, int place, String reward, int votes) { }
+ public record TimeChangeArchiveSection(String name, List lines) {
+ public TimeChangeArchiveSection {
+ lines = List.copyOf(lines);
+ }
+ }
+ public record TimeChangeArchiveSnapshot(List sections) {
+ public TimeChangeArchiveSnapshot {
+ sections = List.copyOf(sections);
+ }
+ }
+
+ private static final String TIME_CHANGE_RECOVERY = "TimeChangeRecovery";
+ private static final String VOTE_PARTY_ACCOUNTING = "VoteParty.Accounting";
+ private static final String VOTE_REPLAY_UNSAFE = "VoteDelivery.ReplayUnsafe";
+ private static final List TIME_CHANGE_PHASES = List.of("START", "SNAPSHOT", "COPY_TOTALS",
+ "USER_UPDATES", "TOP_REWARDS", "VOTE_SHOP", "BUNGEE_WAIT", "TOTALS_RESET", "CACHE_CLEAR",
+ "POST_DATE", "COMPLETE");
- private VotingPluginMain plugin = VotingPluginMain.plugin;
+ private VotingPluginMain plugin = VotingPluginMain.plugin;
+ private final TimeChangeUserCheckpointStore timeChangeUsers;
/**
* Constructs a new ServerData.
*
* @param plugin the main plugin instance
*/
- public ServerData(VotingPluginMain plugin) {
- this.plugin = plugin;
- }
+ public ServerData(VotingPluginMain plugin) {
+ this.plugin = plugin;
+ timeChangeUsers = new TimeChangeUserCheckpointStore(
+ plugin == null || plugin.getDataFolder() == null ? null : plugin.getDataFolder().toPath());
+ }
/**
* Adds an auto-cached placeholder.
@@ -86,12 +139,107 @@ public void addSign(Location location, String data, int position) {
* @param num the vote number
* @param vote the vote time queue entry
*/
- public void addTimeVoted(int num, VoteTimeQueue vote) {
- getData().set("TimedVoteCache." + num + ".Name", vote.getName());
- getData().set("TimedVoteCache." + num + ".Service", vote.getService());
- getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
- saveData();
- }
+ public void addTimeVoted(int num, VoteTimeQueue vote) {
+ getData().set("TimedVoteCache." + num + ".Name", vote.getName());
+ getData().set("TimedVoteCache." + num + ".Service", vote.getService());
+ getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
+ getData().set("TimedVoteCache." + num + ".VoteId",
+ vote.getVoteId() == null ? null : vote.getVoteId().toString());
+ saveData();
+ }
+
+ /** Replaces the durable timed-vote snapshot with one ordered in-memory queue. */
+ public synchronized void replaceTimedVoteCache(List votes) {
+ replaceTimedVoteState(votes, getTimedVoteRetirements());
+ }
+
+ /** Atomically replaces pending timed votes and delivery retirements. */
+ public synchronized void replaceTimedVoteState(List votes, List retirements) {
+ Map previousVotes = snapshotSection("TimedVoteCache");
+ Object previousRetirements = getData().get("TimedVoteRetirements");
+ try {
+ getData().set("TimedVoteCache", null);
+ int index = 0;
+ for (VoteTimeQueue vote : votes) {
+ String path = "TimedVoteCache." + index++;
+ getData().set(path + ".Name", vote.getName());
+ getData().set(path + ".Service", vote.getService());
+ getData().set(path + ".Time", vote.getTime());
+ getData().set(path + ".VoteId", vote.getVoteId() == null ? null : vote.getVoteId().toString());
+ }
+ getData().set("TimedVoteRetirements", retirements.stream().map(UUID::toString).toList());
+ saveData();
+ } catch (RuntimeException failure) {
+ restoreSection("TimedVoteCache", previousVotes);
+ getData().set("TimedVoteRetirements", previousRetirements);
+ throw failure;
+ }
+ }
+
+ /** Retains an ambiguously processed timed vote without admitting it to replay. */
+ public synchronized void quarantineTimedVote(VoteTimeQueue vote) {
+ String key = vote.getVoteId() == null ? vote.legacyTimedVoteId().toString() : vote.getVoteId().toString();
+ String path = "TimedVoteQuarantine." + key;
+ getData().set(path + ".Name", vote.getName());
+ getData().set(path + ".Service", vote.getService());
+ getData().set(path + ".Time", vote.getTime());
+ getData().set(path + ".VoteId", key);
+ saveData();
+ }
+
+ /** Persists that a delivery crossed into effects which cannot safely be replayed. */
+ public synchronized void markVoteReplayUnsafe(UUID voteId) {
+ String path = VOTE_REPLAY_UNSAFE + "." + voteId;
+ boolean existed = getData().contains(path);
+ Object previous = getData().get(path);
+ try {
+ getData().set(path, true);
+ saveData();
+ } catch (RuntimeException failure) {
+ getData().set(path, existed ? previous : null);
+ ConfigurationSection remaining = getData().getConfigurationSection(VOTE_REPLAY_UNSAFE);
+ if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_REPLAY_UNSAFE, null);
+ throw failure;
+ }
+ }
+
+ private Map snapshotSection(String path) {
+ ConfigurationSection section = getData().getConfigurationSection(path);
+ if (section == null) return null;
+ Map snapshot = new HashMap<>();
+ for (Map.Entry entry : section.getValues(true).entrySet()) {
+ if (!(entry.getValue() instanceof ConfigurationSection)) snapshot.put(entry.getKey(), entry.getValue());
+ }
+ return snapshot;
+ }
+
+ private void restoreSection(String path, Map snapshot) {
+ getData().set(path, null);
+ if (snapshot == null) return;
+ for (Map.Entry entry : snapshot.entrySet()) {
+ getData().set(path + "." + entry.getKey(), entry.getValue());
+ }
+ }
+
+ public synchronized boolean isVoteReplayUnsafe(UUID voteId) {
+ return voteId != null && getData().getBoolean(VOTE_REPLAY_UNSAFE + "." + voteId);
+ }
+
+ /** Removes a replay fence after the delivery owner has durably retired the vote. */
+ public synchronized void clearVoteReplayUnsafe(UUID voteId) {
+ if (voteId == null) return;
+ if (!getData().contains(VOTE_REPLAY_UNSAFE + "." + voteId)) return;
+ Map previous = snapshotSection(VOTE_REPLAY_UNSAFE);
+ try {
+ getData().set(VOTE_REPLAY_UNSAFE + "." + voteId, null);
+ ConfigurationSection remaining = getData().getConfigurationSection(VOTE_REPLAY_UNSAFE);
+ if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_REPLAY_UNSAFE, null);
+ saveData();
+ } catch (RuntimeException | Error failure) {
+ restoreSection(VOTE_REPLAY_UNSAFE, previous);
+ throw failure;
+ }
+ }
/**
* Adds a vote shop purchase for the specified identifier.
@@ -259,9 +407,22 @@ public Set getTimedVoteCacheKeys() {
* @param num the cache entry number
* @return the timed vote cache section
*/
- public ConfigurationSection getTimedVoteCacheSection(String num) {
- return getData().getConfigurationSection("TimedVoteCache." + num);
- }
+ public ConfigurationSection getTimedVoteCacheSection(String num) {
+ return getData().getConfigurationSection("TimedVoteCache." + num);
+ }
+
+ /** Returns valid durable retirement identifiers for completed timed votes. */
+ public synchronized List getTimedVoteRetirements() {
+ List retirements = new ArrayList<>();
+ for (String value : getData().getStringList("TimedVoteRetirements")) {
+ try {
+ retirements.add(UUID.fromString(value));
+ } catch (IllegalArgumentException ignored) {
+ plugin.getLogger().warning("Ignoring invalid timed-vote retirement identifier");
+ }
+ }
+ return List.copyOf(retirements);
+ }
/**
* Gets the extra required votes for vote party.
@@ -387,9 +548,696 @@ public void removeSign(String sign) {
/**
* Save data.
*/
- public synchronized void saveData() {
- plugin.getServerDataFile().saveData();
- }
+ public synchronized void saveData() {
+ plugin.getServerDataFile().saveData();
+ }
+
+ /** Atomically records and applies one local VoteParty total increment. */
+ public synchronized boolean incrementVotePartyTotal(UUID voteId) {
+ if (voteId == null) {
+ getData().set("VoteParty.Total", getData().getInt("VoteParty.Total") + 1);
+ saveData();
+ return true;
+ }
+ String receiptPath = VOTE_PARTY_ACCOUNTING + "." + voteId;
+ if (getData().contains(receiptPath)) return false;
+ int previousTotal = getData().getInt("VoteParty.Total");
+ long now = System.currentTimeMillis();
+ try {
+ getData().set("VoteParty.Total", previousTotal + 1);
+ getData().set(receiptPath, now);
+ saveData();
+ return true;
+ } catch (RuntimeException | Error failure) {
+ getData().set("VoteParty.Total", previousTotal);
+ getData().set(receiptPath, null);
+ throw failure;
+ }
+ }
+
+ /** Retires a VoteParty replay receipt after its durable delivery owner completes. */
+ public synchronized void clearVotePartyAccounting(UUID voteId) {
+ if (voteId == null) return;
+ String receiptPath = VOTE_PARTY_ACCOUNTING + "." + voteId;
+ if (!getData().contains(receiptPath)) return;
+ Map previous = snapshotSection(VOTE_PARTY_ACCOUNTING);
+ try {
+ getData().set(receiptPath, null);
+ ConfigurationSection remaining = getData().getConfigurationSection(VOTE_PARTY_ACCOUNTING);
+ if (remaining != null && remaining.getKeys(false).isEmpty()) getData().set(VOTE_PARTY_ACCOUNTING, null);
+ saveData();
+ } catch (RuntimeException | Error failure) {
+ restoreSection(VOTE_PARTY_ACCOUNTING, previous);
+ throw failure;
+ }
+ }
+
+ /**
+ * Starts or resumes the compact local checkpoint for a durable core time
+ * transition. Only the current transition for each time type is retained;
+ * per-recipient reward receipts are bounded by the top-voter recipient list.
+ *
+ * @param transition the core-owned durable transition
+ */
+ public synchronized void beginTimeChangeRecovery(TimeChangeTransition transition) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ boolean sameTransition = transition.getId().equals(getData().getString(path + ".Id", ""));
+ if (timeChangeUsers.isFileBacked()) {
+ TimeChangeUserProgress legacyUser = null;
+ TimeChangeRewardState legacyReward = TimeChangeRewardState.UNCLAIMED;
+ if (sameTransition) {
+ String userPath = path + ".CurrentUser";
+ String uuid = getData().getString(userPath + ".Uuid", "");
+ if (!uuid.isEmpty()) {
+ boolean complete = getData().getBoolean(userPath + ".RewardComplete", false);
+ legacyUser = new TimeChangeUserProgress(uuid,
+ getData().getInt(userPath + ".StreakTarget"),
+ getData().getBoolean(userPath + ".RewardRequired", false), complete);
+ legacyReward = complete ? TimeChangeRewardState.COMPLETE
+ : getData().getBoolean(userPath + ".RewardClaimed", false)
+ ? TimeChangeRewardState.CLAIMED : TimeChangeRewardState.UNCLAIMED;
+ }
+ }
+ timeChangeUsers.begin(transition,
+ sameTransition ? getData().getString(path + ".Cursor", "") : "", legacyUser, legacyReward);
+ }
+ if (sameTransition) return;
+ getData().set(path, null);
+ getData().set(path + ".Id", transition.getId());
+ getData().set(path + ".Period", transition.getPeriodKey());
+ getData().set(path + ".Phase", "START");
+ getData().set(path + ".Cursor", "");
+ saveData();
+ }
+
+ /** Returns whether this exact transition already owns durable recovery state. */
+ public synchronized boolean isTimeChangeRecoveryActive(TimeChangeTransition transition) {
+ if (transition == null) return false;
+ return transition.getId().equals(getData().getString(timeChangeRecoveryPath(transition.getType()) + ".Id", ""));
+ }
+
+ /** Fixes per-user period processing policy for the lifetime of one transition. */
+ public synchronized TimeChangeUserPolicy prepareTimeChangeUserPolicy(TimeChangeTransition transition,
+ TimeChangeUserPolicy proposed) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String policyPath = path + ".UserPolicy";
+ if (!getData().getBoolean(policyPath + ".Prepared", false)) {
+ getData().set(policyPath + ".VoteStreaks", proposed.voteStreaks());
+ getData().set(policyPath + ".HighestTotals", proposed.highestTotals());
+ getData().set(policyPath + ".MonthDateTotalsPrimary", proposed.monthDateTotalsPrimary());
+ getData().set(policyPath + ".StreakUsesPercentage", proposed.streakUsesPercentage());
+ getData().set(policyPath + ".DayPercentage", proposed.dayPercentage());
+ getData().set(policyPath + ".WeekPercentage", proposed.weekPercentage());
+ getData().set(policyPath + ".MonthPercentage", proposed.monthPercentage());
+ getData().set(policyPath + ".EnabledSiteCount", proposed.enabledSiteCount());
+ getData().set(policyPath + ".ProxyOwnsResets", proposed.proxyOwnsResets());
+ getData().set(policyPath + ".WaitForProxy", proposed.waitForProxy());
+ getData().set(policyPath + ".RewardForceProxy", proposed.rewardForceProxy());
+ getData().set(policyPath + ".Prepared", true);
+ try {
+ saveData();
+ } catch (RuntimeException failure) {
+ getData().set(policyPath, null);
+ throw failure;
+ }
+ }
+ return getTimeChangeUserPolicy(transition);
+ }
+
+ /** Returns the per-user policy captured when recovery began. */
+ public synchronized TimeChangeUserPolicy getTimeChangeUserPolicy(TimeChangeTransition transition) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ String policyPath = path + ".UserPolicy";
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))
+ || !getData().getBoolean(policyPath + ".Prepared", false)) {
+ throw new IllegalStateException("Time change user policy is not prepared");
+ }
+ return new TimeChangeUserPolicy(getData().getBoolean(policyPath + ".VoteStreaks"),
+ getData().getBoolean(policyPath + ".HighestTotals"),
+ getData().getBoolean(policyPath + ".MonthDateTotalsPrimary"),
+ getData().getBoolean(policyPath + ".StreakUsesPercentage"),
+ getData().getDouble(policyPath + ".DayPercentage"),
+ getData().getDouble(policyPath + ".WeekPercentage"),
+ getData().getDouble(policyPath + ".MonthPercentage"),
+ getData().getBoolean(policyPath + ".ProxyOwnsResets"),
+ getData().getBoolean(policyPath + ".WaitForProxy"),
+ getData().getBoolean(policyPath + ".RewardForceProxy"),
+ getData().getInt(policyPath + ".EnabledSiteCount"));
+ }
+
+ /** Fixes the VoteShop identifiers selected for this transition before resets begin. */
+ public synchronized List prepareTimeChangeVoteShopTargets(TimeChangeTransition transition,
+ List proposed) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String targetsPath = path + ".VoteShopTargets";
+ if (!getData().getBoolean(targetsPath + ".Prepared", false)) {
+ getData().set(targetsPath + ".Identifiers", List.copyOf(proposed));
+ getData().set(targetsPath + ".Prepared", true);
+ try {
+ saveData();
+ } catch (RuntimeException failure) {
+ getData().set(targetsPath, null);
+ throw failure;
+ }
+ }
+ return List.copyOf(getData().getStringList(targetsPath + ".Identifiers"));
+ }
+
+ public synchronized List getTimeChangeVoteShopTargets(TimeChangeTransition transition) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ String targetsPath = path + ".VoteShopTargets";
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))
+ || !getData().getBoolean(targetsPath + ".Prepared", false)) {
+ throw new IllegalStateException("Time change VoteShop targets are not prepared");
+ }
+ return List.copyOf(getData().getStringList(targetsPath + ".Identifiers"));
+ }
+
+ /** Fixes top reward and archive selection inputs before the period boundary is copied. */
+ public synchronized TimeChangeTopPolicy prepareTimeChangeTopPolicy(TimeChangeTransition transition,
+ TimeChangeTopPolicy proposed) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String policyPath = path + ".TopPolicy";
+ if (!getData().getBoolean(policyPath + ".Prepared", false)) {
+ getData().set(policyPath + ".RewardsEnabled", proposed.rewardsEnabled());
+ getData().set(policyPath + ".AwardTies", proposed.awardTies());
+ getData().set(policyPath + ".IgnorePermission", proposed.ignorePermission());
+ getData().set(policyPath + ".ArchiveRequired", proposed.archiveRequired());
+ getData().set(policyPath + ".RewardPlaces", proposed.rewardPlaces());
+ getData().set(policyPath + ".BlacklistedPlayers", proposed.blacklistedPlayers());
+ getData().set(policyPath + ".Prepared", true);
+ try {
+ saveData();
+ } catch (RuntimeException failure) {
+ getData().set(policyPath, null);
+ throw failure;
+ }
+ }
+ return getTimeChangeTopPolicy(transition);
+ }
+
+ public synchronized TimeChangeTopPolicy getTimeChangeTopPolicy(TimeChangeTransition transition) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ String policyPath = path + ".TopPolicy";
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))
+ || !getData().getBoolean(policyPath + ".Prepared", false)) {
+ throw new IllegalStateException("Time change top policy is not prepared");
+ }
+ return new TimeChangeTopPolicy(getData().getBoolean(policyPath + ".RewardsEnabled"),
+ getData().getBoolean(policyPath + ".AwardTies"),
+ getData().getBoolean(policyPath + ".IgnorePermission"),
+ getData().getBoolean(policyPath + ".ArchiveRequired"),
+ getData().getStringList(policyPath + ".RewardPlaces"),
+ getData().getStringList(policyPath + ".BlacklistedPlayers"));
+ }
+
+ /** Fixes whether one listener effect belongs to this transition. */
+ public synchronized boolean prepareTimeChangeEffectPolicy(TimeChangeTransition transition, String effect,
+ boolean proposed) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String policyPath = path + ".EffectPolicies." + effect;
+ if (!getData().contains(policyPath)) {
+ getData().set(policyPath, proposed);
+ try {
+ saveData();
+ } catch (RuntimeException failure) {
+ getData().set(policyPath, null);
+ throw failure;
+ }
+ }
+ return getData().getBoolean(policyPath);
+ }
+
+ /** Returns whether the named phase has been durably completed. */
+ public synchronized boolean hasTimeChangePhase(TimeChangeTransition transition, String phase) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) return false;
+ int requested = TIME_CHANGE_PHASES.indexOf(phase);
+ int completed = TIME_CHANGE_PHASES.indexOf(getData().getString(path + ".Phase", "START"));
+ return requested >= 0 && completed >= requested;
+ }
+
+ /** Records the next completed recovery phase synchronously. */
+ public synchronized void completeTimeChangePhase(TimeChangeTransition transition, String phase) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ getData().set(path + ".Phase", phase);
+ saveData();
+ }
+
+ /** Returns the last durably completed UUID in sorted user processing. */
+ public synchronized String getTimeChangeCursor(TimeChangeTransition transition) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) return "";
+ if (timeChangeUsers.isFileBacked()) return timeChangeUsers.cursor(transition);
+ return getData().getString(path + ".Cursor", "");
+ }
+
+ /** Advances the compact sorted-user cursor after that user's work is done. */
+ public synchronized void completeTimeChangeUser(TimeChangeTransition transition, String uuid) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ if (timeChangeUsers.isFileBacked()) {
+ timeChangeUsers.completeUser(transition, uuid);
+ return;
+ }
+ getData().set(path + ".Cursor", uuid);
+ getData().set(path + ".CurrentUser", null);
+ saveData();
+ }
+
+ /**
+ * Persists the absolute streak target before changing a user. Only one user is
+ * in flight because the period walk is ordered and synchronous.
+ */
+ public synchronized TimeChangeUserProgress prepareTimeChangeUserStreak(TimeChangeTransition transition,
+ String uuid, int streakTarget, boolean rewardRequired) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ if (timeChangeUsers.isFileBacked()) {
+ return timeChangeUsers.prepareStreak(transition, uuid, streakTarget, rewardRequired);
+ }
+ String userPath = path + ".CurrentUser";
+ if (!uuid.equals(getData().getString(userPath + ".Uuid", ""))) {
+ getData().set(userPath, null);
+ getData().set(userPath + ".Uuid", uuid);
+ getData().set(userPath + ".StreakTarget", streakTarget);
+ getData().set(userPath + ".RewardRequired", rewardRequired);
+ getData().set(userPath + ".RewardClaimed", false);
+ getData().set(userPath + ".RewardComplete", false);
+ saveData();
+ }
+ return new TimeChangeUserProgress(uuid, getData().getInt(userPath + ".StreakTarget"),
+ getData().getBoolean(userPath + ".RewardRequired", false),
+ getData().getBoolean(userPath + ".RewardComplete", false));
+ }
+
+ public synchronized TimeChangeRewardState getTimeChangeUserStreakRewardState(
+ TimeChangeTransition transition, String uuid) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ if (timeChangeUsers.isFileBacked()) return timeChangeUsers.rewardState(transition, uuid);
+ String userPath = path + ".CurrentUser";
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))
+ || !uuid.equals(getData().getString(userPath + ".Uuid", ""))) {
+ throw new IllegalStateException("Time change recovery user does not match");
+ }
+ if (getData().getBoolean(userPath + ".RewardComplete", false)) return TimeChangeRewardState.COMPLETE;
+ return getData().getBoolean(userPath + ".RewardClaimed", false)
+ ? TimeChangeRewardState.CLAIMED : TimeChangeRewardState.UNCLAIMED;
+ }
+
+ /** Durably claims the in-flight user's streak reward before invoking it. */
+ public synchronized void claimTimeChangeUserStreakReward(TimeChangeTransition transition, String uuid) {
+ if (getTimeChangeUserStreakRewardState(transition, uuid) != TimeChangeRewardState.UNCLAIMED) {
+ throw new IllegalStateException("Time change streak reward is already claimed");
+ }
+ if (timeChangeUsers.isFileBacked()) {
+ timeChangeUsers.setRewardState(transition, uuid, TimeChangeRewardState.CLAIMED);
+ return;
+ }
+ String userPath = timeChangeRecoveryPath(transition.getType()) + ".CurrentUser";
+ getData().set(userPath + ".RewardClaimed", true);
+ try {
+ saveData();
+ } catch (RuntimeException failure) {
+ getData().set(userPath + ".RewardClaimed", false);
+ throw failure;
+ }
+ }
+
+ /** Marks the in-flight user's streak reward call as returned successfully. */
+ public synchronized void completeTimeChangeUserStreakReward(TimeChangeTransition transition, String uuid) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ if (timeChangeUsers.isFileBacked()) {
+ if (timeChangeUsers.rewardState(transition, uuid) != TimeChangeRewardState.CLAIMED) {
+ throw new IllegalStateException("Time change streak reward is not claimed");
+ }
+ timeChangeUsers.setRewardState(transition, uuid, TimeChangeRewardState.COMPLETE);
+ return;
+ }
+ String userPath = path + ".CurrentUser";
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))
+ || !uuid.equals(getData().getString(userPath + ".Uuid", ""))) {
+ throw new IllegalStateException("Time change recovery user does not match");
+ }
+ getData().set(userPath + ".RewardComplete", true);
+ try {
+ saveData();
+ } catch (RuntimeException failure) {
+ getData().set(userPath + ".RewardComplete", false);
+ throw failure;
+ }
+ }
+
+ /**
+ * Persists the ranked recipients and reward assignments selected at the period
+ * boundary. Retries always return this first durable selection.
+ */
+ public synchronized List prepareTimeChangeRewardTargets(
+ TimeChangeTransition transition, List proposed) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String targetsPath = path + ".RewardTargets";
+ if (getData().getBoolean(targetsPath + ".Prepared", false)) {
+ return getTimeChangeRewardTargets(transition);
+ }
+ List snapshot = List.copyOf(proposed);
+ for (TimeChangeRewardTarget target : snapshot) {
+ validateRewardTarget(target);
+ }
+ getData().set(targetsPath, null);
+ getData().set(targetsPath + ".Count", snapshot.size());
+ for (int index = 0; index < snapshot.size(); index++) {
+ TimeChangeRewardTarget target = snapshot.get(index);
+ String targetPath = targetsPath + ".Entries." + index;
+ getData().set(targetPath + ".Uuid", target.uuid());
+ getData().set(targetPath + ".PlayerName", target.playerName());
+ getData().set(targetPath + ".Place", target.place());
+ getData().set(targetPath + ".Reward", target.reward());
+ getData().set(targetPath + ".Votes", target.votes());
+ }
+ getData().set(targetsPath + ".Prepared", true);
+ try {
+ saveData();
+ } catch (RuntimeException | Error failure) {
+ getData().set(targetsPath, null);
+ throw failure;
+ }
+ return snapshot;
+ }
+
+ /** Returns the durable ranked reward selection for this transition. */
+ public synchronized List getTimeChangeRewardTargets(TimeChangeTransition transition) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ String targetsPath = path + ".RewardTargets";
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))
+ || !getData().getBoolean(targetsPath + ".Prepared", false)) {
+ return List.of();
+ }
+ int count = getData().getInt(targetsPath + ".Count", -1);
+ if (count < 0) {
+ throw new IllegalStateException("Invalid time change reward target count");
+ }
+ List targets = new ArrayList<>(count);
+ for (int index = 0; index < count; index++) {
+ String targetPath = targetsPath + ".Entries." + index;
+ TimeChangeRewardTarget target = new TimeChangeRewardTarget(
+ getData().getString(targetPath + ".Uuid", ""),
+ getData().getString(targetPath + ".PlayerName", ""),
+ getData().getInt(targetPath + ".Place", 0),
+ getData().getString(targetPath + ".Reward", ""),
+ getData().getInt(targetPath + ".Votes", -1));
+ validateRewardTarget(target);
+ targets.add(target);
+ }
+ return List.copyOf(targets);
+ }
+
+ private void validateRewardTarget(TimeChangeRewardTarget target) {
+ if (target == null || target.playerName() == null || target.place() <= 0 || target.votes() < 0
+ || target.reward() == null || target.reward().isEmpty()) {
+ throw new IllegalStateException("Invalid time change reward target");
+ }
+ try {
+ UUID.fromString(target.uuid());
+ } catch (RuntimeException invalidUuid) {
+ throw new IllegalStateException("Invalid time change reward target UUID", invalidUuid);
+ }
+ }
+
+ /** Persists the reward and archive boundary snapshots in one checkpoint. */
+ public synchronized void prepareTimeChangeSnapshot(TimeChangeTransition transition,
+ List targets, TimeChangeArchiveSnapshot archive) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String snapshotPath = path + ".BoundarySnapshot";
+ if (getData().getBoolean(snapshotPath + ".Prepared", false)) return;
+ List targetSnapshot = List.copyOf(targets);
+ for (TimeChangeRewardTarget target : targetSnapshot) validateRewardTarget(target);
+ validateArchive(archive);
+ String targetsPath = path + ".RewardTargets";
+ String archivePath = path + ".Archive";
+ getData().set(targetsPath, null);
+ getData().set(targetsPath + ".Count", targetSnapshot.size());
+ for (int index = 0; index < targetSnapshot.size(); index++) {
+ TimeChangeRewardTarget target = targetSnapshot.get(index);
+ String targetPath = targetsPath + ".Entries." + index;
+ getData().set(targetPath + ".Uuid", target.uuid());
+ getData().set(targetPath + ".PlayerName", target.playerName());
+ getData().set(targetPath + ".Place", target.place());
+ getData().set(targetPath + ".Reward", target.reward());
+ getData().set(targetPath + ".Votes", target.votes());
+ }
+ getData().set(targetsPath + ".Prepared", true);
+ getData().set(archivePath, null);
+ getData().set(archivePath + ".Count", archive.sections().size());
+ for (int index = 0; index < archive.sections().size(); index++) {
+ TimeChangeArchiveSection section = archive.sections().get(index);
+ String sectionPath = archivePath + ".Sections." + index;
+ getData().set(sectionPath + ".Name", section.name());
+ getData().set(sectionPath + ".Lines", section.lines());
+ }
+ getData().set(archivePath + ".Prepared", true);
+ getData().set(snapshotPath + ".Prepared", true);
+ try {
+ saveData();
+ } catch (RuntimeException | Error failure) {
+ getData().set(targetsPath, null);
+ getData().set(archivePath, null);
+ getData().set(snapshotPath, null);
+ throw failure;
+ }
+ }
+
+ /** Persists the complete top-voter archive contents selected at the period boundary. */
+ public synchronized TimeChangeArchiveSnapshot prepareTimeChangeArchive(TimeChangeTransition transition,
+ TimeChangeArchiveSnapshot proposed) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String archivePath = path + ".Archive";
+ if (getData().getBoolean(archivePath + ".Prepared", false)) return getTimeChangeArchive(transition);
+ validateArchive(proposed);
+ getData().set(archivePath, null);
+ getData().set(archivePath + ".Count", proposed.sections().size());
+ for (int index = 0; index < proposed.sections().size(); index++) {
+ TimeChangeArchiveSection section = proposed.sections().get(index);
+ String sectionPath = archivePath + ".Sections." + index;
+ getData().set(sectionPath + ".Name", section.name());
+ getData().set(sectionPath + ".Lines", section.lines());
+ }
+ getData().set(archivePath + ".Prepared", true);
+ try {
+ saveData();
+ } catch (RuntimeException | Error failure) {
+ getData().set(archivePath, null);
+ throw failure;
+ }
+ return proposed;
+ }
+
+ /** Returns the durable top-voter archive contents for this transition. */
+ public synchronized TimeChangeArchiveSnapshot getTimeChangeArchive(TimeChangeTransition transition) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ String archivePath = path + ".Archive";
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))
+ || !getData().getBoolean(archivePath + ".Prepared", false)) {
+ return new TimeChangeArchiveSnapshot(List.of());
+ }
+ int count = getData().getInt(archivePath + ".Count", -1);
+ if (count < 0) throw new IllegalStateException("Invalid time change archive section count");
+ List sections = new ArrayList<>(count);
+ for (int index = 0; index < count; index++) {
+ String sectionPath = archivePath + ".Sections." + index;
+ sections.add(new TimeChangeArchiveSection(getData().getString(sectionPath + ".Name", ""),
+ getData().getStringList(sectionPath + ".Lines")));
+ }
+ TimeChangeArchiveSnapshot snapshot = new TimeChangeArchiveSnapshot(sections);
+ validateArchive(snapshot);
+ return snapshot;
+ }
+
+ private void validateArchive(TimeChangeArchiveSnapshot snapshot) {
+ if (snapshot == null) throw new IllegalStateException("Invalid time change archive");
+ Set names = new HashSet<>();
+ for (TimeChangeArchiveSection section : snapshot.sections()) {
+ if (section == null || section.name() == null || section.name().isEmpty() || section.lines() == null
+ || !names.add(section.name()) || section.lines().stream().anyMatch(line -> line == null)) {
+ throw new IllegalStateException("Invalid time change archive section");
+ }
+ }
+ }
+
+ /** Returns the durable delivery state for one top-voter reward recipient. */
+ public synchronized TimeChangeRewardState getTimeChangeRewardState(TimeChangeTransition transition,
+ String uuid) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ return TimeChangeRewardState.UNCLAIMED;
+ }
+ if (getData().getBoolean(path + ".Rewards." + uuid, false)) return TimeChangeRewardState.COMPLETE;
+ String stored = getData().getString(path + ".RewardStates." + uuid, "");
+ try {
+ return stored.isEmpty() ? TimeChangeRewardState.UNCLAIMED : TimeChangeRewardState.valueOf(stored);
+ } catch (IllegalArgumentException invalid) {
+ throw new IllegalStateException("Invalid time change reward state for " + uuid, invalid);
+ }
+ }
+
+ /** Checks the durable receipt for one top-voter reward recipient. */
+ public synchronized boolean hasTimeChangeRewardReceipt(TimeChangeTransition transition, String uuid) {
+ return getTimeChangeRewardState(transition, uuid) == TimeChangeRewardState.COMPLETE;
+ }
+
+ /** Durably claims a top-voter reward before invoking its existing reward API. */
+ public synchronized void claimTimeChangeReward(TimeChangeTransition transition, String uuid) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ if (getTimeChangeRewardState(transition, uuid) != TimeChangeRewardState.UNCLAIMED) {
+ throw new IllegalStateException("Time change reward is already claimed for " + uuid);
+ }
+ String statePath = path + ".RewardStates." + uuid;
+ getData().set(statePath, TimeChangeRewardState.CLAIMED.name());
+ try {
+ saveData();
+ } catch (RuntimeException failure) {
+ getData().set(statePath, null);
+ throw failure;
+ }
+ }
+
+ /** Persists a recipient receipt only after the existing reward API returns. */
+ public synchronized void completeTimeChangeReward(TimeChangeTransition transition, String uuid) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String receiptPath = path + ".Rewards." + uuid;
+ String statePath = path + ".RewardStates." + uuid;
+ Object previousReceipt = getData().get(receiptPath);
+ String previousState = getData().getString(statePath, "");
+ getData().set(receiptPath, true);
+ getData().set(statePath, TimeChangeRewardState.COMPLETE.name());
+ try {
+ saveData();
+ } catch (RuntimeException failure) {
+ getData().set(receiptPath, previousReceipt);
+ getData().set(statePath, previousState.isEmpty() ? null : previousState);
+ throw failure;
+ }
+ }
+
+ /** Records idempotent non-reward listener effects such as VoteParty resets. */
+ public synchronized boolean hasTimeChangeEffect(TimeChangeTransition transition, String effect) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ return transition.getId().equals(getData().getString(path + ".Id", ""))
+ && getData().getBoolean(path + ".Effects." + effect, false);
+ }
+
+ /** Marks a completed non-reward listener effect. */
+ public synchronized void completeTimeChangeEffect(TimeChangeTransition transition, String effect) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ getData().set(path + ".Effects." + effect, true);
+ saveData();
+ }
+
+ /**
+ * Clears YAML-backed VoteParty state before the recoverable database reset.
+ * The in-memory marker deliberately remains applied when saving fails. A later
+ * vote will therefore persist the cleared boundary and its own new state
+ * together, while a restart can safely retry the clear before votes resume.
+ */
+ public synchronized void prepareTimeChangeVotePartyReset(TimeChangeTransition transition, String effect) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String resetPath = path + ".VotePartyResets." + effect + ".StateReset";
+ if (getData().getBoolean(resetPath, false)) return;
+ getData().set("VoteParty.Total", 0);
+ getData().set("VoteParty.Voted", new ArrayList<>());
+ getData().set(resetPath, true);
+ saveData();
+ }
+
+ /** Records the VoteParty reset receipt after its database boundary is removed. */
+ public synchronized void completeTimeChangeVotePartyReset(TimeChangeTransition transition, String effect) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ String resetPath = path + ".VotePartyResets." + effect + ".StateReset";
+ if (!getData().getBoolean(resetPath, false)) {
+ throw new IllegalStateException("VoteParty state reset is not prepared");
+ }
+ String effectPath = path + ".Effects." + effect;
+ Object previousEffect = getData().get(effectPath);
+ try {
+ getData().set(effectPath, true);
+ saveData();
+ } catch (RuntimeException | Error failure) {
+ getData().set(effectPath, previousEffect);
+ throw failure;
+ }
+ }
+
+ /** Resets VoteParty's extra requirement and records its receipt in one save. */
+ public synchronized void completeTimeChangeVotePartyExtraReset(TimeChangeTransition transition, String effect) {
+ String path = timeChangeRecoveryPath(transition.getType());
+ if (!transition.getId().equals(getData().getString(path + ".Id", ""))) {
+ throw new IllegalStateException("Time change recovery transition does not match");
+ }
+ int previousExtra = getData().getInt("VotePartyExtraRequired");
+ String effectPath = path + ".Effects." + effect;
+ Object previousEffect = getData().get(effectPath);
+ try {
+ getData().set("VotePartyExtraRequired", 0);
+ getData().set(effectPath, true);
+ saveData();
+ } catch (RuntimeException | Error failure) {
+ getData().set("VotePartyExtraRequired", previousExtra);
+ getData().set(effectPath, previousEffect);
+ throw failure;
+ }
+ }
+
+ private String timeChangeRecoveryPath(TimeType type) {
+ return TIME_CHANGE_RECOVERY + "." + type.name();
+ }
/**
* Saves the disabled reminders list.
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java
new file mode 100644
index 0000000000..103a4188ca
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/TimeChangeUserCheckpointStore.java
@@ -0,0 +1,205 @@
+package com.bencodez.votingplugin.data;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.Properties;
+
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
+import com.bencodez.advancedcore.api.time.TimeType;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress;
+import com.bencodez.votingplugin.util.DurableFiles;
+
+/**
+ * Compact durable state for the per-user portion of period transitions.
+ *
+ * The general server YAML can contain large top-voter archives. Keeping the
+ * hot cursor and one in-flight streak receipt here makes each user commit
+ * constant-sized while retaining the existing write-before-next-user ordering.
+ */
+final class TimeChangeUserCheckpointStore {
+ private static final String FILE_NAME = "TimeChangeUserCheckpoint.properties";
+
+ private final Path file;
+ private final Map states = new EnumMap<>(TimeType.class);
+
+ TimeChangeUserCheckpointStore(Path dataDirectory) {
+ file = dataDirectory == null ? null : dataDirectory.resolve(FILE_NAME);
+ if (file != null) load();
+ }
+
+ boolean isFileBacked() {
+ return file != null;
+ }
+
+ synchronized void begin(TimeChangeTransition transition, String legacyCursor,
+ TimeChangeUserProgress legacyUser, TimeChangeRewardState legacyRewardState) {
+ State current = states.get(transition.getType());
+ if (current != null && transition.getId().equals(current.id)) return;
+ State replacement = new State(transition.getId());
+ replacement.cursor = legacyCursor == null ? "" : legacyCursor;
+ if (legacyUser != null && legacyUser.uuid() != null && !legacyUser.uuid().isEmpty()) {
+ replacement.userUuid = legacyUser.uuid();
+ replacement.streakTarget = legacyUser.streakTarget();
+ replacement.rewardRequired = legacyUser.rewardRequired();
+ replacement.rewardState = legacyRewardState == null
+ ? (legacyUser.rewardComplete() ? TimeChangeRewardState.COMPLETE : TimeChangeRewardState.UNCLAIMED)
+ : legacyRewardState;
+ }
+ persist(transition.getType(), replacement);
+ }
+
+ synchronized String cursor(TimeChangeTransition transition) {
+ State state = state(transition);
+ return state == null ? "" : state.cursor;
+ }
+
+ synchronized void completeUser(TimeChangeTransition transition, String uuid) {
+ State replacement = copy(requiredState(transition));
+ replacement.cursor = uuid;
+ replacement.clearUser();
+ persist(transition.getType(), replacement);
+ }
+
+ synchronized TimeChangeUserProgress prepareStreak(TimeChangeTransition transition, String uuid,
+ int streakTarget, boolean rewardRequired) {
+ State state = requiredState(transition);
+ if (!uuid.equals(state.userUuid)) {
+ State replacement = copy(state);
+ replacement.userUuid = uuid;
+ replacement.streakTarget = streakTarget;
+ replacement.rewardRequired = rewardRequired;
+ replacement.rewardState = TimeChangeRewardState.UNCLAIMED;
+ persist(transition.getType(), replacement);
+ state = requiredState(transition);
+ }
+ return new TimeChangeUserProgress(uuid, state.streakTarget, state.rewardRequired,
+ state.rewardState == TimeChangeRewardState.COMPLETE);
+ }
+
+ synchronized TimeChangeRewardState rewardState(TimeChangeTransition transition, String uuid) {
+ State state = requiredState(transition);
+ if (!uuid.equals(state.userUuid)) throw new IllegalStateException("Time change recovery user does not match");
+ return state.rewardState;
+ }
+
+ synchronized void setRewardState(TimeChangeTransition transition, String uuid, TimeChangeRewardState value) {
+ State state = requiredState(transition);
+ if (!uuid.equals(state.userUuid)) throw new IllegalStateException("Time change recovery user does not match");
+ State replacement = copy(state);
+ replacement.rewardState = value;
+ persist(transition.getType(), replacement);
+ }
+
+ private State state(TimeChangeTransition transition) {
+ State state = states.get(transition.getType());
+ return state != null && transition.getId().equals(state.id) ? state : null;
+ }
+
+ private State requiredState(TimeChangeTransition transition) {
+ State state = state(transition);
+ if (state == null) throw new IllegalStateException("Time change recovery transition does not match");
+ return state;
+ }
+
+ private void persist(TimeType type, State replacement) {
+ State previous = states.put(type, replacement);
+ if (file == null) return;
+ Path temporary = null;
+ try {
+ Path parent = file.toAbsolutePath().normalize().getParent();
+ Files.createDirectories(parent);
+ temporary = Files.createTempFile(parent, FILE_NAME, ".tmp");
+ Properties properties = encode();
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ properties.store(bytes, "VotingPlugin time-change user checkpoint");
+ Files.write(temporary, bytes.toByteArray(), StandardOpenOption.TRUNCATE_EXISTING,
+ StandardOpenOption.WRITE);
+ DurableFiles.publishStagedFile(temporary, file);
+ } catch (DurableFiles.PublishedException published) {
+ if (previous == null) states.remove(type); else states.put(type, previous);
+ throw new IllegalStateException("Checkpoint was published but directory durability failed", published);
+ } catch (IOException failure) {
+ if (previous == null) states.remove(type); else states.put(type, previous);
+ throw new IllegalStateException("Unable to persist time-change user checkpoint", failure);
+ } finally {
+ if (temporary != null) {
+ try { Files.deleteIfExists(temporary); }
+ catch (IOException ignored) { }
+ }
+ }
+ }
+
+ private Properties encode() {
+ Properties properties = new Properties();
+ for (Map.Entry entry : states.entrySet()) {
+ String prefix = entry.getKey().name() + ".";
+ State state = entry.getValue();
+ properties.setProperty(prefix + "id", state.id);
+ properties.setProperty(prefix + "cursor", state.cursor);
+ if (!state.userUuid.isEmpty()) {
+ properties.setProperty(prefix + "user.uuid", state.userUuid);
+ properties.setProperty(prefix + "user.streakTarget", Integer.toString(state.streakTarget));
+ properties.setProperty(prefix + "user.rewardRequired", Boolean.toString(state.rewardRequired));
+ properties.setProperty(prefix + "user.rewardState", state.rewardState.name());
+ }
+ }
+ return properties;
+ }
+
+ private void load() {
+ if (!Files.isRegularFile(file)) return;
+ Properties properties = new Properties();
+ try (InputStream input = Files.newInputStream(file)) {
+ properties.load(input);
+ for (TimeType type : TimeType.values()) {
+ String prefix = type.name() + ".";
+ String id = properties.getProperty(prefix + "id", "");
+ if (id.isEmpty()) continue;
+ State state = new State(id);
+ state.cursor = properties.getProperty(prefix + "cursor", "");
+ state.userUuid = properties.getProperty(prefix + "user.uuid", "");
+ state.streakTarget = Integer.parseInt(properties.getProperty(prefix + "user.streakTarget", "0"));
+ state.rewardRequired = Boolean.parseBoolean(properties.getProperty(prefix + "user.rewardRequired", "false"));
+ state.rewardState = TimeChangeRewardState.valueOf(
+ properties.getProperty(prefix + "user.rewardState", TimeChangeRewardState.UNCLAIMED.name()));
+ states.put(type, state);
+ }
+ } catch (IOException | RuntimeException failure) {
+ throw new IllegalStateException("Unable to read time-change user checkpoint", failure);
+ }
+ }
+
+ private static State copy(State source) {
+ State copy = new State(source.id);
+ copy.cursor = source.cursor;
+ copy.userUuid = source.userUuid;
+ copy.streakTarget = source.streakTarget;
+ copy.rewardRequired = source.rewardRequired;
+ copy.rewardState = source.rewardState;
+ return copy;
+ }
+
+ private static final class State {
+ private final String id;
+ private String cursor = "";
+ private String userUuid = "";
+ private int streakTarget;
+ private boolean rewardRequired;
+ private TimeChangeRewardState rewardState = TimeChangeRewardState.UNCLAIMED;
+
+ private State(String id) { this.id = id; }
+ private void clearUser() {
+ userUuid = "";
+ streakTarget = 0;
+ rewardRequired = false;
+ rewardState = TimeChangeRewardState.UNCLAIMED;
+ }
+ }
+}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java
index d75f7bd1a6..95927aa0c8 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java
@@ -1,4 +1,8 @@
-package com.bencodez.votingplugin.events;
+package com.bencodez.votingplugin.events;
+
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
@@ -76,9 +80,62 @@ public static HandlerList getHandlerList() {
@Setter
private boolean broadcast = true;
- @Getter
- @Setter
- private int voteNumber = 1;
+ @Getter
+ @Setter
+ private int voteNumber = 1;
+
+ /** Stable delivery identity supplied by proxy transports and durable retries. */
+ @Getter
+ @Setter
+ private UUID voteId;
+
+ @Getter
+ @Setter
+ private boolean accountingAdmissionFailed;
+
+ /** Processing began but did not reach the post-vote completion boundary. */
+ @Getter
+ @Setter
+ private boolean processingFailed;
+
+ /** A non-idempotent effect may already have run, so automatic replay is unsafe. */
+ @Getter
+ @Setter
+ private boolean replayUnsafe;
+
+ /** The durable producer will retire the replay fence after its own acknowledgement. */
+ @Getter
+ @Setter
+ private boolean deferredDeliveryCompletion;
+
+ private final CompletableFuture processingCompletion = new CompletableFuture<>();
+
+ @Getter
+ private volatile boolean processingPending;
+
+ /**
+ * Completion signal for producers that fire this event from the platform thread.
+ * Processing is moved to the vote executor in that case, so callers must not
+ * interpret a synchronous return from Bukkit event dispatch as vote completion.
+ */
+ public CompletionStage getProcessingCompletion() {
+ return processingCompletion.minimalCompletionStage();
+ }
+
+ /** Marks asynchronous vote processing as admitted but not yet complete. */
+ public void markProcessingPending() {
+ processingPending = true;
+ }
+
+ /** Publishes the final processing flags to an asynchronous event producer. */
+ public void completeProcessing() {
+ processingPending = false;
+ processingCompletion.complete(this);
+ }
+
+ public boolean isProcessingIncomplete() {
+ return accountingAdmissionFailed || processingFailed;
+ }
/**
* Constructs a new PlayerVoteEvent.
@@ -88,9 +145,23 @@ public static HandlerList getHandlerList() {
* @param serviceSite the service site name
* @param realVote whether this is a real vote
*/
- public PlayerVoteEvent(VoteSite voteSite, String voteUsername, String serviceSite, boolean realVote) {
- super(true);
- this.player = voteUsername;
+ public PlayerVoteEvent(VoteSite voteSite, String voteUsername, String serviceSite, boolean realVote) {
+ this(voteSite, voteUsername, serviceSite, realVote, true);
+ }
+
+ /**
+ * Constructs a vote event with an explicit Bukkit dispatch contract.
+ *
+ * @param voteSite the vote site
+ * @param voteUsername the username of the voter
+ * @param serviceSite the service site name
+ * @param realVote whether this is a real vote
+ * @param asynchronous whether Bukkit will dispatch the event off its primary thread
+ */
+ public PlayerVoteEvent(VoteSite voteSite, String voteUsername, String serviceSite, boolean realVote,
+ boolean asynchronous) {
+ super(asynchronous);
+ this.player = voteUsername;
this.voteSite = voteSite;
this.realVote = realVote;
this.serviceSite = serviceSite;
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java
index b2bd177b2e..c8f0dad961 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java
@@ -11,16 +11,20 @@
import org.bukkit.event.Listener;
import com.bencodez.advancedcore.api.bedrock.BedrockNameResolver;
+import com.bencodez.advancedcore.api.user.UserStorage;
import com.bencodez.advancedcore.api.user.validation.UserValidationResult;
import com.bencodez.simpleapi.array.ArrayUtils;
import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.core.vote.SharedVoteAdmissionException;
import com.bencodez.votingplugin.core.vote.SharedVotePolicy;
import com.bencodez.votingplugin.core.vote.SharedVoteProcessor;
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.topvoter.TopVoter;
import com.bencodez.votingplugin.user.VotingPluginUser;
+import com.bencodez.votingplugin.util.VoteTaskAdmission;
import com.bencodez.votingplugin.votesites.VoteSite;
+import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService;
/** Bukkit event entry point and platform operations for the shared vote sequence. */
public class PlayerVoteListener implements Listener {
@@ -30,18 +34,61 @@ public PlayerVoteListener(VotingPluginMain plugin) {
this.plugin = plugin;
}
- @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
- public void onplayerVote(PlayerVoteEvent event) {
- SharedVoteProcessor.process(new BukkitOperations(plugin, event));
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
+ public void onplayerVote(PlayerVoteEvent event) {
+ if (event.isCancelled()) {
+ event.completeProcessing();
+ return;
+ }
+ if (!VoteTaskAdmission.isVoteTask() && (Bukkit.isPrimaryThread() || !event.isAsynchronous())) {
+ if (event.isDeferredDeliveryCompletion()) {
+ failAdmission(event, new SharedVoteAdmissionException(
+ "Durable vote producer must retry processing from the vote executor"));
+ event.completeProcessing();
+ return;
+ }
+ PlatformVoteState platformState = PlatformVoteState.capture(plugin, event.getPlayer());
+ event.markProcessingPending();
+ if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> processVote(event, platformState))) {
+ failAdmission(event, new SharedVoteAdmissionException("Vote executor rejected accounting admission"));
+ event.completeProcessing();
+ }
+ return;
+ }
+ processVote(event, PlatformVoteState.uncaptured());
+ }
+
+ private void processVote(PlayerVoteEvent event, PlatformVoteState platformState) {
+ try {
+ SharedVoteProcessor.process(new BukkitOperations(plugin, event, platformState));
+ } catch (SharedVoteAdmissionException admissionFailure) {
+ failAdmission(event, admissionFailure);
+ } catch (RuntimeException processingFailure) {
+ event.setProcessingFailed(true);
+ plugin.getLogger().severe("Vote processing did not complete for " + event.getPlayer() + '/'
+ + event.getServiceSite() + "; a durable producer may retry it");
+ plugin.debug(processingFailure);
+ } finally {
+ event.completeProcessing();
+ }
}
- private static final class BukkitOperations implements SharedVoteProcessor.Operations {
+ private void failAdmission(PlayerVoteEvent event, SharedVoteAdmissionException failure) {
+ event.setAccountingAdmissionFailed(true);
+ plugin.getLogger().severe("Vote processing aborted because shared accounting admission failed for "
+ + event.getPlayer() + '/' + event.getServiceSite());
+ plugin.debug(failure);
+ }
+
+ static final class BukkitOperations implements SharedVoteProcessor.Operations {
private final VotingPluginMain plugin;
private final PlayerVoteEvent event;
+ private final PlatformVoteState platformState;
- private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) {
+ BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event, PlatformVoteState platformState) {
this.plugin = plugin;
this.event = event;
+ this.platformState = platformState;
}
@Override public boolean enabled() { return plugin.isEnabled(); }
@@ -97,7 +144,10 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) {
@Override public String siteKey(VoteSite site) { return site.getKey(); }
@Override public String siteDisplayName(VoteSite site) { return site.getDisplayName(); }
@Override public VotingPluginUser resolveUser(String name) {
- if (event.getVotingPluginUser() != null) return event.getVotingPluginUser();
+ if (event.getVotingPluginUser() != null) return event.getVotingPluginUser();
+ if (platformState.matchesName(name)) return plugin.getVotingPluginUserManager()
+ .getVotingPluginUser(platformState.uuid(), platformState.playerName());
+ if (platformState.captured()) return plugin.getVotingPluginUserManager().getVotingPluginUser(name);
Player player = Bukkit.getPlayerExact(name);
if (player != null) return plugin.getVotingPluginUserManager().getVotingPluginUser(player);
return plugin.getVotingPluginUserManager().getVotingPluginUser(name);
@@ -105,12 +155,20 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) {
@Override public String userName(VotingPluginUser user) { return user.getPlayerName(); }
@Override public String userId(VotingPluginUser user) { return user.getUUID(); }
@Override public UUID userUuid(VotingPluginUser user) { return user.getJavaUUID(); }
- @Override public boolean userOnline(VotingPluginUser user) { return user.isOnline(); }
- @Override public boolean userVanished(VotingPluginUser user) { return user.isVanished(); }
+ @Override public boolean userOnline(VotingPluginUser user) {
+ return platformState.captured() ? platformState.matches(user) && platformState.online() : user.isOnline();
+ }
+ @Override public boolean userVanished(VotingPluginUser user) {
+ return platformState.captured() ? platformState.matches(user) && platformState.vanished() : user.isVanished();
+ }
@Override public long lastVoteTime(VotingPluginUser user, VoteSite site) { return user.getTime(site); }
@Override public boolean waitUntilVoteDelay(VoteSite site) { return site.isWaitUntilVoteDelay(); }
@Override public boolean canVoteSite(VotingPluginUser user, VoteSite site) { return user.canVoteSite(site); }
- @Override public boolean bypassWaitPermission(VotingPluginUser user) { return user.hasPermission("VotingPlugin.BypassWaitUntilVoteDelay"); }
+ @Override public boolean bypassWaitPermission(VotingPluginUser user) {
+ return platformState.captured()
+ ? platformState.matches(user) && platformState.bypassWaitPermission()
+ : user.hasPermission("VotingPlugin.BypassWaitUntilVoteDelay");
+ }
@Override public boolean processRewards() { return plugin.getOptions().isProcessRewards(); }
@Override public void giveWaitRewards(VoteSite site, VotingPluginUser user, boolean online, boolean proxyVote) {
site.giveWaitUntilVoteDelayRewards(user, online, proxyVote);
@@ -123,16 +181,58 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) {
@Override public boolean broadcastEnabled() { return event.isBroadcast(); }
@Override public boolean hasBroadcastHandler() { return plugin.getBroadcastHandler() != null; }
@Override public void broadcast(UUID uuid, String name, String siteDisplayName, boolean online) {
- plugin.getBroadcastHandler().broadcastVote(uuid, name, siteDisplayName, online);
+ Runnable broadcast = () -> plugin.getBroadcastHandler().broadcastVote(uuid, name, siteDisplayName, online);
+ if (!platformState.captured()) {
+ broadcast.run();
+ } else if (platformState.owner() != null) {
+ plugin.getBukkitScheduler().runTask(plugin, broadcast, platformState.owner());
+ } else {
+ plugin.getBukkitScheduler().runTask(plugin, broadcast);
+ }
}
@Override public boolean hasProxyTextTotals() { return event.getBungeeTextTotals() != null; }
- @Override public UUID proxyVoteId() { return event.getBungeeTextTotals().getVoteUUID(); }
+ @Override public UUID incomingVoteId() {
+ if (event.getVoteId() != null) return event.getVoteId();
+ return event.getBungeeTextTotals() == null ? null : event.getBungeeTextTotals().getVoteUUID();
+ }
+ @Override public SharedVoteProcessor.AccountingAdmission prepareAccounting(
+ VotingPluginUser user, UUID voteId, boolean countTotals, boolean awardPoints,
+ int pointAmount, int pointCap) {
+ boolean onlineAtVoteTime = event.isBungee() ? event.isWasOnline() : userOnline(user);
+ boolean countVoteParty = plugin.getSpecialRewardsConfig().isVotePartyEnabled()
+ && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || event.isRealVote())
+ && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || onlineAtVoteTime);
+ VoteShopPurchaseService.VoteAccountingAdmission admission = VoteShopPurchaseService
+ .prepareMysqlVoteAccounting(plugin, voteId, user.getUUID(), countTotals, awardPoints,
+ countVoteParty, event.isForceBungee(), pointAmount, pointCap, user.getPointsPath());
+ if (!admission.success()) {
+ throw new SharedVoteAdmissionException("Unable to admit shared MySQL vote accounting before processing");
+ }
+ return new SharedVoteProcessor.AccountingAdmission(admission.countTotals(), admission.awardPoints(),
+ admission.countVoteParty(), admission.pointAmount(), admission.pointCap(),
+ admission.pointColumn(), admission.replayUnsafe());
+ }
+ @Override public int configuredPointAmount() { return plugin.getConfigFile().getPointsOnVote(); }
+ @Override public int configuredPointCap() { return plugin.getConfigFile().getLimitVotePoints(); }
+ @Override public void finishAccounting(UUID voteId) {
+ VoteShopPurchaseService.finishMysqlVoteAccounting(voteId);
+ }
@Override public void cache(VotingPluginUser user) { user.cache(); }
@Override public void updateName(VotingPluginUser user) { user.updateName(true); }
- @Override public void voteParty(VotingPluginUser user, boolean realVote, boolean forceProxyRouting,
- boolean onlineAtVoteTime) {
- plugin.getVoteParty().vote(user, realVote, forceProxyRouting, onlineAtVoteTime);
+ @Override public void voteParty(VotingPluginUser user, boolean forceProxyRouting, UUID voteId, boolean eligible) {
+ plugin.getVoteParty().voteAdmitted(user, forceProxyRouting, voteId, eligible);
}
+ @Override public void markReplayUnsafe(UUID voteId) {
+ if (!VoteShopPurchaseService.markVoteReplayUnsafe(plugin, voteId)) {
+ throw new SharedVoteAdmissionException("Unable to persist the vote effect boundary");
+ }
+ event.setReplayUnsafe(true);
+ }
+ @Override public boolean deferDeliveryCompletion() { return event.isDeferredDeliveryCompletion(); }
+ @Override public void completeDelivery(UUID voteId) {
+ VoteShopPurchaseService.completeVoteDelivery(plugin, voteId);
+ }
+ @Override public void restoreReplayUnsafe() { event.setReplayUnsafe(true); }
@Override public long incomingTime() { return event.getTime(); }
@Override public void setTime(VotingPluginUser user, VoteSite site, long time) { user.setTime(site, time); }
@Override public void setTimeNow(VotingPluginUser user, VoteSite site) { user.setTime(site); }
@@ -143,7 +243,11 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) {
@Override public int voteNumber() { return event.getVoteNumber(); }
@Override public void sendVoteEffects(VotingPluginUser user, boolean online) { user.sendVoteEffects(online); }
@Override public boolean closeInventoryOnVote() { return plugin.getConfigFile().isCloseInventoryOnVote(); }
- @Override public void closeInventory(VotingPluginUser user) { user.closeInv(); }
+ @Override public void closeInventory(VotingPluginUser user) {
+ Runnable close = user::closeInv;
+ if (platformState.owner() != null) plugin.getBukkitScheduler().runTask(plugin, close, platformState.owner());
+ else plugin.getBukkitScheduler().runTask(plugin, close);
+ }
@Override public boolean offlineVotesLimitEnabled() { return plugin.getConfigFile().isOfflineVotesLimitEnabled(); }
@Override public int offlineVotes(VotingPluginUser user, VoteSite site) { return user.getNumberOfOfflineVotes(site); }
@Override public int offlineVotesLimitAmount() { return plugin.getConfigFile().getOfflineVotesLimitAmount(); }
@@ -152,19 +256,25 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) {
return new SharedVotePolicy(plugin.getConfigFile().isCountFakeVotes(), plugin.getConfigFile().isAddTotals(),
plugin.getConfigFile().isAddTotalsOffline(), false, false);
}
- @Override public void addTotal(VotingPluginUser user) { user.addTotal(); }
- @Override public void addTotalDaily(VotingPluginUser user) { user.addTotalDaily(); }
- @Override public void addTotalWeekly(VotingPluginUser user) { user.addTotalWeekly(); }
- @Override public void addPoints(VotingPluginUser user) { user.addPoints(); }
- @Override public void checkDayVoteStreak(VotingPluginUser user, boolean forceProxyRouting) {
- user.checkDayVoteStreak(forceProxyRouting);
+ @Override public void addTotal(VotingPluginUser user, UUID voteId) { user.addTotal(voteId); }
+ @Override public void addTotalDaily(VotingPluginUser user, UUID voteId) { user.addTotalDaily(voteId); }
+ @Override public void addTotalWeekly(VotingPluginUser user, UUID voteId) { user.addTotalWeekly(voteId); }
+ @Override public void addPoints(VotingPluginUser user, UUID voteId, int amount, int cap, String pointColumn) {
+ user.addVotePoints(voteId, amount, cap, pointColumn);
+ }
+ @Override public void checkDayVoteStreak(VotingPluginUser user, boolean forceProxyRouting, UUID voteId) {
+ user.checkDayVoteStreak(forceProxyRouting, voteId);
}
@Override public boolean limitMonthlyVotes() { return plugin.getConfigFile().isLimitMonthlyVotes(); }
@Override public int proxyMonthTotal() { return event.getBungeeTextTotals().getMonthTotal(); }
@Override public int userMonthTotal(VotingPluginUser user) { return user.getTotal(TopVoter.Monthly); }
@Override public int currentDayOfMonth() { return plugin.getTimeChecker().getTime().getDayOfMonth(); }
@Override public int enabledSiteCount() { return plugin.getVoteSiteManager().getVoteSitesEnabled().size(); }
- @Override public void setMonthTotal(VotingPluginUser user, int total) { user.setTotal(TopVoter.Monthly, total); }
+ @Override public void setMonthTotal(VotingPluginUser user, int total) {
+ // Shared MySQL increments apply the same cap inside the boundary-serialized
+ // transaction, so an absolute cache write here could only reintroduce a stale value.
+ if (!UserStorage.MYSQL.equals(plugin.getStorageType())) user.capMonthTotal(total);
+ }
@Override public void milestones(VotingPluginUser user, UUID voteId, boolean forceProxyRouting) {
plugin.getVoteMilestonesManager().handleVote(user, event.getBungeeTextTotals(), forceProxyRouting,
voteId, new HashMap());
@@ -184,4 +294,34 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) {
@Override public void clearCache(VotingPluginUser user) { user.clearCache(); }
@Override public void setUpdate() { plugin.setUpdate(true); }
}
+
+ /** Bukkit-owned state captured before a primary-thread event enters the storage lane. */
+ static record PlatformVoteState(boolean captured, Player owner, UUID uuid, String playerName, boolean online,
+ boolean vanished, boolean bypassWaitPermission) {
+ static PlatformVoteState uncaptured() {
+ return new PlatformVoteState(false, null, null, null, false, false, false);
+ }
+
+ static PlatformVoteState capture(VotingPluginMain plugin, String requestedName) {
+ if (requestedName == null || requestedName.isEmpty()) {
+ return new PlatformVoteState(true, null, null, null, false, false, false);
+ }
+ Player player = Bukkit.getPlayerExact(requestedName);
+ if (player == null) return new PlatformVoteState(true, null, null, null, false, false, false);
+ VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(player);
+ boolean vanished = user.isVanished();
+ boolean online = player.isOnline()
+ && (!plugin.getOptions().isTreatVanishAsOffline() || !vanished);
+ return new PlatformVoteState(true, player, player.getUniqueId(), player.getName(), online, vanished,
+ user.hasPermission("VotingPlugin.BypassWaitUntilVoteDelay"));
+ }
+
+ boolean matchesName(String name) {
+ return uuid != null && playerName != null && name != null && playerName.equalsIgnoreCase(name);
+ }
+
+ boolean matches(VotingPluginUser user) {
+ return uuid != null && user != null && uuid.equals(user.getJavaUUID());
+ }
+ }
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java
index 5e7188a5f9..ead143f95c 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java
@@ -1,7 +1,8 @@
package com.bencodez.votingplugin.listeners;
-import java.util.concurrent.RejectedExecutionException;
+import java.util.UUID;
+import org.bukkit.Bukkit;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
@@ -10,8 +11,10 @@
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.proxy.BungeeMethod;
+import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.VoteOutcome;
import com.bencodez.votingplugin.util.MinecraftUsernameValidator;
import com.bencodez.votingplugin.util.ServiceSiteValidator;
+import com.bencodez.votingplugin.util.VoteTaskAdmission;
import com.vexsoftware.votifier.model.Vote;
import com.vexsoftware.votifier.model.VotifierEvent;
@@ -36,6 +39,42 @@ public VotiferEvent(VotingPluginMain plugin) {
* @param voteUsername the validated player name
*/
public void processVote(String voteSite, String voteUsername) {
+ processVote(voteSite, voteUsername, UUID.randomUUID());
+ }
+
+ public void processVote(String voteSite, String voteUsername, UUID voteId) {
+ if (!VoteTaskAdmission.isVoteTask() && Bukkit.getServer() != null && Bukkit.isPrimaryThread()) {
+ if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(),
+ () -> processVote(voteSite, voteUsername, voteId))) {
+ retainForAccountingRetry(voteSite, voteUsername, voteId);
+ }
+ return;
+ }
+ VoteOutcome outcome = processVoteAttempt(voteSite, voteUsername, voteId, false);
+ if (outcome == VoteOutcome.RETRY) {
+ retainForAccountingRetry(voteSite, voteUsername, voteId);
+ } else if (outcome == VoteOutcome.QUARANTINE
+ && !plugin.getVotifierVoteOverflowQueue().quarantine(voteUsername, voteSite, voteId)) {
+ plugin.getLogger().severe("Unable to retain ambiguous Votifier vote " + voteId + " for manual review");
+ } else if (outcome == VoteOutcome.QUARANTINE) {
+ plugin.getLogger().severe("Votifier vote " + voteId
+ + " reached an ambiguous post-effect failure and was retained for manual review");
+ }
+ }
+
+ public boolean processQueuedVote(String voteSite, String voteUsername, UUID voteId) {
+ return processVoteAttempt(voteSite, voteUsername, voteId, true) == VoteOutcome.COMPLETE;
+ }
+
+ public VoteOutcome processQueuedVoteOutcome(String voteSite, String voteUsername, UUID voteId) {
+ return processVoteAttempt(voteSite, voteUsername, voteId, true);
+ }
+
+ private VoteOutcome processVoteAttempt(String voteSite, String voteUsername, UUID voteId,
+ boolean deferredDeliveryCompletion) {
+ if (!VoteTaskAdmission.isVoteTask() && Bukkit.getServer() != null && Bukkit.isPrimaryThread()) {
+ return VoteOutcome.RETRY;
+ }
try {
plugin.getServerData().addServiceSite(voteSite);
if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass()
@@ -46,7 +85,7 @@ public void processVote(String voteSite, String voteUsername) {
|| plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) {
plugin.getLogger().severe(
"Ignoring vote from votifier since a proxy vote transport is enabled; receive votes on the proxy or enable VotifierBypass, then check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups");
- return;
+ return VoteOutcome.COMPLETE;
}
String matchSite = "";
@@ -77,22 +116,38 @@ public void processVote(String voteSite, String voteUsername) {
if (plugin.getTimeChecker().isActiveProcessing()
&& plugin.getConfigFile().isQueueVotesDuringTimeChange()) {
plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite);
- plugin.getTimeQueueHandler().addVote(voteUsername, voteSite);
- return;
+ return plugin.getTimeQueueHandler().addVoteDurably(voteId, voteUsername, voteSite)
+ ? VoteOutcome.COMPLETE : VoteOutcome.RETRY;
}
String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite);
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true);
+ voteEvent.setVoteId(voteId);
+ voteEvent.setDeferredDeliveryCompletion(deferredDeliveryCompletion);
plugin.getServer().getPluginManager().callEvent(voteEvent);
+ if (voteEvent.isProcessingIncomplete()) {
+ return voteEvent.isReplayUnsafe() ? VoteOutcome.QUARANTINE : VoteOutcome.RETRY;
+ }
if (voteEvent.isCancelled()) {
plugin.debug("Vote cancelled");
}
} catch (Exception e) {
plugin.getLogger().severe("Error occured during vote processing");
- e.printStackTrace();
+ plugin.debug(e);
+ return VoteOutcome.RETRY;
+ }
+ return VoteOutcome.COMPLETE;
+ }
+
+ private void retainForAccountingRetry(String voteSite, String voteUsername, UUID voteId) {
+ VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue();
+ if (overflow == null || !overflow.enqueueDurably(voteUsername, voteSite, voteId)) {
+ plugin.getLogger().severe("Unable to retain vote after shared MySQL accounting admission failed");
+ } else {
+ plugin.getLogger().warning("Shared MySQL accounting is unavailable; retained Votifier vote for retry");
}
}
@@ -135,11 +190,11 @@ public void onVotiferEvent(VotifierEvent event) {
plugin.debug("VoteSite: " + voteSite);
plugin.debug("IP: " + IP);
- try {
- plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername));
- } catch (RejectedExecutionException rejected) {
+ UUID voteId = UUID.randomUUID();
+ if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(),
+ () -> processVote(voteSite, voteUsername, voteId))) {
VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue();
- if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) {
+ if (overflow == null || !overflow.enqueueDurably(voteUsername, voteSite, voteId)) {
plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for "
+ MinecraftUsernameValidator.sanitizeForLog(voteUsername));
} else {
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java
index 7ec1cb4c82..02fd5b564b 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java
@@ -17,6 +17,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@@ -28,6 +29,8 @@
import com.bencodez.votingplugin.util.DurableFiles;
import com.bencodez.votingplugin.util.MinecraftUsernameValidator;
import com.bencodez.votingplugin.util.ServiceSiteValidator;
+import com.bencodez.votingplugin.util.VoteTaskAdmission;
+import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService;
/**
* Durable overflow for votes that cannot currently be admitted to the bounded
@@ -42,19 +45,22 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
private static final String QUEUE_FILE = "VotifierVoteQueue.yml";
private final VotingPluginMain plugin;
- private final BiConsumer processor;
+ private final VoteProcessor processor;
private final Path file;
private final ScheduledThreadPoolExecutor worker;
private final Object lock = new Object();
private final Object persistenceWriteLock = new Object();
private final ArrayDeque entries = new ArrayDeque<>();
+ private final ArrayDeque retiredVotes = new ArrayDeque<>();
private boolean drainScheduled;
private boolean persistenceScheduled;
+ private boolean retirementRetryScheduled;
private boolean persistenceDirty;
private boolean started;
private boolean closed;
private long stateVersion;
private long durableVersion;
+ private long persistedSnapshotVersion;
/**
* Creates and loads the overflow queue.
@@ -62,7 +68,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
* @param plugin the owning plugin
* @param processor callback receiving service site and player name
*/
- public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer processor) {
+ public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processor) {
this.plugin = plugin;
this.processor = processor;
this.file = new File(plugin.getDataFolder(), QUEUE_FILE).toPath();
@@ -75,6 +81,13 @@ public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer processor) {
+ this(plugin, (serviceSite, username, voteId) -> {
+ processor.accept(serviceSite, username);
+ return VoteOutcome.COMPLETE;
+ });
+ }
+
/**
* Starts draining persisted and newly queued votes. Call this only after the
* Votifier and vote-processing listeners have been registered so a recovered
@@ -84,6 +97,7 @@ public void start() {
synchronized (lock) {
if (closed || started) return;
started = true;
+ scheduleRetirementRetryLocked(0L);
scheduleDrainLocked();
}
}
@@ -97,10 +111,14 @@ public void start() {
* @return false when the bounded overflow is full or shutting down
*/
public boolean enqueue(String username, String serviceSite) {
- if (username == null || serviceSite == null) return false;
+ return enqueue(username, serviceSite, UUID.randomUUID());
+ }
+
+ public boolean enqueue(String username, String serviceSite, UUID voteId) {
+ if (username == null || serviceSite == null || voteId == null) return false;
synchronized (lock) {
- if (closed || entries.size() >= MAX_ENTRIES) return false;
- entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis()));
+ if (closed || ownedEntriesLocked() >= MAX_ENTRIES) return false;
+ entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId));
stateVersion++;
requestPersistenceLocked();
scheduleDrainLocked();
@@ -108,6 +126,63 @@ public boolean enqueue(String username, String serviceSite) {
}
}
+ /** Adds a vote and reports success only after its queue snapshot is durable. */
+ public boolean enqueueDurably(String username, String serviceSite, UUID voteId) {
+ if (username == null || serviceSite == null || voteId == null) return false;
+ return admitDurably(new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId));
+ }
+
+ /**
+ * Durably retains an ambiguous vote without automatically replaying it.
+ * Success is reported only after the quarantine snapshot has been forced and
+ * replaced on disk.
+ */
+ public boolean quarantine(String username, String serviceSite, UUID voteId) {
+ if (username == null || serviceSite == null || voteId == null) return false;
+ PendingVote pending = new PendingVote(username, serviceSite, System.currentTimeMillis(), voteId);
+ pending.quarantined = true;
+ return admitDurably(pending);
+ }
+
+ private boolean admitDurably(PendingVote pending) {
+ synchronized (lock) {
+ if (closed || ownedEntriesLocked() >= MAX_ENTRIES) return false;
+ entries.addLast(pending);
+ stateVersion++;
+ }
+
+ QueueSnapshot snapshot;
+ long snapshotVersion;
+ try {
+ synchronized (persistenceWriteLock) {
+ // Snapshot after obtaining the write lock so an older asynchronous
+ // snapshot cannot overtake this confirmed quarantine write.
+ synchronized (lock) {
+ if (closed) return false;
+ snapshot = snapshotLocked();
+ snapshotVersion = stateVersion;
+ }
+ writeSnapshotLocked(snapshot);
+ persistedSnapshotVersion = Math.max(persistedSnapshotVersion, snapshotVersion);
+ }
+ } catch (IOException failure) {
+ plugin.getLogger().warning("Unable to persist quarantined Votifier vote: "
+ + failure.getClass().getSimpleName());
+ synchronized (lock) {
+ if (!closed) requestPersistenceLocked();
+ }
+ return false;
+ }
+
+ synchronized (lock) {
+ durableVersion = Math.max(durableVersion, snapshotVersion);
+ scheduleRetirementRetryLocked(0L);
+ if (stateVersion != snapshotVersion) requestPersistenceLocked();
+ scheduleDrainLocked();
+ }
+ return true;
+ }
+
/**
* Returns the number of votes waiting for admission or completion.
*
@@ -115,10 +190,14 @@ public boolean enqueue(String username, String serviceSite) {
*/
public int size() {
synchronized (lock) {
- return entries.size();
+ return ownedEntriesLocked();
}
}
+ private int ownedEntriesLocked() {
+ return entries.size() + retiredVotes.size();
+ }
+
private void scheduleDrain() {
synchronized (lock) {
scheduleDrainLocked();
@@ -148,17 +227,14 @@ private void drain() {
return;
}
pending.submitted = true;
- try {
- // Serialize admission with enqueue so the version proven durable
- // above cannot change in the gap before submit accepts this vote.
- plugin.getVoteTimer().submit(() -> {
- try {
- processor.accept(pending.serviceSite, pending.username);
- } finally {
- acknowledge(pending);
- }
- });
- } catch (RejectedExecutionException rejected) {
+ // Serialize admission with enqueue so the version proven durable
+ // above cannot change in the gap before submit accepts this vote.
+ if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), () -> {
+ VoteOutcome outcome = processor.accept(pending.serviceSite, pending.username, pending.voteId);
+ if (outcome == VoteOutcome.COMPLETE) acknowledge(pending);
+ else if (outcome == VoteOutcome.QUARANTINE) quarantine(pending);
+ else retry(pending);
+ })) {
pending.submitted = false;
drainScheduled = false;
try {
@@ -173,17 +249,45 @@ private void drain() {
}
}
+ private void retry(PendingVote pending) {
+ synchronized (lock) {
+ if (closed || !entries.contains(pending)) return;
+ pending.submitted = false;
+ drainScheduled = false;
+ try {
+ worker.schedule(this::scheduleDrain, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
+ } catch (RejectedExecutionException ignored) {
+ // The durable entry remains for restart.
+ }
+ }
+ }
+
+ private void quarantine(PendingVote pending) {
+ synchronized (lock) {
+ if (closed || !entries.contains(pending)) return;
+ pending.submitted = false;
+ pending.quarantined = true;
+ stateVersion++;
+ requestPersistenceLocked();
+ drainScheduled = false;
+ scheduleDrainLocked();
+ }
+ plugin.getLogger().severe("Queued Votifier vote " + pending.voteId
+ + " reached an ambiguous post-effect failure and was retained for manual review");
+ }
+
private PendingVote nextUnsubmittedLocked() {
for (PendingVote pending : entries) {
- if (!pending.submitted) return pending;
+ if (!pending.submitted && !pending.quarantined) return pending;
}
return null;
}
private void acknowledge(PendingVote pending) {
synchronized (lock) {
- entries.remove(pending);
- stateVersion++;
+ if (closed || !entries.remove(pending)) return;
+ long retirementVersion = ++stateVersion;
+ retiredVotes.addLast(new RetiredVote(pending.voteId, retirementVersion));
requestPersistenceLocked();
scheduleDrainLocked();
}
@@ -200,9 +304,47 @@ private void requestPersistenceLocked() {
}
}
+ private void scheduleRetirementRetryLocked(long delayMillis) {
+ if (closed || !started || retiredVotes.isEmpty() || retirementRetryScheduled) return;
+ boolean durableRetirement = retiredVotes.stream().anyMatch(retired -> retired.version <= durableVersion);
+ if (!durableRetirement) return;
+ retirementRetryScheduled = true;
+ try {
+ worker.schedule(this::retryRetirements, delayMillis, TimeUnit.MILLISECONDS);
+ } catch (RejectedExecutionException ignored) {
+ retirementRetryScheduled = false;
+ }
+ }
+
+ private void retryRetirements() {
+ List candidates = new ArrayList<>();
+ synchronized (lock) {
+ retirementRetryScheduled = false;
+ if (closed) return;
+ for (RetiredVote retired : retiredVotes) {
+ if (retired.version <= durableVersion) candidates.add(retired);
+ }
+ }
+ List completed = new ArrayList<>();
+ for (RetiredVote retired : candidates) {
+ if (completeDelivery(retired.voteId)) completed.add(retired);
+ }
+ synchronized (lock) {
+ if (closed) return;
+ if (!completed.isEmpty()) {
+ retiredVotes.removeAll(completed);
+ stateVersion++;
+ requestPersistenceLocked();
+ }
+ if (completed.size() != candidates.size()) {
+ scheduleRetirementRetryLocked(RETRY_DELAY_MILLIS);
+ }
+ }
+ }
+
private void persistLoop() {
while (true) {
- List snapshot;
+ QueueSnapshot snapshot;
long snapshotVersion;
synchronized (lock) {
if (!persistenceDirty) {
@@ -210,11 +352,11 @@ private void persistLoop() {
return;
}
persistenceDirty = false;
- snapshot = new ArrayList<>(entries);
+ snapshot = snapshotLocked();
snapshotVersion = stateVersion;
}
try {
- writeSnapshot(snapshot);
+ writeSnapshot(snapshot, snapshotVersion);
} catch (IOException failure) {
plugin.getLogger().warning("Unable to persist queued Votifier votes: " + failure.getClass().getSimpleName());
synchronized (lock) {
@@ -228,13 +370,19 @@ private void persistLoop() {
return;
}
synchronized (lock) {
+ // closeBlocking owns the final snapshot and marker retirement once
+ // closed; writeSnapshot deliberately becomes a no-op in that race.
+ if (closed) return;
durableVersion = Math.max(durableVersion, snapshotVersion);
+ scheduleRetirementRetryLocked(0L);
if (!persistenceDirty) {
persistenceScheduled = false;
scheduleDrainLocked();
- return;
}
}
+ synchronized (lock) {
+ if (!persistenceDirty) return;
+ }
}
}
@@ -244,28 +392,59 @@ private void load() {
YamlConfiguration yaml = new YamlConfiguration();
yaml.loadFromString(readQueueFile());
Object raw = yaml.get("Votes");
- if (!(raw instanceof List> values)) return;
boolean skipped = false;
- for (Object value : values) {
- if (entries.size() >= MAX_ENTRIES) {
- skipped = true;
- break;
- }
- if (!(value instanceof Map, ?> map)) {
- skipped = true;
- continue;
+ if (raw instanceof List> values) {
+ for (Object value : values) {
+ if (ownedEntriesLocked() >= MAX_ENTRIES) {
+ skipped = true;
+ break;
+ }
+ if (!(value instanceof Map, ?> map)) {
+ skipped = true;
+ continue;
+ }
+ Object username = map.get("Username");
+ Object serviceSite = map.get("ServiceSite");
+ Object time = map.get("Time");
+ Object rawVoteId = map.get("VoteId");
+ if (!(username instanceof String name) || !(serviceSite instanceof String site)
+ || !(time instanceof Number timestamp)
+ || !MinecraftUsernameValidator.isValid(name, plugin.getOptions().getBedrockPlayerPrefix())
+ || !ServiceSiteValidator.isValid(site) || timestamp.longValue() <= 0) {
+ skipped = true;
+ continue;
+ }
+ UUID voteId;
+ try {
+ voteId = rawVoteId instanceof String id ? UUID.fromString(id) : UUID.randomUUID();
+ if (!(rawVoteId instanceof String)) skipped = true;
+ } catch (IllegalArgumentException invalidVoteId) {
+ skipped = true;
+ continue;
+ }
+ PendingVote pending = new PendingVote(name, site, timestamp.longValue(), voteId);
+ pending.quarantined = Boolean.TRUE.equals(map.get("Quarantined"));
+ entries.addLast(pending);
}
- Object username = map.get("Username");
- Object serviceSite = map.get("ServiceSite");
- Object time = map.get("Time");
- if (!(username instanceof String name) || !(serviceSite instanceof String site)
- || !(time instanceof Number timestamp)
- || !MinecraftUsernameValidator.isValid(name, plugin.getOptions().getBedrockPlayerPrefix())
- || !ServiceSiteValidator.isValid(site) || timestamp.longValue() <= 0) {
- skipped = true;
- continue;
+ } else if (raw != null) {
+ skipped = true;
+ }
+ Object rawRetirements = yaml.get("Retirements");
+ if (rawRetirements instanceof List> values) {
+ for (Object value : values) {
+ if (ownedEntriesLocked() >= MAX_ENTRIES) {
+ skipped = true;
+ break;
+ }
+ try {
+ if (!(value instanceof String id)) throw new IllegalArgumentException("non-string retirement");
+ retiredVotes.addLast(new RetiredVote(UUID.fromString(id), 0L));
+ } catch (IllegalArgumentException invalidRetirement) {
+ skipped = true;
+ }
}
- entries.addLast(new PendingVote(name, site, timestamp.longValue()));
+ } else if (rawRetirements != null) {
+ skipped = true;
}
if (skipped) {
synchronized (lock) {
@@ -290,24 +469,38 @@ private String readQueueFile() throws IOException {
}
}
- private void writeSnapshot(List snapshot) throws IOException {
+ private QueueSnapshot snapshotLocked() {
+ List retirements = new ArrayList<>();
+ for (RetiredVote retired : retiredVotes) retirements.add(retired.voteId);
+ return new QueueSnapshot(new ArrayList<>(entries), retirements);
+ }
+
+ private void writeSnapshot(QueueSnapshot snapshot, long snapshotVersion) throws IOException {
synchronized (persistenceWriteLock) {
if (closed) return;
+ // A confirmed synchronous quarantine snapshot may overtake a worker
+ // snapshot that was captured earlier. Never let that stale worker write
+ // replace the newer durable state afterward.
+ if (snapshotVersion < persistedSnapshotVersion) return;
writeSnapshotLocked(snapshot);
+ persistedSnapshotVersion = snapshotVersion;
}
}
- private void writeSnapshotLocked(List snapshot) throws IOException {
+ private void writeSnapshotLocked(QueueSnapshot snapshot) throws IOException {
YamlConfiguration yaml = new YamlConfiguration();
List> values = new ArrayList<>();
- for (PendingVote pending : snapshot) {
+ for (PendingVote pending : snapshot.votes) {
Map value = new LinkedHashMap<>();
value.put("Username", pending.username);
value.put("ServiceSite", pending.serviceSite);
value.put("Time", pending.time);
+ value.put("VoteId", pending.voteId.toString());
+ if (pending.quarantined) value.put("Quarantined", true);
values.add(value);
}
yaml.set("Votes", values);
+ yaml.set("Retirements", snapshot.retirements.stream().map(UUID::toString).toList());
byte[] bytes = yaml.saveToString().getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_FILE_BYTES) throw new IOException("queue snapshot exceeds size limit");
Path parent = file.toAbsolutePath().normalize().getParent();
@@ -330,13 +523,13 @@ private void writeSnapshotLocked(List snapshot) throws IOException
@Override
public void close() {
- List snapshot;
+ QueueSnapshot snapshot;
synchronized (lock) {
if (closed) return;
closed = true;
// At-least-once delivery: retain any unacknowledged callback. A callback
// interrupted during shutdown must be replayed rather than silently lost.
- snapshot = new ArrayList<>(entries);
+ snapshot = snapshotLocked();
persistenceScheduled = false;
}
worker.shutdownNow();
@@ -349,22 +542,51 @@ public void close() {
synchronized (persistenceWriteLock) {
writeSnapshotLocked(snapshot);
}
+ for (UUID voteId : snapshot.retirements) completeDelivery(voteId);
} catch (IOException failure) {
plugin.getLogger().warning("Unable to persist queued Votifier votes during shutdown: "
+ failure.getClass().getSimpleName());
}
}
+ private boolean completeDelivery(UUID voteId) {
+ try {
+ VoteShopPurchaseService.completeVoteDelivery(plugin, voteId);
+ return true;
+ } catch (RuntimeException failure) {
+ plugin.getLogger().warning("Unable to retire acknowledged Votifier vote replay fence: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return false;
+ }
+ }
+
private static final class PendingVote {
private final String username;
private final String serviceSite;
private final long time;
+ private final UUID voteId;
private boolean submitted;
+ private boolean quarantined;
- private PendingVote(String username, String serviceSite, long time) {
+ private PendingVote(String username, String serviceSite, long time, UUID voteId) {
this.username = username;
this.serviceSite = serviceSite;
this.time = time;
+ this.voteId = voteId;
}
}
+
+ private record RetiredVote(UUID voteId, long version) { }
+
+ private record QueueSnapshot(List votes, List retirements) { }
+
+ @FunctionalInterface
+ public interface VoteProcessor {
+ VoteOutcome accept(String serviceSite, String username, UUID voteId);
+ }
+
+ public enum VoteOutcome {
+ COMPLETE, RETRY, QUARANTINE
+ }
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java
index f7042b17be..e6fc4a96a9 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java
@@ -438,9 +438,16 @@ public void timeChanged(TimeType type, boolean fake, boolean pre, boolean post)
warn("Global data not enabled, ignoring time change event");
return;
}
- int delay = 1;
+ String transitionId = UUID.randomUUID().toString();
+ List activeServers = new ArrayList<>();
+ boolean allSupportBoundaryProtocol = true;
for (String s : getAllAvailableServers()) {
if (getGlobalDataHandler().getGlobalMysql().containsKey(s)) {
+ HashMap boundary = new HashMap<>();
+ boundary.put(VotingPluginWire.timeChangeTransitionKey(type.toString()), new DataValueString(""));
+ boundary.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()),
+ new DataValueString(""));
+ getGlobalDataHandler().setData(s, boundary);
String lastOnlineStr = getGlobalDataHandler().getString(s, "LastOnline");
long lastOnline = 0;
try {
@@ -451,15 +458,10 @@ public void timeChanged(TimeType type, boolean fake, boolean pre, boolean post)
if (LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli() - lastOnline < 1000
* 60 * 60 * 12) {
- HashMap dataToSet = new HashMap<>();
- dataToSet.put("LastUpdated", new DataValueString(
- "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli()));
- dataToSet.put("FinishedProcessing", new DataValueBoolean(false));
- dataToSet.put(type.toString(), new DataValueBoolean(true));
- getGlobalDataHandler().setData(s, dataToSet);
-
- globalMessageProxyHandler.sendMessage(s, delay, VotingPluginWire.bungeeTimeChange());
- delay++;
+ activeServers.add(s);
+ allSupportBoundaryProtocol &= VotingPluginWire.supportsTimeChangeBoundaryProtocol(
+ lastOnlineStr, getGlobalDataHandler().getString(s,
+ VotingPluginWire.TIME_CHANGE_BOUNDARY_PROTOCOL_KEY));
} else {
warn("Server " + s + " hasn't been online recently");
}
@@ -467,6 +469,20 @@ public void timeChanged(TimeType type, boolean fake, boolean pre, boolean post)
warn("Server " + s + " global data handler disabled?");
}
}
+ String publishedTransition = allSupportBoundaryProtocol ? transitionId
+ : VotingPluginWire.LEGACY_TIME_CHANGE_TRANSITION;
+ int delay = 1;
+ for (String server : activeServers) {
+ HashMap dataToSet = new HashMap<>();
+ dataToSet.put(VotingPluginWire.timeChangeTransitionKey(type.toString()),
+ new DataValueString(publishedTransition));
+ dataToSet.put("LastUpdated", new DataValueString(
+ "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli()));
+ dataToSet.put("FinishedProcessing", new DataValueBoolean(false));
+ dataToSet.put(type.toString(), new DataValueBoolean(true));
+ getGlobalDataHandler().setData(server, dataToSet);
+ globalMessageProxyHandler.sendMessage(server, delay++, VotingPluginWire.bungeeTimeChange());
+ }
globalDataHandler.onTimeChange(type);
}
@@ -488,16 +504,45 @@ public boolean isAutomaticTimeChangeEnabled(TimeType type) {
}
public void onTimeChangedFailed(String srv, TimeType type) {
- getGlobalDataHandler().setBoolean(srv, type.toString(), false);
- getGlobalDataHandler().setBoolean(srv, "FinishedProcessing", true);
- getGlobalDataHandler().setBoolean(srv, "Processing", false);
+ HashMap failed = new HashMap<>();
+ failed.put(type.toString(), new DataValueBoolean(false));
+ failed.put("FinishedProcessing", new DataValueBoolean(true));
+ failed.put("Processing", new DataValueBoolean(false));
+ // A participant that failed did not prove either the negotiated boundary
+ // or the legacy reset path. Remove its marker before aggregate completion.
+ failed.put(VotingPluginWire.timeChangeTransitionKey(type.toString()), new DataValueString(""));
+ failed.put(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), new DataValueString(""));
+ getGlobalDataHandler().setData(srv, failed);
}
public void onTimeChangedFinished(TimeType type) {
- if (type.equals(TimeType.MONTH)) {
- getProxyMySQL().copyColumnData(TopVoter.Monthly.getColumnName(), "LastMonthTotal");
+ boolean boundaryCaptured = false;
+ boolean legacyBoundary = false;
+ String boundaryCapturedKey = VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString());
+ String transitionKey = VotingPluginWire.timeChangeTransitionKey(type.toString());
+ for (String server : getAllAvailableServers()) {
+ if (!getGlobalDataHandler().getGlobalMysql().containsKey(server)) continue;
+ String expected = getGlobalDataHandler().getString(server, transitionKey);
+ String captured = getGlobalDataHandler().getString(server, boundaryCapturedKey);
+ if (VotingPluginWire.LEGACY_TIME_CHANGE_TRANSITION.equals(expected)) {
+ legacyBoundary = true;
+ continue;
+ }
+ if (expected != null && !expected.isBlank() && expected.equals(captured)) {
+ boundaryCaptured = true;
+ }
+ }
+ if (legacyBoundary) {
+ if (type.equals(TimeType.MONTH)) {
+ getProxyMySQL().copyColumnData(TopVoter.Monthly.getColumnName(), "LastMonthTotal");
+ }
+ getProxyMySQL().wipeColumnData(TopVoter.of(type).getColumnName(), DataType.INTEGER);
+ } else if (boundaryCaptured) {
+ getProxyMySQL().wipeColumnData(TopVoter.of(type).getColumnName(), DataType.INTEGER);
+ } else {
+ warn("Retaining " + TopVoter.of(type).getColumnName()
+ + " because no backend confirmed the time-change boundary");
}
- getProxyMySQL().wipeColumnData(TopVoter.of(type).getColumnName(), DataType.INTEGER);
if (!getConfig().getGlobalDataEnabled()) {
return;
@@ -654,6 +699,14 @@ public void onTimeChangedFinished(TimeType type) {
getGlobalDataHandler().getGlobalMysql().alterColumnType("ForceUpdate", "VARCHAR(5)");
getGlobalDataHandler().getGlobalMysql().alterColumnType("ForceUpdateId", "VARCHAR(36)");
getGlobalDataHandler().getGlobalMysql().alterColumnType("LastUpdated", "MEDIUMTEXT");
+ for (TimeType type : TimeType.values()) {
+ getGlobalDataHandler().getGlobalMysql().alterColumnType(
+ VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()), "VARCHAR(36)");
+ getGlobalDataHandler().getGlobalMysql().alterColumnType(
+ VotingPluginWire.timeChangeTransitionKey(type.toString()), "VARCHAR(36)");
+ }
+ getGlobalDataHandler().getGlobalMysql().alterColumnType(
+ VotingPluginWire.TIME_CHANGE_BOUNDARY_PROTOCOL_KEY, "VARCHAR(32)");
}
// column types (unchanged from original)
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java
index 393995e27c..dc89e04d3a 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java
@@ -33,6 +33,27 @@ public final class VotingPluginWire {
private VotingPluginWire() {
}
+ public static String timeChangeBoundaryCapturedKey(String type) {
+ return "BoundaryCaptured" + type;
+ }
+
+ public static String timeChangeTransitionKey(String type) {
+ return "BoundaryTransition" + type;
+ }
+
+ public static final String TIME_CHANGE_BOUNDARY_PROTOCOL_KEY = "BoundaryProtocol";
+ public static final String TIME_CHANGE_BOUNDARY_PROTOCOL_VERSION = "1";
+ public static final String LEGACY_TIME_CHANGE_TRANSITION = "LEGACY";
+
+ public static String timeChangeBoundaryProtocolHeartbeat(String lastOnline) {
+ return TIME_CHANGE_BOUNDARY_PROTOCOL_VERSION + ":" + lastOnline;
+ }
+
+ public static boolean supportsTimeChangeBoundaryProtocol(String lastOnline, String advertisedProtocol) {
+ return lastOnline != null && !lastOnline.isBlank()
+ && timeChangeBoundaryProtocolHeartbeat(lastOnline).equals(advertisedProtocol);
+ }
+
public static final int SCHEMA_VERSION = 1;
// =========================
@@ -581,6 +602,13 @@ public static Vote readVote(JsonEnvelope env) {
setTotals, manageTotals, broadcast, num, numberOfVotes);
}
+ /** Resolves the current vote ID or the legacy ID carried in the totals snapshot. */
+ @SuppressWarnings("deprecation")
+ public static UUID resolveVoteId(Vote vote) {
+ if (vote == null || vote.voteId != null) return vote == null ? null : vote.voteId;
+ return VoteTotalsSnapshot.parseStorage(vote.totals).getVoteUUID();
+ }
+
public static final class VoteDelayRejected {
public final String player;
public final String uuid;
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/SpecialRewards.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/SpecialRewards.java
index ed50217205..89becc40c8 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/SpecialRewards.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/SpecialRewards.java
@@ -33,9 +33,19 @@ public SpecialRewards(VotingPluginMain plugin) {
* @param type the streak type (day/week/month)
* @param forceBungee whether to force bungee mode
* @return true if a reward was given
- */
- public boolean checkVoteStreak(UUID voteUUID, VotingPluginUser user, String type, boolean forceBungee) {
- boolean gotReward = false;
+ */
+ public boolean checkVoteStreak(UUID voteUUID, VotingPluginUser user, String type, boolean forceBungee) {
+ int currentStreak = 0;
+ if (type.equalsIgnoreCase("day")) currentStreak = user.getDayVoteStreak();
+ else if (type.equalsIgnoreCase("week")) currentStreak = user.getWeekVoteStreak();
+ else if (type.equalsIgnoreCase("month")) currentStreak = user.getMonthVoteStreak();
+ return checkVoteStreakAt(voteUUID, user, type, currentStreak, forceBungee);
+ }
+
+ /** Checks rewards against a durable streak value captured before later period changes. */
+ public boolean checkVoteStreakAt(UUID voteUUID, VotingPluginUser user, String type, int currentStreak,
+ boolean forceBungee) {
+ boolean gotReward = false;
Set streaks = plugin.getSpecialRewardsConfig().getVoteStreakVotes(type);
for (String streak : streaks) {
@@ -54,25 +64,17 @@ public boolean checkVoteStreak(UUID voteUUID, VotingPluginUser user, String type
if (plugin.getSpecialRewardsConfig().getVoteStreakRewardEnabled(type, streak)
&& plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(),
plugin.getSpecialRewardsConfig().getVoteStreakRewardsPath(type, "" + streak))) {
- int curStreak = 0;
- if (type.equalsIgnoreCase("day")) {
- curStreak = user.getDayVoteStreak();
- } else if (type.equalsIgnoreCase("week")) {
- curStreak = user.getWeekVoteStreak();
- } else if (type.equalsIgnoreCase("month")) {
- curStreak = user.getMonthVoteStreak();
- }
- if (!multiple) {
- if (curStreak == streakRequired) {
- giveVoteStreakReward(voteUUID, user, user.isOnline(), type, "" + streakRequired,
- curStreak, forceBungee);
+ if (!multiple) {
+ if (currentStreak == streakRequired) {
+ giveVoteStreakReward(voteUUID, user, user.isOnline(), type, "" + streakRequired,
+ currentStreak, forceBungee);
gotReward = true;
plugin.debug(
user.getPlayerName() + " got VoteStreak " + streakRequired + " for " + type);
}
} else {
- if (curStreak != 0 && curStreak % streakRequired == 0) {
- giveVoteStreakReward(voteUUID, user, user.isOnline(), type, streak, curStreak,
+ if (currentStreak != 0 && currentStreak % streakRequired == 0) {
+ giveVoteStreakReward(voteUUID, user, user.isOnline(), type, streak, currentStreak,
forceBungee);
gotReward = true;
plugin.debug(
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java
index 98d71533b1..0387ac1d7c 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java
@@ -4,6 +4,7 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
+import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
@@ -13,6 +14,7 @@
import com.bencodez.advancedcore.api.messages.PlaceholderUtils;
import com.bencodez.advancedcore.api.misc.MiscUtils;
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
import com.bencodez.advancedcore.api.time.TimeType;
import com.bencodez.advancedcore.api.time.events.DayChangeEvent;
import com.bencodez.advancedcore.api.time.events.MonthChangeEvent;
@@ -47,9 +49,13 @@ public VoteParty(VotingPluginMain plugin) {
* @param user the voting plugin user
*/
public void addTotal(VotingPluginUser user) {
- setTotalVotes(getTotalVotes() + 1);
- user.setVotePartyVotes(user.getVotePartyVotes() + 1);
- plugin.getPlaceholders().onVotePartyUpdate();
+ addTotal(user, null);
+ }
+
+ public void addTotal(VotingPluginUser user, UUID voteId) {
+ boolean totalAdded = plugin.getServerData().incrementVotePartyTotal(voteId);
+ user.addVotePartyVote(voteId);
+ if (totalAdded) plugin.getPlaceholders().onVotePartyUpdate();
}
/**
@@ -253,13 +259,10 @@ public void giveRewards(VotingPluginUser orgUser, boolean forceBungee) {
*/
@EventHandler
public void onDayChange(DayChangeEvent event) {
- if (event.getTransition() != null
- && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.DAY)) {
- return;
- }
- if (plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()) {
- reset(true);
- }
+ if (event.getTransition() != null && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.DAY)
+ && !plugin.getServerData().isTimeChangeRecoveryActive(event.getTransition())) return;
+ runRecoverableVotePartyReset(event.getTransition(), "VotePartyDayReset",
+ plugin.getSpecialRewardsConfig().isVotePartyResetEachDay());
}
/**
@@ -269,17 +272,12 @@ public void onDayChange(DayChangeEvent event) {
*/
@EventHandler
public void onMonthChange(MonthChangeEvent event) {
- if (event.getTransition() != null
- && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.MONTH)) {
- return;
- }
- if (plugin.getSpecialRewardsConfig().isVotePartyResetMonthly()) {
- reset(true);
- }
-
- if (plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesMonthly()) {
- plugin.getServerData().setVotePartyExtraRequired(0);
- }
+ if (event.getTransition() != null && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.MONTH)
+ && !plugin.getServerData().isTimeChangeRecoveryActive(event.getTransition())) return;
+ runRecoverableVotePartyReset(event.getTransition(), "VotePartyMonthReset",
+ plugin.getSpecialRewardsConfig().isVotePartyResetMonthly());
+ runRecoverableExtraReset(event.getTransition(), "VotePartyMonthExtraVotes",
+ plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesMonthly());
}
/**
@@ -289,16 +287,76 @@ public void onMonthChange(MonthChangeEvent event) {
*/
@EventHandler
public void onWeekChange(WeekChangeEvent event) {
- if (event.getTransition() != null
- && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.WEEK)) {
+ if (event.getTransition() != null && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.WEEK)
+ && !plugin.getServerData().isTimeChangeRecoveryActive(event.getTransition())) return;
+ runRecoverableVotePartyReset(event.getTransition(), "VotePartyWeekReset",
+ plugin.getSpecialRewardsConfig().isVotePartyResetWeekly());
+ runRecoverableExtraReset(event.getTransition(), "VotePartyWeekExtraVotes",
+ plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesWeekly());
+ }
+
+ private void runRecoverableVotePartyReset(TimeChangeTransition transition, String effect, boolean configured) {
+ if (transition == null) {
+ if (configured) reset(true);
return;
}
- if (plugin.getSpecialRewardsConfig().isVotePartyResetWeekly()) {
- reset(true);
+ runRecoverableReset(transition, effect, configured, () -> {
+ String generation = transition.getId() + ':' + effect;
+ if (!copyRecoverableUserCountBoundary("vote-party-copy:" + generation)) {
+ throw new IllegalStateException("Unable to durably capture VoteParty user counts");
+ }
+ plugin.getServerData().prepareTimeChangeVotePartyReset(transition, effect);
+ if (!resetRecoverableUserCounts("vote-party-reset:" + generation)) {
+ throw new IllegalStateException("Unable to durably reset VoteParty user counts");
+ }
+ plugin.getServerData().completeTimeChangeVotePartyReset(transition, effect);
+ });
+ }
+
+ /** Storage boundary kept separate so lifecycle tests do not require a live database. */
+ public boolean copyRecoverableUserCountBoundary(String generation) {
+ return state.copyUserCountBoundary(generation);
+ }
+
+ /** Storage boundary kept separate so lifecycle tests do not require a live database. */
+ public boolean resetRecoverableUserCounts(String generation) {
+ return state.resetUserCounts(generation);
+ }
+
+ private void runRecoverableExtraReset(TimeChangeTransition transition, String effect, boolean configured) {
+ if (transition == null) {
+ if (configured) plugin.getServerData().setVotePartyExtraRequired(0);
+ return;
}
+ runRecoverableReset(transition, effect, configured,
+ () -> plugin.getServerData().completeTimeChangeVotePartyExtraReset(transition, effect));
+ }
- if (plugin.getSpecialRewardsConfig().isVotePartyResetExtraVotesWeekly()) {
- plugin.getServerData().setVotePartyExtraRequired(0);
+ private synchronized void runRecoverableReset(TimeChangeTransition transition, String effect,
+ boolean configured, Runnable reset) {
+ if (transition == null) {
+ reset.run();
+ return;
+ }
+ TimeChangeTransition.Lease lease = transition.retain();
+ try {
+ if (transition.isCancellationRequested()) {
+ throw new java.util.concurrent.CancellationException("Time transition was cancelled");
+ }
+ plugin.getServerData().beginTimeChangeRecovery(transition);
+ boolean required = plugin.getServerData().prepareTimeChangeEffectPolicy(transition, effect, configured);
+ if (required && !plugin.getServerData().hasTimeChangeEffect(transition, effect)) {
+ reset.run();
+ }
+ if (transition.isCancellationRequested()) {
+ throw new java.util.concurrent.CancellationException("Time transition was cancelled");
+ }
+ lease.complete();
+ } catch (Throwable failure) {
+ lease.fail(failure);
+ plugin.getLogger().warning("VoteParty time-change effect remains pending: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
}
}
@@ -358,28 +416,30 @@ public synchronized void vote(VotingPluginUser user, boolean realVote, boolean f
vote(user, realVote, forceBungee, user.isOnline());
}
- /**
- * Processes a vote for the vote party system using the player's online state
- * when the vote occurred. Proxy deliveries can be replayed after the player
- * joins, so current online state must not decide whether an originally offline
- * vote counts.
- *
- * @param user the voting plugin user
- * @param realVote whether this is a real vote
- * @param forceBungee whether to force Bungee processing
- * @param onlineAtVoteTime whether the player was online when the vote occurred
- */
+ public synchronized void vote(VotingPluginUser user, boolean realVote, boolean forceBungee, UUID voteId) {
+ vote(user, realVote, forceBungee, voteId, user.isOnline());
+ }
+
+ /** Preserves the original presence decision for delayed proxy deliveries. */
public synchronized void vote(VotingPluginUser user, boolean realVote, boolean forceBungee,
boolean onlineAtVoteTime) {
- if (plugin.getSpecialRewardsConfig().isVotePartyEnabled()) {
- if (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || realVote) {
- if (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || onlineAtVoteTime) {
- addTotal(user);
- addVotePlayer(user);
- check(user, forceBungee);
- checkVoteReminder(user);
- }
- }
- }
+ vote(user, realVote, forceBungee, null, onlineAtVoteTime);
+ }
+
+ private void vote(VotingPluginUser user, boolean realVote, boolean forceBungee, UUID voteId,
+ boolean onlineAtVoteTime) {
+ boolean eligible = plugin.getSpecialRewardsConfig().isVotePartyEnabled()
+ && (plugin.getSpecialRewardsConfig().isVotePartyCountFakeVotes() || realVote)
+ && (plugin.getSpecialRewardsConfig().isVotePartyCountOfflineVotes() || onlineAtVoteTime);
+ voteAdmitted(user, forceBungee, voteId, eligible);
+ }
+
+ /** Applies the VoteParty eligibility decision captured during durable vote admission. */
+ public synchronized void voteAdmitted(VotingPluginUser user, boolean forceBungee, UUID voteId, boolean eligible) {
+ if (!eligible) return;
+ addTotal(user, voteId);
+ addVotePlayer(user);
+ check(user, forceBungee);
+ checkVoteReminder(user);
}
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java
index 99d31ce860..82ee7f2d94 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java
@@ -5,6 +5,7 @@
import com.bencodez.simpleapi.sql.DataType;
import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.topvoter.TimeChangeTotalReset;
/** Owns persistent VoteParty totals, participant state, and per-user counters. */
public final class VotePartyState {
@@ -12,6 +13,7 @@ public final class VotePartyState {
private static final String TOTAL_PATH = "VoteParty.Total";
private static final String VOTED_PATH = "VoteParty.Voted";
private static final String USER_COUNT_KEY = "VotePartyVotes";
+ private static final String LAST_USER_COUNT_KEY = "LastVotePartyVotes";
private final VotingPluginMain plugin;
@@ -59,4 +61,12 @@ public void reset(boolean resetTotal) {
public void resetUserCounts() {
plugin.getUserManager().removeAllKeyValues(USER_COUNT_KEY, DataType.INTEGER);
}
+
+ public boolean copyUserCountBoundary(String generation) {
+ return TimeChangeTotalReset.copyBoundary(plugin, USER_COUNT_KEY, LAST_USER_COUNT_KEY, generation);
+ }
+
+ public boolean resetUserCounts(String generation) {
+ return TimeChangeTotalReset.reset(plugin, USER_COUNT_KEY, LAST_USER_COUNT_KEY, generation);
+ }
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java
index cea9280a18..0ecd6d597d 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/test/VoteTester.java
@@ -104,6 +104,7 @@ public void run() {
public void testSpam(int amount, String name, String site) {
AtomicBoolean rejectionLogged = new AtomicBoolean();
+ AtomicBoolean accountingFailureLogged = new AtomicBoolean();
for (int i = 0; i < amount; i++) {
plugin.getBukkitScheduler().runTaskAsynchronously(plugin, new Runnable() {
@@ -113,9 +114,14 @@ public void run() {
@Override
public void run() {
- PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(site, false), name,
- plugin.getVoteSiteManager().getVoteSiteServiceSite(site), false);
- plugin.getServer().getPluginManager().callEvent(voteEvent);
+ PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(site, false), name,
+ plugin.getVoteSiteManager().getVoteSiteServiceSite(site), false);
+ plugin.getServer().getPluginManager().callEvent(voteEvent);
+ if (voteEvent.isProcessingIncomplete()
+ && accountingFailureLogged.compareAndSet(false, true)) {
+ plugin.getLogger().warning(
+ "One or more spam-test votes failed shared accounting admission.");
+ }
}
}) && rejectionLogged.compareAndSet(false, true)) {
plugin.getLogger().warning("One or more spam-test votes could not be submitted because vote processing is busy.");
@@ -137,7 +143,11 @@ public void run() {
long start1 = System.currentTimeMillis();
PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(site, false), name,
plugin.getVoteSiteManager().getVoteSiteServiceSite(site), false);
- plugin.getServer().getPluginManager().callEvent(voteEvent);
+ plugin.getServer().getPluginManager().callEvent(voteEvent);
+ if (voteEvent.isProcessingIncomplete()) {
+ plugin.getLogger().warning("Vote performance test stopped after shared accounting admission failed.");
+ return;
+ }
long start2 = System.currentTimeMillis();
timesPerVote.add(start2 - start1);
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java
index 1a82995ff7..644ffe27a6 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java
@@ -2,8 +2,15 @@
import java.time.LocalDateTime;
import java.time.ZoneId;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.List;
import java.util.Queue;
-import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.UUID;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -13,25 +20,33 @@
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
import com.bencodez.advancedcore.api.time.events.DateChangedEvent;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.util.VoteTaskAdmission;
-
-import lombok.Getter;
+import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService;
/**
* The TimeQueueHandler class manages time-based vote queue processing.
*/
public class TimeQueueHandler implements Listener {
private static final long TICKS_PER_SECOND = 20L;
- @Getter
- private Queue timeChangeQueue = new ConcurrentLinkedQueue<>();
+ private final Deque timeChangeQueue = new ConcurrentLinkedDeque<>();
+ private final Deque completedDeliveries = new ArrayDeque<>();
+ private final Object queuePersistenceLock = new Object();
+ /** Guarded by {@link #queuePersistenceLock}. */
+ private VoteTimeQueue inFlightVote;
private VotingPluginMain plugin;
private final AtomicBoolean retryPending = new AtomicBoolean();
+ private final AtomicBoolean retryPersistenceRequired = new AtomicBoolean();
private final AtomicInteger retryAttempts = new AtomicInteger();
+ public Queue getTimeChangeQueue() {
+ return timeChangeQueue;
+ }
+
/**
* Constructs a new TimeQueueHandler.
*
@@ -49,20 +64,62 @@ public TimeQueueHandler(VotingPluginMain plugin) {
* @param voteSiteName the vote site name
*/
public void addVote(String voteUsername, String voteSiteName) {
- timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName,
+ addVote(null, voteUsername, voteSiteName);
+ }
+
+ /** Adds a vote while preserving the identity assigned at reception. */
+ public void addVote(UUID voteId, String voteUsername, String voteSiteName) {
+ timeChangeQueue.add(new VoteTimeQueue(voteId, voteUsername, voteSiteName,
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
}
+ /** Adds and persists a vote before its previous durable owner may acknowledge it. */
+ public boolean addVoteDurably(UUID voteId, String voteUsername, String voteSiteName) {
+ VoteTimeQueue vote = new VoteTimeQueue(voteId, voteUsername, voteSiteName,
+ LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+ synchronized (queuePersistenceLock) {
+ timeChangeQueue.add(vote);
+ try {
+ plugin.getServerData().replaceTimedVoteState(pendingVoteSnapshot(), retirementSnapshot());
+ return true;
+ } catch (RuntimeException persistenceFailure) {
+ timeChangeQueue.remove(vote);
+ plugin.getLogger().severe("Unable to persist a vote handed to the time-change queue");
+ plugin.debug(persistenceFailure);
+ return false;
+ }
+ }
+ }
+
/**
* Loads cached votes from server data and schedules queue processing.
*/
public void load() {
- for (String str : plugin.getServerData().getTimedVoteCacheKeys()) {
+ completedDeliveries.addAll(plugin.getServerData().getTimedVoteRetirements());
+ List keys = new ArrayList<>(plugin.getServerData().getTimedVoteCacheKeys());
+ keys.sort(Comparator.comparingInt(TimeQueueHandler::timedVoteCacheIndex));
+ for (String str : keys) {
ConfigurationSection data = plugin.getServerData().getTimedVoteCacheSection(str);
- timeChangeQueue
- .add(new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time")));
+ UUID voteId = null;
+ try {
+ String storedVoteId = data.getString("VoteId", "");
+ if (storedVoteId != null && !storedVoteId.isEmpty()) voteId = UUID.fromString(storedVoteId);
+ } catch (IllegalArgumentException invalidVoteId) {
+ plugin.debug(invalidVoteId);
+ }
+ timeChangeQueue.add(new VoteTimeQueue(voteId, data.getString("Name"), data.getString("Service"),
+ data.getLong("Time")));
}
scheduleQueueProcessing(120, TimeUnit.SECONDS);
+ if (!completedDeliveries.isEmpty()) scheduleRetry(true);
+ }
+
+ private static int timedVoteCacheIndex(String key) {
+ try {
+ return Integer.parseInt(key);
+ } catch (NumberFormatException ignored) {
+ return Integer.MAX_VALUE;
+ }
}
/**
@@ -72,33 +129,54 @@ public void load() {
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void postTimeChange(DateChangedEvent event) {
- scheduleQueueProcessing(5, TimeUnit.SECONDS);
+ TimeChangeTransition transition = event == null ? null : event.getTransition();
+ if (transition == null) {
+ scheduleQueueProcessing(5, TimeUnit.SECONDS);
+ return;
+ }
+ TimeChangeTransition.Lease lease = transition.retain();
+ try {
+ ensureTransitionActive(transition);
+ scheduleQueueProcessing(5, TimeUnit.SECONDS);
+ ensureTransitionActive(transition);
+ lease.complete();
+ } catch (Throwable failure) {
+ lease.fail(failure);
+ plugin.getLogger().warning("Time-queue post-period scheduling remains pending: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
}
private void scheduleQueueProcessing(long delay, TimeUnit unit) {
- boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), () -> {
- // Clear only after the bounded executor has admitted the task. If it is
- // rejected, shutdown persistence can still recover the in-memory queue.
- plugin.getServerData().clearTimedVoteCache();
- processQueue();
- }, delay, unit);
+ boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), this::processQueue, delay, unit);
if (!admitted) {
plugin.getLogger().warning("Unable to schedule time-queue processing because vote processing is busy; queued votes were retained.");
- scheduleRetry();
+ scheduleRetry(false);
} else {
retryAttempts.set(0);
}
}
- private void scheduleRetry() {
- if (timeChangeQueue.isEmpty() || !retryPending.compareAndSet(false, true)) return;
+ private void scheduleRetry(boolean persistenceRequired) {
+ if (persistenceRequired) retryPersistenceRequired.set(true);
+ if ((!persistenceRequired && timeChangeQueue.isEmpty()) || !retryPending.compareAndSet(false, true)) return;
+ if (!plugin.isEnabled()) {
+ retryPending.set(false);
+ return;
+ }
int attempt = Math.min(6, retryAttempts.getAndIncrement());
long delaySeconds = Math.min(60L, 1L << attempt);
long delayTicks = delaySeconds * TICKS_PER_SECOND;
try {
plugin.getBukkitScheduler().runTaskLaterAsynchronously(plugin, () -> {
retryPending.set(false);
- if (!timeChangeQueue.isEmpty()) scheduleQueueProcessing(0, TimeUnit.SECONDS);
+ if (retryPersistenceRequired.getAndSet(false) && !persistQueueSnapshot()) {
+ scheduleRetry(true);
+ return;
+ }
+ if (timeChangeQueue.isEmpty()) return;
+ scheduleQueueProcessing(0, TimeUnit.SECONDS);
}, delayTicks);
} catch (RuntimeException rejected) {
retryPending.set(false);
@@ -106,18 +184,126 @@ private void scheduleRetry() {
}
}
+ private boolean persistQueueSnapshot() {
+ return persistQueueSnapshot(null);
+ }
+
+ private boolean persistQueueSnapshot(UUID completedVoteId) {
+ List durableCompletions;
+ synchronized (queuePersistenceLock) {
+ List proposedRetirements = retirementSnapshot();
+ if (completedVoteId != null && !proposedRetirements.contains(completedVoteId)) {
+ proposedRetirements.add(completedVoteId);
+ }
+ List proposedVotes = completedVoteId == null
+ ? pendingVoteSnapshot() : new ArrayList<>(timeChangeQueue);
+ try {
+ plugin.getServerData().replaceTimedVoteState(proposedVotes, proposedRetirements);
+ if (completedVoteId != null) {
+ inFlightVote = null;
+ if (!completedDeliveries.contains(completedVoteId)) completedDeliveries.addLast(completedVoteId);
+ }
+ durableCompletions = retirementSnapshot();
+ } catch (RuntimeException persistenceFailure) {
+ plugin.getLogger().severe("Unable to persist the time-queue retry; the vote remains in memory");
+ plugin.debug(persistenceFailure);
+ return false;
+ }
+ }
+ List successfulRetirements = new ArrayList<>();
+ for (UUID voteId : durableCompletions) {
+ try {
+ VoteShopPurchaseService.completeVoteDelivery(plugin, voteId);
+ successfulRetirements.add(voteId);
+ } catch (RuntimeException persistenceFailure) {
+ plugin.getLogger().severe("Unable to retire completed timed vote " + voteId
+ + "; its durable completion will be retried");
+ plugin.debug(persistenceFailure);
+ }
+ }
+ if (!successfulRetirements.isEmpty()) {
+ synchronized (queuePersistenceLock) {
+ completedDeliveries.removeAll(successfulRetirements);
+ try {
+ plugin.getServerData().replaceTimedVoteState(pendingVoteSnapshot(), retirementSnapshot());
+ } catch (RuntimeException persistenceFailure) {
+ for (UUID voteId : successfulRetirements) {
+ if (!completedDeliveries.contains(voteId)) completedDeliveries.addLast(voteId);
+ }
+ plugin.getLogger().severe("Unable to persist completed timed-vote retirements");
+ plugin.debug(persistenceFailure);
+ scheduleRetry(true);
+ return true;
+ }
+ }
+ }
+ if (successfulRetirements.size() != durableCompletions.size()) scheduleRetry(true);
+ return true;
+ }
+
+ private void ensureTransitionActive(TimeChangeTransition transition) {
+ if (!plugin.isEnabled() || transition.isCancellationRequested()) {
+ throw new CancellationException("Time transition was cancelled before time-queue scheduling completed");
+ }
+ }
+
/**
* Processes all votes in the queue.
*/
public void processQueue() {
- while (getTimeChangeQueue().size() > 0) {
- VoteTimeQueue vote = getTimeChangeQueue().remove();
+ while (true) {
+ VoteTimeQueue vote;
+ synchronized (queuePersistenceLock) {
+ if (inFlightVote != null) return;
+ vote = timeChangeQueue.pollFirst();
+ if (vote == null) return;
+ inFlightVote = vote;
+ }
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(),
vote.getService(), true);
- voteEvent.setTime(voteEvent.getTime());
+ voteEvent.setTime(vote.getTime());
+ voteEvent.setVoteId(vote.getVoteId() == null ? vote.legacyTimedVoteId() : vote.getVoteId());
+ voteEvent.setDeferredDeliveryCompletion(true);
plugin.getServer().getPluginManager().callEvent(voteEvent);
+ if (voteEvent.isProcessingIncomplete()) {
+ if (voteEvent.isReplayUnsafe()) {
+ try {
+ plugin.getServerData().quarantineTimedVote(vote);
+ } catch (RuntimeException persistenceFailure) {
+ synchronized (queuePersistenceLock) {
+ timeChangeQueue.addFirst(vote);
+ if (inFlightVote == vote) inFlightVote = null;
+ }
+ plugin.getLogger().severe("Unable to quarantine ambiguous timed vote "
+ + voteEvent.getVoteId() + "; retained it in the durable retry queue");
+ plugin.debug(persistenceFailure);
+ scheduleRetry(!persistQueueSnapshot());
+ return;
+ }
+ plugin.getLogger().severe("Timed vote " + voteEvent.getVoteId()
+ + " reached an ambiguous post-effect failure and was retained for manual review");
+ synchronized (queuePersistenceLock) {
+ if (inFlightVote == vote) inFlightVote = null;
+ }
+ if (!persistQueueSnapshot()) {
+ scheduleRetry(true);
+ return;
+ }
+ continue;
+ }
+ synchronized (queuePersistenceLock) {
+ timeChangeQueue.addFirst(vote);
+ if (inFlightVote == vote) inFlightVote = null;
+ }
+ scheduleRetry(!persistQueueSnapshot());
+ return;
+ }
+ if (!persistQueueSnapshot(voteEvent.getVoteId())) {
+ scheduleRetry(true);
+ return;
+ }
if (voteEvent.isCancelled()) {
plugin.debug("Vote cancelled");
return;
@@ -129,13 +315,23 @@ public void processQueue() {
* Saves pending votes to server data.
*/
public void save() {
- if (!timeChangeQueue.isEmpty()) {
- int num = 0;
- for (VoteTimeQueue vote : timeChangeQueue) {
- plugin.getServerData().addTimeVoted(num, vote);
- num++;
- }
+ synchronized (queuePersistenceLock) {
+ List pending = pendingVoteSnapshot();
+ plugin.getServerData().replaceTimedVoteState(pending, retirementSnapshot());
+ timeChangeQueue.clear();
}
- timeChangeQueue.clear();
+ }
+
+ /** Must be called while holding {@link #queuePersistenceLock}. */
+ private List retirementSnapshot() {
+ return new ArrayList<>(completedDeliveries);
+ }
+
+ /** Must be called while holding {@link #queuePersistenceLock}. */
+ private List pendingVoteSnapshot() {
+ List pending = new ArrayList<>(timeChangeQueue.size() + (inFlightVote == null ? 0 : 1));
+ if (inFlightVote != null) pending.add(inFlightVote);
+ pending.addAll(timeChangeQueue);
+ return pending;
}
}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java
new file mode 100644
index 0000000000..a5708ca01c
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TimeChangeTotalReset.java
@@ -0,0 +1,206 @@
+package com.bencodez.votingplugin.topvoter;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.user.PeriodTotalMutationFence;
+import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService;
+
+/** Applies a period-total reset once even when its outer checkpoint must retry. */
+public final class TimeChangeTotalReset {
+ private static final String SQLITE_JOURNAL = "VotingPlugin_TimeTotalResets";
+
+ private TimeChangeTotalReset() { }
+
+ public static boolean reset(VotingPluginMain plugin, String column, String previousColumn, String generation) {
+ boolean[] reset = { false };
+ PeriodTotalMutationFence.withReset(() -> {
+ // Drain queued absolute totals while new total mutations are excluded. The
+ // reset then subtracts the boundary copy, preserving votes accepted while
+ // earlier recovery phases were running.
+ plugin.getUserManager().getDataManager().clearCache();
+ if (UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ reset[0] = VoteShopPurchaseService.resetMysqlPeriodTotal(plugin, column, previousColumn, generation);
+ return;
+ }
+ if (!UserStorage.SQLITE.equals(plugin.getStorageType())) return;
+ UserTable table = plugin.getSQLiteUserTable();
+ try {
+ String url = table.getSqLite().getSQLConnection().getMetaData().getURL();
+ try (Connection connection = DriverManager.getConnection(url)) {
+ reset[0] = resetSqlite(connection, table.getName(), column, previousColumn, generation);
+ }
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically reset period totals: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
+ });
+ return reset[0];
+ }
+
+ public static boolean copyBoundary(VotingPluginMain plugin, String column, String previousColumn, String generation) {
+ boolean[] copied = { false };
+ PeriodTotalMutationFence.withReset(() -> {
+ plugin.getUserManager().getDataManager().clearCache();
+ if (UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ copied[0] = VoteShopPurchaseService.copyMysqlPeriodBoundary(plugin, column, previousColumn, generation);
+ return;
+ }
+ if (!UserStorage.SQLITE.equals(plugin.getStorageType())) return;
+ UserTable table = plugin.getSQLiteUserTable();
+ try {
+ String url = table.getSqLite().getSQLConnection().getMetaData().getURL();
+ try (Connection connection = DriverManager.getConnection(url)) {
+ copied[0] = copyBoundarySqlite(connection, table.getName(), column, previousColumn, generation);
+ }
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically copy period totals: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
+ });
+ return copied[0];
+ }
+
+ /** Captures the daily-streak value and timestamp as one recoverable boundary. */
+ public static boolean copyDailyStreakBoundary(VotingPluginMain plugin, String generation) {
+ boolean[] copied = { false };
+ PeriodTotalMutationFence.withReset(() -> {
+ plugin.getUserManager().getDataManager().clearCache();
+ if (UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ copied[0] = VoteShopPurchaseService.copyMysqlDailyStreakBoundary(plugin, "DayVoteStreak",
+ "LastDayVoteStreak", "DayVoteStreakLastUpdate", "LastDayVoteStreakLastUpdate", generation);
+ return;
+ }
+ if (!UserStorage.SQLITE.equals(plugin.getStorageType())) return;
+ UserTable table = plugin.getSQLiteUserTable();
+ try {
+ String url = table.getSqLite().getSQLConnection().getMetaData().getURL();
+ try (Connection connection = DriverManager.getConnection(url)) {
+ copied[0] = copyDailyStreakBoundarySqlite(connection, table.getName(), generation);
+ }
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically copy daily streak boundary: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
+ });
+ return copied[0];
+ }
+
+ /** Resets an auxiliary integer column once for a recoverable listener effect. */
+ public static boolean resetToZero(VotingPluginMain plugin, String column, String generation) {
+ plugin.getUserManager().getDataManager().clearCache();
+ if (UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ return VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, column, generation);
+ }
+ if (!UserStorage.SQLITE.equals(plugin.getStorageType())) return false;
+ UserTable table = plugin.getSQLiteUserTable();
+ try {
+ String url = table.getSqLite().getSQLConnection().getMetaData().getURL();
+ try (Connection connection = DriverManager.getConnection(url)) {
+ return resetSqliteToZero(connection, table.getName(), column, generation);
+ }
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically reset user counters: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return false;
+ }
+ }
+
+ static boolean resetSqliteToZero(Connection connection, String table, String column, String generation)
+ throws SQLException {
+ return resetSqliteExpression(connection, table, column, generation, "0");
+ }
+
+ static boolean copyBoundarySqlite(Connection connection, String table, String column, String previousColumn,
+ String generation) throws SQLException {
+ if (!safeIdentifier(column) || !safeIdentifier(previousColumn)) {
+ throw new SQLException("Invalid period boundary column");
+ }
+ return resetSqliteExpression(connection, table, previousColumn, generation,
+ "COALESCE(" + quote(column) + ", 0)");
+ }
+
+ static boolean copyDailyStreakBoundarySqlite(Connection connection, String table, String generation)
+ throws SQLException {
+ if (!safeIdentifier(table)) throw new SQLException("Invalid daily streak boundary table");
+ return resetSqliteExpression(connection, table, "LastDayVoteStreak", generation,
+ "COALESCE(" + quote("DayVoteStreak") + ", 0), " + quote("LastDayVoteStreakLastUpdate")
+ + " = COALESCE(" + quote("DayVoteStreakLastUpdate") + ", '')");
+ }
+
+ static boolean resetSqlite(Connection connection, String table, String column, String previousColumn,
+ String generation)
+ throws SQLException {
+ if (!safeIdentifier(table) || !safeIdentifier(column) || !safeIdentifier(previousColumn)
+ || generation == null || generation.isEmpty()) {
+ throw new SQLException("Invalid period-total reset input");
+ }
+ return resetSqliteExpression(connection, table, column, generation, "MAX(0, COALESCE(" + quote(column)
+ + ", 0) - COALESCE(" + quote(previousColumn) + ", 0))");
+ }
+
+ private static boolean resetSqliteExpression(Connection connection, String table, String column, String generation,
+ String expression) throws SQLException {
+ if (!safeIdentifier(table) || !safeIdentifier(column) || generation == null || generation.isEmpty()) {
+ throw new SQLException("Invalid reset input");
+ }
+ synchronized (connection) {
+ boolean previousAutoCommit = connection.getAutoCommit();
+ SQLException primaryFailure = null;
+ try {
+ if (!previousAutoCommit) throw new SQLException("SQLite user connection already has an active transaction");
+ connection.setAutoCommit(false);
+ try (Statement schema = connection.createStatement()) {
+ schema.executeUpdate("CREATE TABLE IF NOT EXISTS " + quote(SQLITE_JOURNAL)
+ + " (generation TEXT PRIMARY KEY)");
+ }
+ int inserted;
+ try (PreparedStatement marker = connection.prepareStatement(
+ "INSERT OR IGNORE INTO " + quote(SQLITE_JOURNAL) + " (generation) VALUES (?)")) {
+ marker.setString(1, generation);
+ inserted = marker.executeUpdate();
+ }
+ if (inserted == 1) {
+ try (Statement wipe = connection.createStatement()) {
+ wipe.executeUpdate("UPDATE " + quote(table) + " SET " + quote(column) + " = " + expression);
+ }
+ }
+ connection.commit();
+ return true;
+ } catch (SQLException failure) {
+ primaryFailure = failure;
+ try {
+ connection.rollback();
+ } catch (SQLException rollbackFailure) {
+ failure.addSuppressed(rollbackFailure);
+ }
+ throw failure;
+ } finally {
+ try {
+ connection.setAutoCommit(previousAutoCommit);
+ } catch (SQLException restoreFailure) {
+ if (primaryFailure != null) primaryFailure.addSuppressed(restoreFailure);
+ else throw restoreFailure;
+ }
+ }
+ }
+ }
+
+ private static boolean safeIdentifier(String value) {
+ return value != null && !value.isEmpty() && value.indexOf('\0') < 0;
+ }
+
+ private static String quote(String identifier) {
+ return '`' + identifier.replace("`", "``") + '`';
+ }
+}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java
index 03fe3f2bf9..006b6918de 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java
@@ -1,12 +1,12 @@
package com.bencodez.votingplugin.topvoter;
import java.io.File;
+import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Collections;
-import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
@@ -14,8 +14,9 @@
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;
-import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CancellationException;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
@@ -23,6 +24,7 @@
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
import com.bencodez.advancedcore.api.time.TimeType;
import com.bencodez.advancedcore.api.time.events.DateChangedEvent;
import com.bencodez.advancedcore.api.time.events.DayChangeEvent;
@@ -37,16 +39,36 @@
import com.bencodez.simpleapi.sql.Column;
import com.bencodez.simpleapi.sql.DataType;
import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSection;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSnapshot;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeTopPolicy;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy;
+import com.bencodez.votingplugin.user.PeriodTotalMutationFence;
import com.bencodez.votingplugin.user.VotingPluginUser;
+import com.bencodez.votingplugin.voteshop.service.VoteShopLimitMutationFence;
import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService;
/**
* Handles top voter rankings and statistics.
*/
public class TopVoterHandler implements Listener {
+ private static final String SNAPSHOT = "SNAPSHOT";
+ private static final String COPY_TOTALS = "COPY_TOTALS";
+ private static final String USER_UPDATES = "USER_UPDATES";
+ private static final String TOP_REWARDS = "TOP_REWARDS";
+ private static final String VOTE_SHOP = "VOTE_SHOP";
+ private static final String BUNGEE_WAIT = "BUNGEE_WAIT";
+ private static final String TOTALS_RESET = "TOTALS_RESET";
+ private static final String CACHE_CLEAR = "CACHE_CLEAR";
+ private static final String POST_DATE = "POST_DATE";
+ private static final String COMPLETE = "COMPLETE";
private VotingPluginMain plugin;
private final TopVoterLoader loader;
+ private final HashMap retainedTransitions = new HashMap<>();
/**
* Constructs a new top voter handler.
@@ -152,8 +174,10 @@ public void loadPreviousMonthTopVoters() {
*/
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onDateChanged(DateChangedEvent event) {
- if (event.getTransition() != null
- && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(event.getTimeType())) {
+ if (event.getTransition() != null) {
+ if (!plugin.getConfigFile().isAutomaticTimeChangeEnabled(event.getTimeType())
+ && !plugin.getServerData().isTimeChangeRecoveryActive(event.getTransition())) return;
+ finishRecoverableDateChange(event);
return;
}
plugin.setUpdate(true);
@@ -172,8 +196,10 @@ public void onDateChanged(DateChangedEvent event) {
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDayChange(DayChangeEvent event) {
- if (event.getTransition() != null
- && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.DAY)) {
+ if (event.getTransition() != null) {
+ if (!plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.DAY)
+ && !plugin.getServerData().isTimeChangeRecoveryActive(event.getTransition())) return;
+ processRecoverableChange(TopVoter.Daily, event.getTransition());
return;
}
synchronized (VotingPluginMain.plugin) {
@@ -277,8 +303,10 @@ public void onDayChange(DayChangeEvent event) {
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMonthChange(MonthChangeEvent event) {
- if (event.getTransition() != null
- && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.MONTH)) {
+ if (event.getTransition() != null) {
+ if (!plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.MONTH)
+ && !plugin.getServerData().isTimeChangeRecoveryActive(event.getTransition())) return;
+ processRecoverableChange(TopVoter.Monthly, event.getTransition());
return;
}
long startTime = System.currentTimeMillis();
@@ -403,16 +431,32 @@ public void onMonthChange(MonthChangeEvent event) {
*/
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onPreDateChanged(PreDateChangedEvent event) {
- if (event.getTimeType().equals(TimeType.DAY)) {
+ TimeChangeTransition transition = event.getTransition();
+ if (transition != null) {
+ TimeChangeTransition.Lease lease = transition.retain();
+ try {
+ ensureTransitionActive(transition);
+ applyPreDateChange(event.getTimeType());
+ ensureTransitionActive(transition);
+ lease.complete();
+ } catch (Throwable failure) {
+ lease.fail(failure);
+ plugin.getLogger().warning("Pre time-change work remains pending: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
+ return;
+ }
+ applyPreDateChange(event.getTimeType());
+ }
+
+ private void applyPreDateChange(TimeType type) {
+ if (type.equals(TimeType.DAY)) {
plugin.getBannedPlayers().clear();
for (OfflinePlayer p : Bukkit.getBannedPlayers()) {
plugin.getBannedPlayers().add(p.getUniqueId().toString());
}
}
- if (event.getTransition() != null
- && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(event.getTimeType())) {
- return;
- }
plugin.setUpdate(true);
plugin.update();
}
@@ -424,8 +468,10 @@ public void onPreDateChanged(PreDateChangedEvent event) {
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onWeekChange(WeekChangeEvent event) {
- if (event.getTransition() != null
- && !plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.WEEK)) {
+ if (event.getTransition() != null) {
+ if (!plugin.getConfigFile().isAutomaticTimeChangeEnabled(TimeType.WEEK)
+ && !plugin.getServerData().isTimeChangeRecoveryActive(event.getTransition())) return;
+ processRecoverableChange(TopVoter.Weekly, event.getTransition());
return;
}
long startTime = System.currentTimeMillis();
@@ -530,6 +576,467 @@ public void onWeekChange(WeekChangeEvent event) {
}
}
+ /**
+ * Performs the existing period work with a durable cursor. The transition
+ * lease is deliberately retained until DateChangedEvent has applied its post
+ * effects, because AdvancedCore completes its marker only after that lease.
+ */
+ private void processRecoverableChange(TopVoter top, TimeChangeTransition transition) {
+ TimeChangeTransition.Lease lease = transition.retain();
+ try {
+ synchronized (VotingPluginMain.plugin) {
+ plugin.getServerData().beginTimeChangeRecovery(transition);
+ plugin.getServerData().prepareTimeChangeUserPolicy(transition, currentTimeChangeUserPolicy());
+ plugin.getServerData().prepareTimeChangeVoteShopTargets(transition,
+ currentVoteShopResetTargets(top));
+ plugin.getServerData().prepareTimeChangeTopPolicy(transition, currentTimeChangeTopPolicy(top));
+ if (!plugin.getServerData().hasTimeChangePhase(transition, COMPLETE)) {
+ runRecoverablePeriod(top, transition);
+ }
+ ensureTransitionActive(transition);
+ synchronized (retainedTransitions) {
+ retainedTransitions.put(transition.getId(), lease);
+ }
+ }
+ } catch (Throwable failure) {
+ lease.fail(failure);
+ plugin.getLogger().warning("Time change recovery for " + top + " remains pending: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
+ }
+
+ private TimeChangeUserPolicy currentTimeChangeUserPolicy() {
+ boolean proxyOwnsResets = bungeeHandleResets();
+ return new TimeChangeUserPolicy(plugin.getConfigFile().isUseVoteStreaks(),
+ plugin.getConfigFile().isUseHighestTotals(),
+ plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal(),
+ plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage(),
+ plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(),
+ plugin.getSpecialRewardsConfig().getVoteStreakRequirementWeek(),
+ plugin.getSpecialRewardsConfig().getVoteStreakRequirementMonth(), proxyOwnsResets,
+ plugin.getBungeeSettings().isUseBungeecoord() && !proxyOwnsResets,
+ plugin.getBungeeSettings().isUseBungeecoord(),
+ plugin.getVoteSiteManager().getVoteSitesEnabled().size());
+ }
+
+ private List currentVoteShopResetTargets(TopVoter top) {
+ return plugin.getShopFile().getShopIdentifiers().stream()
+ .filter(identifier -> shouldResetVoteShop(top, identifier)).toList();
+ }
+
+ private TimeChangeTopPolicy currentTimeChangeTopPolicy(TopVoter top) {
+ boolean archiveRequired = (top == TopVoter.Daily && plugin.getConfigFile().isStoreTopVotersDaily())
+ || (top == TopVoter.Weekly && plugin.getConfigFile().isStoreTopVotersWeekly())
+ || top == TopVoter.Monthly;
+ return new TimeChangeTopPolicy(isTopRewardEnabled(top),
+ plugin.getConfigFile().isTopVoterAwardsTies(),
+ plugin.getConfigFile().isTopVoterIgnorePermission(), archiveRequired,
+ new ArrayList<>(getPossibleRewardPlaces(top)),
+ new ArrayList<>(plugin.getConfigFile().getBlackList()));
+ }
+
+ private void runRecoverablePeriod(TopVoter top, TimeChangeTransition transition) {
+ // Capture the boundary before any long-running phase. The database journal
+ // makes the later COPY_TOTALS retry a no-op, so votes accepted while reward
+ // or user recovery is pending remain above this boundary.
+ copyTotalBoundary(top, transition);
+
+ if (!plugin.getServerData().hasTimeChangePhase(transition, SNAPSHOT)) {
+ ensureTransitionActive(transition);
+ boolean archiveRequired = plugin.getServerData().getTimeChangeTopPolicy(transition).archiveRequired();
+ TimeChangeArchiveSnapshot proposedArchive = archiveRequired
+ ? buildTopVoterArchiveSnapshot(top, transition) : new TimeChangeArchiveSnapshot(List.of());
+ plugin.getServerData().prepareTimeChangeSnapshot(transition,
+ buildTopRewardSnapshot(top, transition), proposedArchive);
+ if (archiveRequired) {
+ plugin.getLogger().info("Saving TopVoters " + top);
+ storeTopVoters(top, transition, plugin.getServerData().getTimeChangeArchive(transition));
+ }
+ plugin.getServerData().completeTimeChangePhase(transition, SNAPSHOT);
+ }
+
+ if (!plugin.getServerData().hasTimeChangePhase(transition, COPY_TOTALS)) {
+ ensureTransitionActive(transition);
+ copyTotalBoundary(top, transition);
+ plugin.getServerData().completeTimeChangePhase(transition, COPY_TOTALS);
+ }
+
+ if (!plugin.getServerData().hasTimeChangePhase(transition, USER_UPDATES)) {
+ processRecoverableUsers(top, transition);
+ plugin.getServerData().completeTimeChangePhase(transition, USER_UPDATES);
+ }
+ if (top == TopVoter.Daily && plugin.getStorageType().equals(UserStorage.MYSQL)
+ && !VoteShopPurchaseService.completeMysqlDailyStreakReset(plugin,
+ "time-streak-reset:" + transition.getId())) {
+ throw new IllegalStateException("Unable to publish the completed daily streak reset");
+ }
+
+ if (!plugin.getServerData().hasTimeChangePhase(transition, TOP_REWARDS)) {
+ processRecoverableTopRewards(top, transition);
+ plugin.getServerData().completeTimeChangePhase(transition, TOP_REWARDS);
+ }
+
+ processRecoverableVoteShop(top, transition);
+
+ if (!plugin.getServerData().hasTimeChangePhase(transition, BUNGEE_WAIT)) {
+ waitForBungee(top, transition);
+ plugin.getServerData().completeTimeChangePhase(transition, BUNGEE_WAIT);
+ }
+
+ if (!plugin.getServerData().hasTimeChangePhase(transition, TOTALS_RESET)) {
+ ensureTransitionActive(transition);
+ if (!plugin.getServerData().getTimeChangeUserPolicy(transition).proxyOwnsResets()) {
+ resetTotals(top, transition);
+ }
+ plugin.getServerData().completeTimeChangePhase(transition, TOTALS_RESET);
+ }
+
+ if (!plugin.getServerData().hasTimeChangePhase(transition, CACHE_CLEAR)) {
+ ensureTransitionActive(transition);
+ if (plugin.getStorageType().equals(UserStorage.MYSQL)) plugin.getMysql().clearCacheBasic();
+ plugin.getServerData().completeTimeChangePhase(transition, CACHE_CLEAR);
+ }
+ }
+
+ void copyTotalBoundary(TopVoter top, TimeChangeTransition transition) {
+ ensureTransitionActive(transition);
+ boolean[] copied = { false };
+ PeriodTotalMutationFence.withReset(() -> {
+ copied[0] = TimeChangeTotalReset.copyBoundary(plugin, top.getColumnName(), top.getLastColumnName(),
+ "time-copy:" + transition.getId());
+ if (copied[0] && top == TopVoter.Daily
+ && plugin.getServerData().getTimeChangeUserPolicy(transition).voteStreaks()) {
+ copied[0] = TimeChangeTotalReset.copyDailyStreakBoundary(plugin,
+ "time-streak-copy:" + transition.getId());
+ }
+ });
+ if (!copied[0]) {
+ throw new IllegalStateException("Unable to durably copy " + top + " boundary state");
+ }
+ }
+
+ void processRecoverableUsers(TopVoter top, TimeChangeTransition transition) {
+ TimeChangeUserPolicy policy = plugin.getServerData().getTimeChangeUserPolicy(transition);
+ if (!policy.voteStreaks() && !policy.highestTotals()) return;
+ AtomicReference cursor = new AtomicReference<>(plugin.getServerData().getTimeChangeCursor(transition));
+ LocalDateTime lastMonthTime = top == TopVoter.Monthly ? previousMonthTime(transition) : null;
+ // AdvancedCore streams deterministic UUID-ordered SQL pages synchronously.
+ // Process each row as it arrives so memory stays bounded and a failure cannot
+ // leave an uncancelled enumeration running behind a falsely failed transition.
+ plugin.getUserManager().forEachUserKeys((uuid, columns) -> {
+ String value = uuid.toString();
+ if (value.compareTo(cursor.get()) <= 0) return;
+ try {
+ ensureTransitionActive(transition);
+ VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false);
+ user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY);
+ user.updateTempCacheWithColumns(columns);
+ try {
+ if (top == TopVoter.Daily) processDailyUser(user, transition, value, policy);
+ else if (top == TopVoter.Weekly) processWeeklyUser(user, transition, value, policy);
+ else processMonthlyUser(user, lastMonthTime, transition, value, policy);
+ if (user.getCache() != null) user.getCache().flushChangesAndRun(() -> { });
+ } finally {
+ user.clearTempCache();
+ }
+ plugin.getServerData().completeTimeChangeUser(transition, value);
+ cursor.set(value);
+ } catch (Throwable userFailure) {
+ throw new IllegalStateException("Unable to durably process time-change user " + value, userFailure);
+ }
+ }, count -> { });
+ }
+
+ void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) {
+ processDailyUser(user, transition, uuid, plugin.getConfigFile().isUseVoteStreaks());
+ }
+
+ void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid,
+ boolean processVoteStreaks) {
+ processDailyUser(user, transition, uuid, new TimeChangeUserPolicy(processVoteStreaks,
+ plugin.getConfigFile().isUseHighestTotals(), false, false, 0, 0, 0, false, false, false));
+ }
+
+ void processDailyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid,
+ TimeChangeUserPolicy policy) {
+ int boundaryTotal = user.getLastDailyTotal();
+ if (policy.voteStreaks()) {
+ PeriodTotalMutationFence.withReset(() -> {
+ int boundaryStreak = user.getLastDayVoteStreak();
+ long boundaryUpdate = user.getLastDayVoteStreakLastUpdate();
+ if (!user.voteStreakUpdatedAt(boundaryUpdate, previousDayTime(transition)) && boundaryStreak != 0) {
+ // A vote accepted after the boundary has already started the new day's
+ // streak. Preserve that contribution while removing the stale streak.
+ int target = user.getDayVoteStreakLastUpdate() == boundaryUpdate ? 0 : 1;
+ applyRecoverableStreak(user, transition, uuid, TopVoter.Daily, target, false);
+ }
+ });
+ }
+ if (policy.highestTotals()
+ && user.getHighestDailyTotal() < boundaryTotal) {
+ user.setHighestDailyTotal(boundaryTotal);
+ }
+ }
+
+ void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid) {
+ processWeeklyUser(user, transition, uuid, currentTimeChangeUserPolicy());
+ }
+
+ void processWeeklyUser(VotingPluginUser user, TimeChangeTransition transition, String uuid,
+ TimeChangeUserPolicy policy) {
+ int boundaryTotal = user.getLastWeeklyTotal();
+ if (policy.voteStreaks()) {
+ if (boundaryTotal == 0 && user.getWeekVoteStreak() != 0) {
+ applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, 0, false);
+ } else if (!policy.streakUsesPercentage()
+ || user.hasPercentageTotal(TopVoter.Weekly,
+ policy.weekPercentage(), null, boundaryTotal, policy.enabledSiteCount())) {
+ applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly,
+ user.getWeekVoteStreak() + 1, true);
+ }
+ }
+ if (policy.highestTotals()
+ && user.getHighestWeeklyTotal() < boundaryTotal) {
+ user.setHighestWeeklyTotal(boundaryTotal);
+ }
+ }
+
+ void processMonthlyUser(VotingPluginUser user, LocalDateTime lastMonthTime,
+ TimeChangeTransition transition, String uuid) {
+ processMonthlyUser(user, lastMonthTime, transition, uuid, currentTimeChangeUserPolicy());
+ }
+
+ void processMonthlyUser(VotingPluginUser user, LocalDateTime lastMonthTime,
+ TimeChangeTransition transition, String uuid, TimeChangeUserPolicy policy) {
+ int boundaryTotal = policy.monthDateTotalsPrimary()
+ ? user.getTotal(TopVoter.Monthly, lastMonthTime) : user.getLastMonthTotal();
+ if (policy.voteStreaks()) {
+ if (boundaryTotal == 0 && user.getMonthVoteStreak() != 0) {
+ applyRecoverableStreak(user, transition, uuid, TopVoter.Monthly, 0, false);
+ } else if (!policy.streakUsesPercentage()
+ || user.hasPercentageTotal(TopVoter.Monthly,
+ policy.monthPercentage(), lastMonthTime,
+ boundaryTotal, policy.enabledSiteCount())) {
+ applyRecoverableStreak(user, transition, uuid, TopVoter.Monthly,
+ user.getMonthVoteStreak() + 1, true);
+ }
+ }
+ if (policy.highestTotals()
+ && user.getHighestMonthlyTotal() < boundaryTotal) {
+ user.setHighestMonthlyTotal(boundaryTotal);
+ }
+ }
+
+ void processRecoverableVoteShop(TopVoter top, TimeChangeTransition transition) {
+ if (plugin.getServerData().hasTimeChangePhase(transition, VOTE_SHOP)) return;
+ ensureTransitionActive(transition);
+ String generation = VoteShopPurchaseService.limitGenerationIdForTransition(transition);
+ for (String shopIdent : plugin.getServerData().getTimeChangeVoteShopTargets(transition)) {
+ if (!resetVoteShopLimit(shopIdent, generation)) {
+ throw new IllegalStateException("Unable to durably reset VoteShop limit " + shopIdent);
+ }
+ }
+ plugin.getServerData().completeTimeChangePhase(transition, VOTE_SHOP);
+ }
+
+ void applyRecoverableStreak(VotingPluginUser user, TimeChangeTransition transition, String uuid,
+ TopVoter top, int proposedTarget, boolean rewardRequired) {
+ TimeChangeUserProgress progress = plugin.getServerData().prepareTimeChangeUserStreak(transition, uuid,
+ proposedTarget, rewardRequired);
+ int current = switch (top) {
+ case Daily -> user.getDayVoteStreak();
+ case Weekly -> user.getWeekVoteStreak();
+ case Monthly -> user.getMonthVoteStreak();
+ default -> proposedTarget;
+ };
+ if (top == TopVoter.Daily && UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ if (!VoteShopPurchaseService.resetMysqlDailyStreakAtBoundary(plugin, uuid,
+ user.getLastDayVoteStreakLastUpdate())) {
+ throw new IllegalStateException("Unable to serialize shared MySQL daily streak reset");
+ }
+ } else if (current != progress.streakTarget()) {
+ switch (top) {
+ case Daily -> user.setDayVoteStreak(progress.streakTarget());
+ case Weekly -> user.setWeekVoteStreak(progress.streakTarget());
+ case Monthly -> user.setMonthVoteStreak(progress.streakTarget());
+ default -> { }
+ }
+ }
+ if (progress.rewardRequired() && !progress.rewardComplete()) {
+ TimeChangeRewardState rewardState = plugin.getServerData()
+ .getTimeChangeUserStreakRewardState(transition, uuid);
+ if (rewardState == TimeChangeRewardState.CLAIMED) {
+ throw new IllegalStateException("Streak reward for " + uuid
+ + " may already have run and requires manual reconciliation");
+ }
+ if (rewardState == TimeChangeRewardState.COMPLETE) return;
+ plugin.getServerData().claimTimeChangeUserStreakReward(transition, uuid);
+ plugin.getSpecialRewards().checkVoteStreak(null, user,
+ top == TopVoter.Weekly ? "Week" : "Month",
+ plugin.getServerData().getTimeChangeUserPolicy(transition).rewardForceProxy());
+ plugin.getServerData().completeTimeChangeUserStreakReward(transition, uuid);
+ }
+ }
+
+ void processRecoverableTopRewards(TopVoter top, TimeChangeTransition transition) {
+ for (TimeChangeRewardTarget target : plugin.getServerData().getTimeChangeRewardTargets(transition)) {
+ ensureTransitionActive(transition);
+ TimeChangeRewardState rewardState = plugin.getServerData()
+ .getTimeChangeRewardState(transition, target.uuid());
+ if (rewardState == TimeChangeRewardState.COMPLETE) continue;
+ if (rewardState == TimeChangeRewardState.CLAIMED) {
+ throw new IllegalStateException("Top voter reward for " + target.uuid()
+ + " may already have run and requires manual reconciliation");
+ }
+ VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(
+ UUID.fromString(target.uuid()), target.playerName());
+ user.userDataFetechMode(UserDataFetchMode.NO_CACHE);
+ plugin.getServerData().claimTimeChangeReward(transition, target.uuid());
+ giveTopVoterAward(top, user, target.place(), target.reward(), target.votes());
+ plugin.getServerData().completeTimeChangeReward(transition, target.uuid());
+ plugin.getLogger().info("Giving " + top + " top voter reward " + target.place() + " to "
+ + target.playerName());
+ }
+ }
+
+ List buildTopRewardSnapshot(TopVoter top, TimeChangeTransition transition) {
+ TimeChangeTopPolicy policy = plugin.getServerData().getTimeChangeTopPolicy(transition);
+ if (!policy.rewardsEnabled()) return List.of();
+ HashMap places = handlePlaces(Set.copyOf(policy.rewardPlaces()));
+ List targets = new ArrayList<>();
+ int place = 0;
+ int lastTotal = -1;
+ for (Entry entry : boundaryTopVotersFor(top, transition).entrySet()) {
+ ensureTransitionActive(transition);
+ if (policy.awardTies()) {
+ if (entry.getValue().intValue() != lastTotal) place++;
+ } else place++;
+ if (places.containsKey(place)) {
+ targets.add(new TimeChangeRewardTarget(entry.getKey().getUuid().toString(),
+ entry.getKey().getPlayerName() == null ? "" : entry.getKey().getPlayerName(),
+ place, places.get(place), entry.getValue().intValue()));
+ }
+ lastTotal = entry.getValue().intValue();
+ }
+ return List.copyOf(targets);
+ }
+
+ LinkedHashMap boundaryTopVotersFor(TopVoter top,
+ TimeChangeTransition transition) {
+ TimeChangeTopPolicy policy = plugin.getServerData().getTimeChangeTopPolicy(transition);
+ if (top == TopVoter.Monthly
+ && plugin.getServerData().getTimeChangeUserPolicy(transition).monthDateTotalsPrimary()) {
+ return loader.getBoundaryRanking(TopVoter.Monthly, previousMonthTime(transition),
+ policy.ignorePermission(), policy.blacklistedPlayers()).players();
+ }
+ return loader.getBoundaryRanking(top, null, policy.ignorePermission(),
+ policy.blacklistedPlayers()).players();
+ }
+
+ private LocalDateTime previousMonthTime(TimeChangeTransition transition) {
+ try {
+ return YearMonth.parse(transition.getPeriodKey()).minusMonths(1).atDay(15).atStartOfDay();
+ } catch (RuntimeException invalidPeriod) {
+ plugin.debug(invalidPeriod);
+ return plugin.getTimeChecker().getTime().minusMonths(1);
+ }
+ }
+
+ LocalDateTime previousDayTime(TimeChangeTransition transition) {
+ try {
+ return LocalDate.parse(transition.getPeriodKey()).minusDays(1).atStartOfDay();
+ } catch (RuntimeException invalidPeriod) {
+ plugin.debug(invalidPeriod);
+ return plugin.getTimeChecker().getTime().minusDays(1);
+ }
+ }
+
+ private boolean isTopRewardEnabled(TopVoter top) {
+ return switch (top) {
+ case Daily -> plugin.getSpecialRewardsConfig().isEnableDailyRewards();
+ case Weekly -> plugin.getSpecialRewardsConfig().isEnableWeeklyAwards();
+ case Monthly -> plugin.getSpecialRewardsConfig().isEnableMonthlyAwards();
+ default -> false;
+ };
+ }
+
+ private Set getPossibleRewardPlaces(TopVoter top) {
+ return switch (top) {
+ case Daily -> plugin.getSpecialRewardsConfig().getDailyPossibleRewardPlaces();
+ case Weekly -> plugin.getSpecialRewardsConfig().getWeeklyPossibleRewardPlaces();
+ case Monthly -> plugin.getSpecialRewardsConfig().getMonthlyPossibleRewardPlaces();
+ default -> Collections.emptySet();
+ };
+ }
+
+ private void giveTopVoterAward(TopVoter top, VotingPluginUser user, int place, String reward, int votes) {
+ switch (top) {
+ case Daily -> user.giveDailyTopVoterAward(place, reward, votes);
+ case Weekly -> user.giveWeeklyTopVoterAward(place, reward, votes);
+ case Monthly -> user.giveMonthlyTopVoterAward(place, reward, votes);
+ default -> { }
+ }
+ }
+
+ private boolean shouldResetVoteShop(TopVoter top, String shopIdent) {
+ return switch (top) {
+ case Daily -> plugin.getShopFile().getVoteShopResetDaily(shopIdent);
+ case Weekly -> plugin.getShopFile().getVoteShopResetWeekly(shopIdent);
+ case Monthly -> plugin.getShopFile().getVoteShopResetMonthly(shopIdent);
+ default -> false;
+ };
+ }
+
+ private void waitForBungee(TopVoter top, TimeChangeTransition transition) {
+ boolean wait = plugin.getServerData().getTimeChangeUserPolicy(transition).waitForProxy();
+ if (!wait) return;
+ ensureTransitionActive(transition);
+ plugin.debug("Delaying time change 10 seconds for other servers to catchup");
+ try {
+ Thread.sleep(10000);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw new CancellationException("Time change sleep was interrupted");
+ }
+ ensureTransitionActive(transition);
+ }
+
+ private void finishRecoverableDateChange(DateChangedEvent event) {
+ TimeChangeTransition transition = event.getTransition();
+ TimeChangeTransition.Lease lease;
+ synchronized (retainedTransitions) {
+ lease = retainedTransitions.remove(transition.getId());
+ }
+ if (lease == null) {
+ return;
+ }
+ try {
+ ensureTransitionActive(transition);
+ if (!plugin.getServerData().hasTimeChangePhase(transition, POST_DATE)) {
+ plugin.setUpdate(true);
+ plugin.update();
+ if (transition.getType().equals(TimeType.MONTH)) loadLastMonth();
+ if (plugin.getStorageType().equals(UserStorage.MYSQL)) plugin.getMysql().clearCacheBasic();
+ plugin.getServerData().completeTimeChangePhase(transition, POST_DATE);
+ }
+ ensureTransitionActive(transition);
+ plugin.getServerData().completeTimeChangePhase(transition, COMPLETE);
+ lease.complete();
+ } catch (Throwable failure) {
+ lease.fail(failure);
+ plugin.getLogger().warning("Post time-change recovery remains pending: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
+ }
+
+ private void ensureTransitionActive(TimeChangeTransition transition) {
+ if (transition.isCancellationRequested()) {
+ throw new CancellationException("Time transition was cancelled before recovery completed");
+ }
+ }
+
/**
* Registers this handler as a listener.
*/
@@ -545,6 +1052,13 @@ public void resetTotals(TopVoter topVoter) {
plugin.getUserManager().removeAllKeyValues(topVoter.getColumnName(), DataType.INTEGER);
}
+ void resetTotals(TopVoter topVoter, TimeChangeTransition transition) {
+ String generation = "time-total:" + transition.getId();
+ if (!TimeChangeTotalReset.reset(plugin, topVoter.getColumnName(), topVoter.getLastColumnName(), generation)) {
+ throw new IllegalStateException("Unable to durably reset " + topVoter + " totals");
+ }
+ }
+
/**
* Resets vote shop limits for a specific shop.
* @param shopIdent the shop identifier
@@ -553,23 +1067,33 @@ public void resetVoteShopLimit(String shopIdent) {
resetVoteShopLimit(shopIdent, null);
}
- private void resetVoteShopLimit(String shopIdent, String resetGeneration) {
+ private boolean resetVoteShopLimit(String shopIdent, String resetGeneration) {
String limitColumn = "VoteShopLimit" + shopIdent;
+ if (resetGeneration != null) {
+ if (UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ return VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, limitColumn, resetGeneration);
+ }
+ if (UserStorage.SQLITE.equals(plugin.getStorageType())) {
+ return VoteShopLimitMutationFence.withLock(() -> TimeChangeTotalReset.resetToZero(plugin, limitColumn,
+ resetGeneration + ':' + limitColumn));
+ }
+ return false;
+ }
if (UserStorage.MYSQL.equals(plugin.getStorageType()) && !plugin.getBungeeSettings().isPerServerPoints()) {
- if (resetGeneration == null) VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn);
- else VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn, resetGeneration);
- return;
+ VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn);
+ return true;
}
if (UserStorage.MYSQL.equals(plugin.getStorageType())) {
// The limit column is still shared with backends that have not switched to
// per-server points. Its wipe and epoch advance must therefore use the
// journal's one transaction; doing the UserManager wipe after advancing the
// epoch can erase a new-epoch reservation.
- VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, limitColumn,
- resetGeneration == null ? UUID.randomUUID().toString() : resetGeneration);
- return;
+ return VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, limitColumn,
+ UUID.randomUUID().toString());
}
- plugin.getUserManager().removeAllKeyValues(limitColumn, DataType.INTEGER);
+ VoteShopLimitMutationFence.withLock(
+ () -> plugin.getUserManager().removeAllKeyValues(limitColumn, DataType.INTEGER));
+ return true;
}
/**
@@ -631,6 +1155,68 @@ public void storeTopVoters(TopVoter top) {
file.saveData();
}
+ TimeChangeArchiveSnapshot buildTopVoterArchiveSnapshot(TopVoter boundaryTop,
+ TimeChangeTransition transition) {
+ List sections = new ArrayList<>();
+ TopVoterLoader.BoundaryRanking boundary = boundaryRankingFor(boundaryTop, transition);
+ LinkedHashMap boundaryRanking = boundary.players();
+ for (TopVoter current : TopVoter.values()) {
+ ArrayList lines = new ArrayList<>();
+ int total = 0;
+ if (current == boundaryTop) {
+ total = boundary.combinedTotal();
+ } else {
+ try {
+ for (Integer value : plugin.getUserManager().getNumbersInColumn(current.getColumnName())) {
+ total += value.intValue();
+ }
+ } catch (Exception failure) {
+ plugin.debug(failure);
+ throw new IllegalStateException("Unable to build " + current + " archive total", failure);
+ }
+ }
+ lines.add("Combined total: " + total);
+ if (current == boundaryTop || plugin.getTopVoter().containsKey(current)) {
+ int place = 1;
+ Iterable> ranking = current == boundaryTop
+ ? boundaryRanking.entrySet() : plugin.getTopVoter(current).entrySet();
+ for (Entry entry : ranking) {
+ lines.add(place + ": " + entry.getKey().getPlayerName() + ": " + entry.getValue());
+ place++;
+ }
+ sections.add(new TimeChangeArchiveSection(current.toString(), lines));
+ }
+ }
+ return new TimeChangeArchiveSnapshot(sections);
+ }
+
+ private TopVoterLoader.BoundaryRanking boundaryRankingFor(TopVoter top,
+ TimeChangeTransition transition) {
+ TimeChangeTopPolicy policy = plugin.getServerData().getTimeChangeTopPolicy(transition);
+ if (top == TopVoter.Monthly
+ && plugin.getServerData().getTimeChangeUserPolicy(transition).monthDateTotalsPrimary()) {
+ return loader.getBoundaryRanking(TopVoter.Monthly, previousMonthTime(transition),
+ policy.ignorePermission(), policy.blacklistedPlayers());
+ }
+ return loader.getBoundaryRanking(top, null, policy.ignorePermission(), policy.blacklistedPlayers());
+ }
+
+ void storeTopVoters(TopVoter top, TimeChangeTransition transition, TimeChangeArchiveSnapshot snapshot) {
+ String fileName = timeChangeArchiveFileName(top, transition);
+ YMLFileHandler file = new YMLFileHandler(plugin, new File(plugin.getDataFolder(), fileName));
+ file.setup();
+ file.header("Saving top voters for " + top + ", file also contains other top voter info as backup");
+ for (TimeChangeArchiveSection section : snapshot.sections()) {
+ file.getData().set(section.name(), section.lines());
+ }
+ file.saveData();
+ }
+
+ String timeChangeArchiveFileName(TopVoter top, TimeChangeTransition transition) {
+ String stablePeriod = transition.getPeriodKey().replaceAll("[^A-Za-z0-9_-]", "_");
+ return "TopVoter" + File.separator + top + File.separator + top + "_" + stablePeriod + ".yml";
+ }
+
/**
* Top voter all time
*
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java
index c6c6485d0b..b7c46c80de 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterLoader.java
@@ -6,6 +6,7 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map.Entry;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
@@ -19,6 +20,7 @@
/** Loads top-voter rankings from user storage. */
public class TopVoterLoader {
+ record BoundaryRanking(LinkedHashMap players, int combinedTotal) { }
private final VotingPluginMain plugin;
@@ -50,6 +52,85 @@ public LinkedHashMap getMonthlyTopVotersAtTime(LocalDat
return TopVoterRanking.sortByValues(topVoters, false);
}
+ /** Loads a ranking from the immutable last-period column copied at transition admission. */
+ public LinkedHashMap getBoundaryTopVoters(TopVoter top) {
+ return getBoundaryRanking(top, null).players();
+ }
+
+ /** Loads an immutable dated-month ranking without stopping when shutdown starts. */
+ public LinkedHashMap getBoundaryMonthlyTopVotersAtTime(LocalDateTime atTime) {
+ return getBoundaryRanking(TopVoter.Monthly, atTime).players();
+ }
+
+ BoundaryRanking getBoundaryRanking(TopVoter top, LocalDateTime monthlyTime) {
+ List blacklist = plugin.getConfigFile().getBlackList();
+ return getBoundaryRanking(top, monthlyTime, plugin.getConfigFile().isTopVoterIgnorePermission(),
+ blacklist == null ? List.of() : blacklist);
+ }
+
+ BoundaryRanking getBoundaryRanking(TopVoter top, LocalDateTime monthlyTime,
+ boolean ignorePermission, List blacklist) {
+ return loadBoundaryRanking(top, monthlyTime, ignorePermission, blacklist);
+ }
+
+ private BoundaryRanking loadBoundaryRanking(TopVoter top,
+ LocalDateTime monthlyTime, boolean ignorePermission, List blacklist) {
+ LinkedHashMap topVoters = new LinkedHashMap<>();
+ int[] combinedTotal = { 0 };
+ int limit = plugin.getConfigFile().getMaxiumNumberOfTopVotersToLoad();
+ plugin.getUserManager().forEachUserKeys((uuid, columns) -> {
+ if (uuid == null) return;
+ VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false);
+ user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY);
+ user.updateTempCacheWithColumns(columns);
+ try {
+ int total = monthlyTime == null ? switch (top) {
+ case Daily -> user.getLastDailyTotal();
+ case Weekly -> user.getLastWeeklyTotal();
+ case Monthly -> user.getLastMonthTotal();
+ default -> 0;
+ } : user.getTotal(TopVoter.Monthly, monthlyTime);
+ if (total > 0) {
+ combinedTotal[0] += total;
+ String playerName = user.getPlayerName();
+ if (user.isBanned() || playerName != null && blacklist.contains(playerName)
+ || ignorePermission && user.isTopVoterIgnore()) return;
+ addBounded(topVoters, user.getTopVoterPlayer(), total, limit);
+ }
+ } finally {
+ user.clearTempCache();
+ }
+ }, count -> { });
+ return new BoundaryRanking(TopVoterRanking.sortByValues(topVoters, false), combinedTotal[0]);
+ }
+
+ private static void addBounded(LinkedHashMap ranking,
+ TopVoterPlayer player, int total, int limit) {
+ if (limit <= 0 || ranking.size() < limit) {
+ ranking.put(player, total);
+ return;
+ }
+ Entry worst = null;
+ for (Entry entry : ranking.entrySet()) {
+ if (worst == null || compareRank(entry.getKey(), entry.getValue(),
+ worst.getKey(), worst.getValue()) > 0) worst = entry;
+ }
+ if (worst != null && compareRank(player, total, worst.getKey(), worst.getValue()) < 0) {
+ ranking.remove(worst.getKey());
+ ranking.put(player, total);
+ }
+ }
+
+ /** Negative when the first entry ranks ahead of the second. */
+ private static int compareRank(TopVoterPlayer first, int firstTotal,
+ TopVoterPlayer second, int secondTotal) {
+ int totals = Integer.compare(secondTotal, firstTotal);
+ if (totals != 0) return totals;
+ int times = first.getLastVoteTime().compareTo(second.getLastVoteTime());
+ if (times != 0) return times;
+ return first.getUuid().toString().compareTo(second.getUuid().toString());
+ }
+
public LinkedHashMap getTopVotersOfMonth(YearMonth month,
HashMap> columnsByPlayer) {
LinkedHashMap totals = new LinkedHashMap<>();
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java
new file mode 100644
index 0000000000..ce554cc45e
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PeriodTotalMutationFence.java
@@ -0,0 +1,31 @@
+package com.bencodez.votingplugin.user;
+
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+/** Orders period-total writes against boundary snapshots and resets. */
+public final class PeriodTotalMutationFence {
+ private static final ReentrantReadWriteLock FENCE = new ReentrantReadWriteLock(true);
+
+ private PeriodTotalMutationFence() {
+ }
+
+ public static void withMutation(Runnable action) {
+ var lock = FENCE.writeLock();
+ lock.lock();
+ try {
+ action.run();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ public static void withReset(Runnable action) {
+ var lock = FENCE.writeLock();
+ lock.lock();
+ try {
+ action.run();
+ } finally {
+ lock.unlock();
+ }
+ }
+}
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 c2680332d7..3bf1a60b27 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java
@@ -180,11 +180,19 @@ AddResult addCommitted(VotingPluginUser user, int amount, String operationId) {
}
AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) {
+ return addCommittedToColumn(user, amount, operationId, pointsColumn, -1);
+ }
+
+ AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn,
+ int maximum) {
if (operationId == null || operationId.isEmpty()) return addCommittedToColumn(user, amount, pointsColumn);
drainCache(user, pointsColumn);
try {
- SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql())
- .add(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis());
+ SharedPointAdditionJournal journal = SharedPointAdditionJournal.forTable(plugin.getMysql());
+ SharedPointAdditionJournal.AdditionResult result = maximum > 0
+ ? journal.addCapped(operationId, user.getUUID(), pointsColumn, amount, maximum,
+ System.currentTimeMillis())
+ : journal.add(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis());
return new AddResult(true, result.total());
} catch (SQLException failure) {
logFailure(failure);
@@ -320,6 +328,11 @@ void acknowledgePointAdditionNow(String operationId) {
}
}
+ void acknowledgePointAdditionOrThrow(String operationId) throws SQLException {
+ if (!canRecoverSharedMysqlPointJournals(plugin) || operationId == null || operationId.isEmpty()) return;
+ SharedPointAdditionJournal.forTable(plugin.getMysql()).acknowledge(operationId, System.currentTimeMillis());
+ }
+
void set(VotingPluginUser user, int value, boolean async) {
String pointsColumn = user.getPointsPath();
run(() -> setAbsolute(user, value, pointsColumn), async);
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java
index 4b6f462c30..e560a52b87 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java
@@ -77,7 +77,13 @@ static SharedPointAdditionJournal forTable(MySQL table) throws SQLException {
/** Applies the operation exactly once and returns the resulting durable total. */
AdditionResult add(String operationId, String uuid, String pointsColumn, int amount, long now) throws SQLException {
- return mutate(operationId, uuid, pointsColumn, amount, now, false);
+ return mutate(operationId, uuid, pointsColumn, amount, null, now, false);
+ }
+
+ /** Applies one idempotent credit and upper bound in the same transaction. */
+ AdditionResult addCapped(String operationId, String uuid, String pointsColumn, int amount, int maximum, long now)
+ throws SQLException {
+ return mutate(operationId, uuid, pointsColumn, amount, Integer.valueOf(maximum), now, false);
}
/** Applies a durable conditional debit. A retry confirms the journal row and
@@ -85,10 +91,10 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo
* rejection and is not recorded as a successful mutation. */
AdditionResult subtract(String operationId, String uuid, String pointsColumn, int amount, long now) throws SQLException {
if (amount < 0) throw new SQLException("Invalid shared point debit");
- return mutate(operationId, uuid, pointsColumn, -amount, now, true);
+ return mutate(operationId, uuid, pointsColumn, -amount, null, now, true);
}
- private AdditionResult mutate(String operationId, String uuid, String pointsColumn, int amount, long now,
+ private AdditionResult mutate(String operationId, String uuid, String pointsColumn, int amount, Integer maximum, long now,
boolean requireNonnegative) throws SQLException {
if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column");
AdditionRow existing = find(operationId);
@@ -99,8 +105,10 @@ private AdditionResult mutate(String operationId, String uuid, String pointsColu
+ qi("state") + ", " + qi("total_points") + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
String points = qi(pointsColumn);
String coalescedPoints = "COALESCE(" + points + ", 0)";
- String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + coalescedPoints
- + " + ? WHERE " + qi("uuid") + uuidCast()
+ String updatedPoints = coalescedPoints + " + ?";
+ if (maximum != null && maximum.intValue() > 0) updatedPoints = "LEAST(" + updatedPoints + ", ?)";
+ String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + updatedPoints
+ + " WHERE " + qi("uuid") + uuidCast()
+ (requireNonnegative ? " AND " + coalescedPoints + " >= ?" : "");
String read = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + uuidCast();
String complete = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("total_points")
@@ -122,8 +130,10 @@ private AdditionResult mutate(String operationId, String uuid, String pointsColu
insertStatement.executeUpdate();
updateStatement.setInt(1, amount);
- updateStatement.setString(2, uuid);
- if (requireNonnegative) updateStatement.setInt(3, -amount);
+ int parameter = 2;
+ if (maximum != null && maximum.intValue() > 0) updateStatement.setInt(parameter++, maximum.intValue());
+ updateStatement.setString(parameter++, uuid);
+ if (requireNonnegative) updateStatement.setInt(parameter, -amount);
if (updateStatement.executeUpdate() != 1) {
rollback(connection);
if (requireNonnegative) throw new DebitRejectedException();
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java
index 8c6b2c1564..3c89372851 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java
@@ -53,6 +53,7 @@ public void addCachingKeys() {
UserDataManager manager = plugin.getUserManager().getDataManager();
manager.addKey(new UserDataKeyBoolean("TopVoterIgnore"));
manager.addKey(new UserDataKeyInt("VotePartyVotes"));
+ manager.addKey(new UserDataKeyInt("LastVotePartyVotes"));
manager.addKey(new UserDataKeyString("LastVotes"));
manager.addKey(new UserDataKeyBoolean(getCoolDownCheckPath()));
manager.addKey(new UserDataKeyString("OfflineVotes").setColumnType("MEDIUMTEXT"));
@@ -71,8 +72,10 @@ public void addCachingKeys() {
manager.addKey(new UserDataKeyInt("WeeklyTotal"));
manager.addKey(new UserDataKeyInt("Points"));
manager.addKey(new UserDataKeyInt("DayVoteStreak"));
+ manager.addKey(new UserDataKeyInt("LastDayVoteStreak"));
manager.addKey(new UserDataKeyInt("BestDayVoteStreak"));
manager.addKey(new UserDataKeyString("DayVoteStreakLastUpdate").setColumnType("MEDIUMTEXT"));
+ manager.addKey(new UserDataKeyString("LastDayVoteStreakLastUpdate").setColumnType("MEDIUMTEXT"));
manager.addKey(new UserDataKeyString(VoteMilestonesManager.getLIMITS_STORAGE_KEY()).setColumnType("LONGTEXT"));
manager.addKey(new UserDataKeyBoolean("Reminded"));
manager.addKey(new UserDataKeyBoolean("DisableBroadcast"));
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 148eca1b6f..af33eed5ad 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java
@@ -48,7 +48,8 @@
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.voteshop.service.VoteShopPurchaseService;
+import com.bencodez.votingplugin.votesites.NextSite;
import com.bencodez.votingplugin.votesites.VoteSite;
/**
@@ -150,9 +151,20 @@ public VotingPluginUser(VotingPluginMain plugin, UUID uuid, String playerName) {
/**
* Adds one to the all-time total votes.
*/
- public void addAllTimeTotal() {
- setAllTimeTotal(getAllTimeTotal() + 1);
- }
+ public void addAllTimeTotal() {
+ setAllTimeTotal(getAllTimeTotal() + 1);
+ }
+
+ public void addAllTimeTotal(UUID voteId) {
+ if (plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "AllTimeTotal",
+ "AllTimeTotal", List.of("AllTimeTotal"), null)) {
+ throw new IllegalStateException("Unable to retain shared MySQL all-time vote total");
+ }
+ return;
+ }
+ addAllTimeTotal();
+ }
/**
* Adds one to the daily vote streak.
@@ -165,8 +177,22 @@ public void addDayVoteStreak() {
/**
* Adds one to the monthly total votes.
*/
- public void addMonthTotal() {
- setMonthTotal(getMonthTotal() + 1);
+ public void addMonthTotal() {
+ addMonthTotal(null);
+ }
+
+ public void addMonthTotal(UUID voteId) {
+ PeriodTotalMutationFence.withMutation(() -> {
+ int fallbackTotal = getMonthTotal() + 1;
+ if (plugin != null && plugin.getConfigFile().isLimitMonthlyVotes()) {
+ int maximum = plugin.getTimeChecker().getTime().getDayOfMonth()
+ * plugin.getVoteSiteManager().getVoteSitesEnabled().size();
+ fallbackTotal = Math.min(maximum, fallbackTotal);
+ }
+ int queuedFallbackTotal = fallbackTotal;
+ if (incrementSharedMysqlMonthTotal(voteId)) return;
+ setMonthTotal(queuedFallbackTotal);
+ });
}
/**
@@ -256,6 +282,56 @@ public synchronized int addPoints(int value, boolean async) {
return newTotal;
}
+ /**
+ * Adds the configured vote points under the accepted vote's durable identity.
+ * Shared MySQL retries confirm the journalled credit before dispatching the
+ * receive hook again, and the vote producer is not acknowledged until the
+ * credit has committed.
+ *
+ * @param voteId stable identity of the accepted vote
+ */
+ public void addVotePoints(UUID voteId) {
+ addVotePoints(voteId, plugin.getConfigFile().getPointsOnVote(), plugin.getConfigFile().getLimitVotePoints(),
+ getPointsPath());
+ }
+
+ /** Applies the point policy captured when the durable vote was admitted. */
+ public void addVotePoints(UUID voteId, int points, int limit) {
+ addVotePoints(voteId, points, limit, getPointsPath());
+ }
+
+ /** Applies the point policy and destination captured by durable vote admission. */
+ public void addVotePoints(UUID voteId, int points, int limit, String admittedPointsColumn) {
+ SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin);
+ if (voteId == null || !sharedPoints.usesMysqlPointMutations()) {
+ addVotePointsWithPolicy(points, limit);
+ return;
+ }
+ String pointsColumn = admittedPointsColumn;
+ String operationId = "vote-points:" + voteId;
+ Integer completedTotal = sharedPoints.completedPointAdditionTotal(operationId, getUUID(), pointsColumn);
+ if (completedTotal == null && (points != 0 || limit > 0)) {
+ int admittedAmount = 0;
+ if (points != 0) {
+ PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, points);
+ Bukkit.getPluginManager().callEvent(event);
+ if (!event.isCancelled()) admittedAmount = event.getPoints();
+ }
+ SharedMysqlPointMutator.AddResult result = sharedPoints.addCommittedToColumn(this,
+ admittedAmount, operationId, pointsColumn, limit);
+ if (!result.success()) {
+ throw new IllegalStateException("Unable to persist vote points for " + getUUID());
+ }
+ }
+ // A durable vote queue has no age limit. Keep vote-point receipts in
+ // COMPLETED so a delayed replay cannot credit the same vote after cleanup.
+ }
+
+ private void addVotePointsWithPolicy(int points, int limit) {
+ if (points != 0) addPoints(points, false);
+ if (limit > 0 && getPoints() > limit) setPoints(limit);
+ }
+
/**
* Keeps ordinary storage writes synchronous while moving shared-MySQL atomic
* arithmetic onto the persistence executor.
@@ -612,6 +688,16 @@ public CompletionStage acknowledgeStorageAwarePointOperation(String operat
return new SharedMysqlPointMutator(plugin).acknowledgePointAddition(operationId);
}
+ /** Makes a completed vote-point receipt eligible for bounded retirement. */
+ public static void acknowledgeVotePointDelivery(VotingPluginMain plugin, UUID voteId) {
+ if (voteId == null) return;
+ try {
+ new SharedMysqlPointMutator(plugin).acknowledgePointAdditionOrThrow("vote-points:" + voteId);
+ } catch (java.sql.SQLException failure) {
+ throw new IllegalStateException("Unable to acknowledge shared MySQL vote points", failure);
+ }
+ }
+
/**
* Adds points and reports the committed total after shared-MySQL persistence
* completes. The callback runs on the user's Bukkit/entity lane.
@@ -900,24 +986,74 @@ public synchronized void addPointsStorageAware(int value, BiConsumer {
+ int fallbackTotal = getDailyTotal() + 1;
+ if (incrementSharedMysqlPeriodTotal(voteId, TopVoter.Daily, "DailyTotal")) return;
+ setDailyTotal(fallbackTotal);
+ });
}
/**
* Adds one to the weekly total votes.
*/
- public void addTotalWeekly() {
- setWeeklyTotal(getWeeklyTotal() + 1);
- }
+ public void addTotalWeekly() {
+ addTotalWeekly(null);
+ }
+
+ public void addTotalWeekly(UUID voteId) {
+ PeriodTotalMutationFence.withMutation(() -> {
+ int fallbackTotal = getWeeklyTotal() + 1;
+ if (incrementSharedMysqlPeriodTotal(voteId, TopVoter.Weekly, "WeeklyTotal")) return;
+ setWeeklyTotal(fallbackTotal);
+ });
+ }
+
+ private boolean incrementSharedMysqlMonthTotal(UUID voteId) {
+ if (plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType())) return false;
+ ArrayList columns = new ArrayList<>();
+ columns.add("MonthTotal");
+ if (plugin.getConfigFile().isStoreMonthTotalsWithDate()) {
+ columns.add(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath());
+ }
+ Integer maximum = null;
+ if (plugin.getConfigFile().isLimitMonthlyVotes()) {
+ maximum = Integer.valueOf(plugin.getTimeChecker().getTime().getDayOfMonth()
+ * plugin.getVoteSiteManager().getVoteSitesEnabled().size());
+ }
+ if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "MonthTotal",
+ "LastMonthTotal", columns, maximum)) {
+ throw new IllegalStateException("Unable to retain shared MySQL monthly vote total");
+ }
+ SharedMysqlCacheReconciler.invalidate(plugin, getUUID(), columns.toArray(String[]::new));
+ return true;
+ }
+
+ private boolean incrementSharedMysqlPeriodTotal(UUID voteId, TopVoter top, String column) {
+ if (plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType())) return false;
+ if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), column,
+ top.getLastColumnName(), List.of(column), null)) {
+ throw new IllegalStateException("Unable to retain shared MySQL " + top + " vote total");
+ }
+ SharedMysqlCacheReconciler.invalidate(plugin, getUUID(), column);
+ return true;
+ }
/**
* Adds one to the weekly vote streak.
@@ -938,9 +1074,21 @@ public void addWeekVoteStreak() {
* @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) {
- if (plugin.getBungeeSettings().isUseBungeecoord()) {
+ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals,
+ boolean wasOnline, boolean broadcast, int num) {
+ @SuppressWarnings("deprecation")
+ UUID legacyVoteId = text == null ? null : text.getVoteUUID();
+ bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, legacyVoteId);
+ }
+
+ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals,
+ boolean wasOnline, boolean broadcast, int num, UUID voteId) {
+ bungeeVotePluginMessagingAccepted(service, time, text, setTotals, wasOnline, broadcast, num, voteId);
+ }
+
+ public boolean bungeeVotePluginMessagingAccepted(String service, long time, VoteTotalsSnapshot text, boolean setTotals,
+ boolean wasOnline, boolean broadcast, int num, UUID voteId) {
+ if (plugin.getBungeeSettings().isUseBungeecoord()) {
plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service);
PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true),
@@ -953,10 +1101,17 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps
voteEvent.setBungeeTextTotals(text);
voteEvent.setWasOnline(wasOnline);
voteEvent.setBroadcast(broadcast);
- voteEvent.setVoteNumber(num);
- plugin.getServer().getPluginManager().callEvent(voteEvent);
- }
- }
+ voteEvent.setVoteNumber(num);
+ voteEvent.setVoteId(voteId);
+ voteEvent.setDeferredDeliveryCompletion(true);
+ plugin.getServer().getPluginManager().callEvent(voteEvent);
+ if (voteEvent.isProcessingFailed() && voteEvent.isReplayUnsafe()) {
+ throw new IllegalStateException("Proxy vote reached an ambiguous post-effect failure: " + voteId);
+ }
+ return !voteEvent.isProcessingIncomplete();
+ }
+ return false;
+ }
/**
* Checks if the user can vote on all sites.
@@ -1092,21 +1247,52 @@ public boolean checkAlmostAllVotes() {
*
* @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());
- }
- }
- }
+ @Deprecated
+ public void checkDayVoteStreak(boolean forceBungee) {
+ checkDayVoteStreak(forceBungee, null);
+ }
+
+ public void checkDayVoteStreak(boolean forceBungee, UUID voteId) {
+ PeriodTotalMutationFence.withMutation(() -> {
+ if (UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ VoteShopPurchaseService.MysqlDailyStreakUpdate update = VoteShopPurchaseService
+ .applyPreparedMysqlDailyStreak(plugin, voteId, getUUID());
+ if (update.result() == VoteShopPurchaseService.MysqlDailyStreakResult.FAILED) {
+ throw new IllegalStateException("Unable to retain shared MySQL daily streak");
+ }
+ if (update.result() == VoteShopPurchaseService.MysqlDailyStreakResult.APPLIED) {
+ completeRecoveredDailyStreak(update.streak(), update.forceProxyRouting());
+ if (!VoteShopPurchaseService.completeMysqlDailyStreakReward(plugin, voteId)) {
+ throw new IllegalStateException("Unable to complete shared MySQL daily streak reward");
+ }
+ }
+ return;
+ }
+ 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));
+ int streak = getDayVoteStreak() + 1;
+ long updatedAt = System.currentTimeMillis();
+ setDayVoteStreak(streak);
+ plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee);
+ setDayVoteStreakLastUpdate(updatedAt);
+ }
+ }
+ });
+ }
+
+ public void completeRecoveredDailyStreak(int streak, boolean forceBungee) {
+ if (getBestDayVoteStreak() < streak) {
+ setBestDayVoteStreak(streak);
+ UserDataCache cache = getCache();
+ if (cache != null) cache.flushChangesAndRun(() -> { });
+ }
+ plugin.getSpecialRewards().checkVoteStreakAt(null, this, "Day", streak, forceBungee);
+ }
/**
* Clears the offline votes.
@@ -1261,8 +1447,20 @@ public int getDayVoteStreak() {
* @return the last update time for the day vote streak
*/
@Deprecated
- public long getDayVoteStreakLastUpdate() {
- String str = getData().getString("DayVoteStreakLastUpdate");
+ public long getDayVoteStreakLastUpdate() {
+ return getVoteStreakUpdate("DayVoteStreakLastUpdate");
+ }
+
+ public int getLastDayVoteStreak() {
+ return getData().getInt("LastDayVoteStreak");
+ }
+
+ public long getLastDayVoteStreakLastUpdate() {
+ return getVoteStreakUpdate("LastDayVoteStreakLastUpdate");
+ }
+
+ private long getVoteStreakUpdate(String path) {
+ String str = getData().getString(path);
if (str == null || str.isEmpty() || str.equals("null")) {
return 0;
}
@@ -1350,9 +1548,19 @@ public int getHighestWeeklyTotal() {
*
* @return the total votes for the last month
*/
- public int getLastMonthTotal() {
- return getData().getInt("LastMonthTotal");
- }
+ public int getLastMonthTotal() {
+ return getData().getInt("LastMonthTotal");
+ }
+
+ /** Returns the daily total captured at the time-change boundary. */
+ public int getLastDailyTotal() {
+ return getData().getInt("LastDailyTotal");
+ }
+
+ /** Returns the weekly total captured at the time-change boundary. */
+ public int getLastWeeklyTotal() {
+ return getData().getInt("LastWeeklyTotal");
+ }
/**
* Gets the last votes for each vote site.
@@ -1782,8 +1990,13 @@ public int getWeekVoteStreak() {
* @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;
+ public void giveDailyTopVoterAward(int place, String path) {
+ giveDailyTopVoterAward(place, path, getTotal(TopVoter.Daily));
+ }
+
+ /** Gives a daily top-voter award using the vote total captured at the period boundary. */
+ public void giveDailyTopVoterAward(int place, String path, int votes) {
+ SpecialRewardType type = SpecialRewardType.TOPVOTER;
type.setType("Daily");
type.setAmount(1);
PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null);
@@ -1792,9 +2005,9 @@ public void giveDailyTopVoterAward(int place, String path) {
if (event.isCancelled()) {
return;
}
- new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
- plugin.getSpecialRewardsConfig().getDailyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
- .withPlaceHolder("topvoter", "Daily").withPlaceHolder("votes", "" + getTotal(TopVoter.Daily))
+ new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
+ plugin.getSpecialRewardsConfig().getDailyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
+ .withPlaceHolder("topvoter", "Daily").withPlaceHolder("votes", "" + votes)
.setOnline(isOnline()).send(this);
}
@@ -1804,7 +2017,12 @@ public void giveDailyTopVoterAward(int place, String path) {
* @param place the place of the top voter
* @param path the path to the reward configuration
*/
- public void giveMonthlyTopVoterAward(int place, String path) {
+ public void giveMonthlyTopVoterAward(int place, String path) {
+ giveMonthlyTopVoterAward(place, path, getTotal(TopVoter.Monthly));
+ }
+
+ /** Gives a monthly top-voter award using the vote total captured at the period boundary. */
+ public void giveMonthlyTopVoterAward(int place, String path, int votes) {
SpecialRewardType type = SpecialRewardType.TOPVOTER;
type.setType("Monthly");
type.setAmount(1);
@@ -1814,9 +2032,9 @@ public void giveMonthlyTopVoterAward(int place, String path) {
if (event.isCancelled()) {
return;
}
- new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
- plugin.getSpecialRewardsConfig().getMonthlyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
- .withPlaceHolder("topvoter", "Monthly").withPlaceHolder("votes", "" + getTotal(TopVoter.Monthly))
+ new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
+ plugin.getSpecialRewardsConfig().getMonthlyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
+ .withPlaceHolder("topvoter", "Monthly").withPlaceHolder("votes", "" + votes)
.setOnline(isOnline()).send(this);
}
@@ -1826,7 +2044,12 @@ public void giveMonthlyTopVoterAward(int place, String path) {
* @param place the place of the top voter
* @param path the path to the reward configuration
*/
- public void giveWeeklyTopVoterAward(int place, String path) {
+ public void giveWeeklyTopVoterAward(int place, String path) {
+ giveWeeklyTopVoterAward(place, path, getTotal(TopVoter.Weekly));
+ }
+
+ /** Gives a weekly top-voter award using the vote total captured at the period boundary. */
+ public void giveWeeklyTopVoterAward(int place, String path, int votes) {
SpecialRewardType type = SpecialRewardType.TOPVOTER;
type.setType("Weekly");
type.setAmount(1);
@@ -1836,9 +2059,9 @@ public void giveWeeklyTopVoterAward(int place, String path) {
if (event.isCancelled()) {
return;
}
- new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
- plugin.getSpecialRewardsConfig().getWeeklyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
- .withPlaceHolder("topvoter", "Weekly").withPlaceHolder("votes", "" + getTotal(TopVoter.Weekly))
+ new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
+ plugin.getSpecialRewardsConfig().getWeeklyAwardRewardsPath(path)).withPlaceHolder("place", "" + place)
+ .withPlaceHolder("topvoter", "Weekly").withPlaceHolder("votes", "" + votes)
.setOnline(isOnline()).send(this);
}
@@ -1887,17 +2110,29 @@ public long getUniqueVoteSitesToday() {
* @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;
+ public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time) {
+ return hasPercentageTotal(top, percentage, time, getTotal(top, time));
+ }
+
+ /** Checks a percentage requirement against an explicitly captured total. */
+ public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time, int total) {
+ return hasPercentageTotal(top, percentage, time, total,
+ plugin.getVoteSiteManager().getVoteSitesEnabled().size());
+ }
+
+ /** Checks a percentage requirement against captured total and site-count boundaries. */
+ public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time, int total,
+ int enabledSiteCount) {
+ if (enabledSiteCount <= 0) return false;
+ switch (top) {
+ case Daily:
+ return (double) total / (double) enabledSiteCount
+ * 100 > percentage;
+ case Monthly:
+ return total / ((double) enabledSiteCount
+ * time.getMonth().length(false)) * 100 > percentage;
+ case Weekly:
+ return total / ((double) enabledSiteCount * 7) * 100 > percentage;
default:
return false;
}
@@ -2345,10 +2580,17 @@ public void setLastVotes(HashMap lastVotes) {
* @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);
- }
+ @Deprecated
+ public void setMonthTotal(int total) {
+ setTotal(TopVoter.Monthly, total);
+ }
+
+ /** Applies a monthly ceiling against the value read inside the period fence. */
+ public void capMonthTotal(int maximum) {
+ PeriodTotalMutationFence.withMutation(() -> {
+ if (getMonthTotal() > maximum) setTotalWithinFence(TopVoter.Monthly, maximum);
+ });
+ }
/**
* Sets the month vote streak.
@@ -2443,7 +2685,11 @@ public void setTopVoterIgnore(boolean topVoterIgnore) {
* @param top the top voter category
* @param value the total votes to set
*/
- public void setTotal(TopVoter top, int value) {
+ public void setTotal(TopVoter top, int value) {
+ PeriodTotalMutationFence.withMutation(() -> setTotalWithinFence(top, value));
+ }
+
+ private void setTotalWithinFence(TopVoter top, int value) {
switch (top) {
case AllTime:
getUserData().setInt("AllTimeTotal", value);
@@ -2477,9 +2723,27 @@ public void setTotal(TopVoter top, int value) {
*
* @param value the number of votes to set
*/
- public void setVotePartyVotes(int value) {
- getUserData().setInt("VotePartyVotes", value);
- }
+ public void setVotePartyVotes(int value) {
+ getUserData().setInt("VotePartyVotes", value);
+ }
+
+ /** Adds one VoteParty count using the shared period boundary when MySQL is shared. */
+ public void addVotePartyVote() {
+ addVotePartyVote(null);
+ }
+
+ public void addVotePartyVote(UUID voteId) {
+ int fallbackTotal = getVotePartyVotes() + 1;
+ if (plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType())) {
+ if (!VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, getUUID(), "VotePartyVotes",
+ "LastVotePartyVotes", List.of("VotePartyVotes"), null)) {
+ throw new IllegalStateException("Unable to retain shared MySQL VoteParty count");
+ }
+ SharedMysqlCacheReconciler.invalidate(plugin, getUUID(), "VotePartyVotes");
+ return;
+ }
+ setVotePartyVotes(fallbackTotal);
+ }
/**
* Sets the vote shop identifier limit.
@@ -2788,9 +3052,13 @@ public long voteNextDurationTime(VoteSite voteSite, long 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 boolean voteStreakUpdatedToday(LocalDateTime time) {
+ return voteStreakUpdatedAt(getDayVoteStreakLastUpdate(), time);
+ }
+
+ public boolean voteStreakUpdatedAt(long update, LocalDateTime time) {
+ return MiscUtils.getInstance().getTime(update).getDayOfYear() == time.getDayOfYear();
+ }
public String getVoteStreakState(String columnName) {
return getData().getString(columnName);
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java
new file mode 100644
index 0000000000..e55d5fff03
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitVoteEventDispatcher.java
@@ -0,0 +1,32 @@
+package com.bencodez.votingplugin.util;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.events.PlayerVoteEvent;
+
+/** Dispatches synchronous vote events from the voted player's Bukkit/Folia owner. */
+public final class BukkitVoteEventDispatcher {
+ private BukkitVoteEventDispatcher() {
+ }
+
+ public static void dispatch(VotingPluginMain plugin, PlayerVoteEvent event) {
+ Player target = Bukkit.getPlayerExact(event.getPlayer());
+ Runnable rejected = () -> fail(event);
+ BukkitCompletionScheduler.run(plugin, target, () -> {
+ try {
+ plugin.getServer().getPluginManager().callEvent(event);
+ } catch (RuntimeException | Error dispatchFailure) {
+ plugin.debug(dispatchFailure);
+ fail(event);
+ throw dispatchFailure;
+ }
+ }, rejected);
+ }
+
+ private static void fail(PlayerVoteEvent event) {
+ event.setProcessingFailed(true);
+ event.completeProcessing();
+ }
+}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java
index e749f9a99f..6578069a0c 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java
@@ -6,6 +6,7 @@
/** Non-blocking admission helpers for work submitted to the bounded vote timer. */
public final class VoteTaskAdmission {
+ private static final ThreadLocal VOTE_TASK_DEPTH = new ThreadLocal<>();
private VoteTaskAdmission() {
}
@@ -19,7 +20,7 @@ private VoteTaskAdmission() {
*/
public static boolean trySubmit(ScheduledExecutorService executor, Runnable task) {
try {
- executor.submit(task);
+ executor.submit(wrap(task));
return true;
} catch (RejectedExecutionException rejected) {
return false;
@@ -37,10 +38,35 @@ public static boolean trySubmit(ScheduledExecutorService executor, Runnable task
*/
public static boolean trySchedule(ScheduledExecutorService executor, Runnable task, long delay, TimeUnit unit) {
try {
- executor.schedule(task, delay, unit);
+ executor.schedule(wrap(task), delay, unit);
return true;
} catch (RejectedExecutionException rejected) {
return false;
}
}
+
+ /** Returns whether the current call is owned by an admitted vote-executor task. */
+ public static boolean isVoteTask() {
+ Integer depth = VOTE_TASK_DEPTH.get();
+ return depth != null && depth.intValue() > 0;
+ }
+
+ /** Marks an asynchronously scheduled producer as owning vote processing. */
+ public static Runnable ownedTask(Runnable task) {
+ return wrap(task);
+ }
+
+ private static Runnable wrap(Runnable task) {
+ return () -> {
+ Integer depth = VOTE_TASK_DEPTH.get();
+ int previous = depth == null ? 0 : depth.intValue();
+ VOTE_TASK_DEPTH.set(Integer.valueOf(previous + 1));
+ try {
+ task.run();
+ } finally {
+ if (previous == 0) VOTE_TASK_DEPTH.remove();
+ else VOTE_TASK_DEPTH.set(Integer.valueOf(previous));
+ }
+ };
+ }
}
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..cdfd760184 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
@@ -9,11 +9,14 @@
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
+import java.time.Instant;
+import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
+import java.util.UUID;
import java.util.concurrent.TimeUnit;
import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
@@ -29,6 +32,12 @@
* arbitrary, non-idempotent side effects.
*/
final class SharedMysqlPurchaseJournal {
+ enum DailyStreakOutcome { APPLIED, ALREADY_UPDATED, DEFERRED }
+ record DailyStreakResult(DailyStreakOutcome outcome, int streak) { }
+ record RecoveredDailyStreak(UUID voteId, String uuid, int streak, boolean forceProxyRouting) { }
+ record AccountingRecoveryBatch(boolean hadRows, List pendingRewards) { }
+ record VoteAccountingDecision(int bits, int pointAmount, int pointCap, String pointColumn,
+ boolean replayUnsafe) { }
private static final String PENDING = "PENDING";
private static final String HOOK_STARTED = "HOOK_STARTED";
private static final String COMPENSATING = "COMPENSATING";
@@ -43,9 +52,25 @@ final class SharedMysqlPurchaseJournal {
private static final int MAX_IDENTIFIER_BYTES = 63;
private static final String JOURNAL_SUFFIX = "_VoteShopPurchases";
private static final String EPOCH_SUFFIX = "_VoteShopLimitEpochs";
+ private static final String ACCOUNTING_SUFFIX = "_VoteAccounting";
private static final String HASHED_TABLE_PREFIX = "vp_vsp_";
private static final String HASHED_EPOCH_TABLE_PREFIX = "vp_vse_";
+ private static final String HASHED_ACCOUNTING_TABLE_PREFIX = "vp_vsa_";
private static final int HASHED_TABLE_HEX_LENGTH = 32;
+ private static final int DAILY_TOTAL = 1;
+ private static final int WEEKLY_TOTAL = 2;
+ private static final int MONTH_TOTAL = 4;
+ private static final int VOTE_PARTY_TOTAL = 8;
+ private static final int DAILY_STREAK = 16;
+ private static final int DAILY_STREAK_REWARD = 32;
+ private static final int ACCOUNTING_DECIDED = 64;
+ private static final int DAILY_STREAK_REWARD_CLAIMED = 128;
+ private static final int AWARD_POINTS = 256;
+ private static final int ALL_TIME_TOTAL = 512;
+ private static final int MONTH_TOTAL_RESERVED = 1024;
+ private static final int ACCOUNTING_METADATA = ACCOUNTING_DECIDED | MONTH_TOTAL_RESERVED;
+ private static final int RECOVERABLE_NON_REWARD_ACCOUNTING = DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL
+ | VOTE_PARTY_TOTAL | DAILY_STREAK | ALL_TIME_TOTAL;
private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>();
private static final Set INITIALIZED = new HashSet<>();
@@ -53,11 +78,13 @@ final class SharedMysqlPurchaseJournal {
private final MySQL table;
private final String journalTable;
private final String epochTable;
+ private final String accountingTable;
SharedMysqlPurchaseJournal(MySQL table, boolean initializeSchema) throws SQLException {
this.table = table;
journalTable = journalTableName(table.getTableName());
epochTable = epochTableName(table.getTableName());
+ accountingTable = accountingTableName(table.getTableName());
if (initializeSchema) ensureSchema();
}
@@ -74,6 +101,10 @@ static String epochTableName(String sourceTable) {
return auxiliaryTableName(sourceTable, EPOCH_SUFFIX, HASHED_EPOCH_TABLE_PREFIX);
}
+ static String accountingTableName(String sourceTable) {
+ return auxiliaryTableName(sourceTable, ACCOUNTING_SUFFIX, HASHED_ACCOUNTING_TABLE_PREFIX);
+ }
+
private static String auxiliaryTableName(String sourceTable, String suffix, String hashedPrefix) {
String legacyName = sourceTable + suffix;
if (legacyName.getBytes(StandardCharsets.UTF_8).length <= MAX_IDENTIFIER_BYTES) return legacyName;
@@ -223,6 +254,948 @@ void resetLimit(String limitColumn, String resetGeneration) throws SQLException
}
}
+ /** Subtracts the copied boundary total once, retaining votes accepted later. */
+ void resetPeriodTotal(String totalColumn, String previousColumn, String resetGeneration) throws SQLException {
+ if (!isSafeColumn(totalColumn) || !isSafeColumn(previousColumn)) {
+ throw new SQLException("Unsafe period total column");
+ }
+ if (resetGeneration == null || resetGeneration.isEmpty() || resetGeneration.length() > 128) {
+ throw new SQLException("Invalid period total reset generation");
+ }
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ String markerKey = "period-reset:" + totalColumn;
+ EpochRow marker = lockLimitEpochRow(connection, markerKey);
+ if (resetGeneration.equals(marker.lastResetGeneration())) {
+ rollback(connection);
+ return;
+ }
+ long oldEpoch = marker.epoch();
+ if (oldEpoch == Long.MAX_VALUE) throw new SQLException("Period total reset epoch overflow");
+ String difference = "COALESCE(" + qi(totalColumn) + ", 0) - COALESCE(" + qi(previousColumn)
+ + ", 0)";
+ String bounded = "GREATEST(0, " + difference + ')';
+ try (PreparedStatement reset = connection.prepareStatement("UPDATE " + qi(table.getTableName())
+ + " SET " + qi(totalColumn) + " = " + bounded);
+ PreparedStatement advance = connection.prepareStatement("UPDATE " + qiEpoch() + " SET "
+ + qi("epoch") + " = ?, " + qi("last_reset_generation") + " = ? WHERE "
+ + qi("limit_column") + " = ?")) {
+ reset.executeUpdate();
+ advance.setLong(1, oldEpoch + 1L);
+ advance.setString(2, resetGeneration);
+ advance.setString(3, markerKey);
+ if (advance.executeUpdate() != 1) throw new SQLException("Period total epoch marker missing");
+ }
+ connection.commit();
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ private AccountingRow commitAndConfirmRequested(Connection connection, UUID voteId, int operation)
+ throws SQLException {
+ try {
+ connection.commit();
+ return null;
+ } catch (SQLException ambiguousCommit) {
+ closeQuietly(connection);
+ try {
+ AccountingRow row = findAccountingVote(voteId);
+ if (row != null && (row.requested() & operation) == operation) return row;
+ } catch (SQLException confirmationFailure) {
+ ambiguousCommit.addSuppressed(confirmationFailure);
+ }
+ throw ambiguousCommit;
+ }
+ }
+
+ /**
+ * Serializes accepted-vote increments with the shared boundary marker. Every
+ * backend therefore agrees whether an increment precedes or follows the copy.
+ */
+ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn, List columns,
+ Integer maximum, boolean alreadyRequested)
+ throws SQLException {
+ return incrementPeriodTotalsResolved(voteId, uuid, boundaryColumn, previousColumn, columns, maximum,
+ alreadyRequested).applied();
+ }
+
+ PeriodTotalResult incrementPeriodTotalsResolved(UUID voteId, String uuid, String boundaryColumn,
+ String previousColumn, List columns, Integer maximum, boolean alreadyRequested)
+ throws SQLException {
+ int operation = accountingOperation(boundaryColumn);
+ if (alreadyRequested) {
+ AccountingRow row = findAccountingVote(voteId);
+ if (row == null || (row.requested() & operation) == 0 || !java.util.Objects.equals(row.uuid(), uuid)) {
+ throw new SQLException("Admitted period total payload is missing or does not match");
+ }
+ if (operation == MONTH_TOTAL) {
+ columns = row.monthColumn() == null ? List.of(boundaryColumn)
+ : List.of(boundaryColumn, row.monthColumn());
+ // Capped monthly increments marked as reserved during durable admission
+ // must not re-evaluate an older cap after later votes advance the total.
+ maximum = (row.requested() & MONTH_TOTAL_RESERVED) != 0 ? null : row.monthMaximum();
+ }
+ }
+ if (voteId == null || uuid == null || uuid.isEmpty() || !isSafeColumn(boundaryColumn) || columns == null
+ || columns.isEmpty() || columns.stream().anyMatch(column -> !isSafeColumn(column))
+ || maximum != null && (maximum.intValue() < 0 || !isSafeColumn(previousColumn))) {
+ throw new SQLException("Invalid period total increment");
+ }
+ if (!alreadyRequested) {
+ requestAccounting(voteId, uuid, operation, null,
+ operation == MONTH_TOTAL && columns.size() > 1 ? columns.get(1) : null, maximum, null, null);
+ }
+ try {
+ applyPeriodTotals(voteId, uuid, boundaryColumn, previousColumn, columns, maximum, operation);
+ return new PeriodTotalResult(true, List.copyOf(columns));
+ } catch (SQLException deferred) {
+ return new PeriodTotalResult(false, List.copyOf(columns));
+ }
+ }
+
+ boolean incrementPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn,
+ List columns, Integer maximum) throws SQLException {
+ return incrementPeriodTotals(voteId, uuid, boundaryColumn, previousColumn, columns, maximum, false);
+ }
+
+ VoteAccountingDecision prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean awardPoints,
+ boolean countVoteParty,
+ String monthColumn, Integer monthMaximum, boolean streakUsesPercentage, double streakPercentage,
+ int enabledSiteCount, boolean forceProxyRouting, long acceptedAt, int pointAmount, int pointCap,
+ String pointColumn)
+ throws SQLException {
+ if (!isSafeColumn(pointColumn)) throw new SQLException("Unsafe shared point column");
+ int requested = (countTotals ? DAILY_TOTAL | WEEKLY_TOTAL | MONTH_TOTAL | ALL_TIME_TOTAL : 0)
+ | (awardPoints ? AWARD_POINTS : 0)
+ | (countVoteParty ? VOTE_PARTY_TOTAL : 0);
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ EpochRow dailyCopyMarker = null;
+ if ((requested & DAILY_TOTAL) != 0 || streakUsesPercentage) {
+ dailyCopyMarker = lockLimitEpochRow(connection, "period-copy:DailyTotal");
+ }
+ EpochRow monthCopyMarker = null;
+ if ((requested & MONTH_TOTAL) != 0) {
+ monthCopyMarker = lockLimitEpochRow(connection, "period-copy:MonthTotal");
+ }
+ if ((requested & VOTE_PARTY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:VotePartyVotes");
+ if ((requested & WEEKLY_TOTAL) != 0) lockLimitEpochRow(connection, "period-copy:WeeklyTotal");
+ lockLimitEpochRow(connection, "streak-copy:DayVoteStreak");
+ lockLimitEpochRow(connection, "streak-reset:DayVoteStreak");
+ ensureAccountingVote(connection, voteId, uuid);
+ AccountingRow row = findAndLockAccountingVote(connection, voteId);
+ if (row == null || row.uuid() != null && !row.uuid().equals(uuid)) {
+ throw new SQLException("Vote accounting identity does not match");
+ }
+ boolean accountingAlreadyDecided = (row.requested() & ACCOUNTING_DECIDED) != 0;
+ int accountingMetadata = 0;
+ if (!accountingAlreadyDecided && (requested & MONTH_TOTAL) != 0 && monthMaximum != null) {
+ boolean monthResetPending = resetPending(connection, "MonthTotal", monthCopyMarker);
+ int admittedMonthTotal = findPeriodTotal(connection, uuid, "MonthTotal", "LastMonthTotal",
+ monthResetPending) + countPendingPeriodTotals(connection, voteId, uuid, MONTH_TOTAL);
+ if (admittedMonthTotal >= monthMaximum.intValue()) requested &= ~MONTH_TOTAL;
+ else accountingMetadata |= MONTH_TOTAL_RESERVED;
+ }
+ int admittedPointAmount = accountingAlreadyDecided && row.pointAmount() != null
+ ? row.pointAmount().intValue() : pointAmount;
+ int admittedPointCap = accountingAlreadyDecided && row.pointCap() != null
+ ? row.pointCap().intValue() : pointCap;
+ String admittedPointColumn = accountingAlreadyDecided && row.pointColumn() != null
+ && !row.pointColumn().isEmpty() ? row.pointColumn() : pointColumn;
+ boolean dailyResetPending = !accountingAlreadyDecided && streakUsesPercentage
+ && resetPending(connection, "DailyTotal", dailyCopyMarker);
+ DailyStreakCandidate streak = accountingAlreadyDecided ? null
+ : findDailyStreakCandidate(connection, uuid, dailyResetPending);
+ boolean dailyAlreadyApplied = (row.completed() & DAILY_TOTAL) != 0;
+ int projectedDailyTotal = accountingAlreadyDecided || !streakUsesPercentage ? 0
+ : streak.dailyTotal() + countPendingPeriodTotals(connection, voteId, uuid, DAILY_TOTAL)
+ + (countTotals && !dailyAlreadyApplied ? 1 : 0);
+ boolean percentageMet = accountingAlreadyDecided || !streakUsesPercentage || enabledSiteCount > 0
+ && (double) projectedDailyTotal / (double) enabledSiteCount * 100 > streakPercentage;
+ boolean requestStreak = !accountingAlreadyDecided
+ && !sameLocalDay(streak.lastUpdate(), acceptedAt) && percentageMet;
+ if (accountingAlreadyDecided) requested = row.requested();
+ else if (requestStreak) requested |= DAILY_STREAK | DAILY_STREAK_REWARD;
+ int persistedRequested = row.requested() | requested | ACCOUNTING_DECIDED | accountingMetadata;
+ int persistedCompleted = row.completed() | ACCOUNTING_DECIDED
+ | accountingMetadata | (persistedRequested & AWARD_POINTS);
+ String update = "UPDATE " + qiAccounting() + " SET " + qi("player_uuid") + " = ?, "
+ + qi("requested") + " = ?, " + qi("completed") + " = ?, "
+ + qi("month_column") + " = COALESCE(?, "
+ + qi("month_column") + "), " + qi("month_maximum") + " = COALESCE(?, "
+ + qi("month_maximum") + "), " + qi("streak_value") + " = COALESCE(?, "
+ + qi("streak_value") + "), " + qi("streak_updated_at") + " = COALESCE(?, "
+ + qi("streak_updated_at") + "), " + qi("streak_force_proxy") + " = COALESCE(?, "
+ + qi("streak_force_proxy") + "), " + qi("point_amount") + " = COALESCE("
+ + qi("point_amount") + ", ?), " + qi("point_cap") + " = COALESCE("
+ + qi("point_cap") + ", ?), " + qi("point_column") + " = COALESCE("
+ + qi("point_column") + ", ?) WHERE " + qi("vote_id") + " = ?";
+ try (PreparedStatement statement = connection.prepareStatement(update)) {
+ statement.setString(1, uuid);
+ statement.setInt(2, persistedRequested);
+ statement.setInt(3, persistedCompleted);
+ statement.setString(4, monthColumn);
+ if (monthMaximum == null) statement.setNull(5, java.sql.Types.INTEGER);
+ else statement.setInt(5, monthMaximum.intValue());
+ if (requestStreak) statement.setInt(6, streak.streak() + 1);
+ else statement.setNull(6, java.sql.Types.INTEGER);
+ if (requestStreak) statement.setLong(7, acceptedAt);
+ else statement.setNull(7, java.sql.Types.BIGINT);
+ if (requestStreak) statement.setInt(8, forceProxyRouting ? 1 : 0);
+ else statement.setNull(8, java.sql.Types.INTEGER);
+ statement.setInt(9, admittedPointAmount);
+ statement.setInt(10, admittedPointCap);
+ statement.setString(11, admittedPointColumn);
+ statement.setString(12, voteId.toString());
+ if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing");
+ }
+ AccountingRow confirmed = commitAndConfirmRequested(connection, voteId, persistedRequested);
+ if (confirmed != null) {
+ if (!java.util.Objects.equals(confirmed.uuid(), uuid) || confirmed.pointAmount() == null
+ || confirmed.pointCap() == null || confirmed.pointColumn() == null) {
+ throw new SQLException("Confirmed vote accounting payload is incomplete or does not match");
+ }
+ return new VoteAccountingDecision(confirmed.requested() & ~ACCOUNTING_METADATA,
+ confirmed.pointAmount().intValue(), confirmed.pointCap().intValue(),
+ confirmed.pointColumn(), confirmed.replayUnsafe());
+ }
+ return new VoteAccountingDecision(persistedRequested & ~ACCOUNTING_METADATA,
+ admittedPointAmount, admittedPointCap, admittedPointColumn, row.replayUnsafe());
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ int prepareVoteAccounting(UUID voteId, String uuid, boolean countTotals, boolean awardPoints,
+ boolean countVoteParty, String monthColumn, Integer monthMaximum, boolean streakUsesPercentage,
+ double streakPercentage, int enabledSiteCount, boolean forceProxyRouting, long acceptedAt)
+ throws SQLException {
+ return prepareVoteAccounting(voteId, uuid, countTotals, awardPoints, countVoteParty, monthColumn,
+ monthMaximum, streakUsesPercentage, streakPercentage, enabledSiteCount, forceProxyRouting,
+ acceptedAt, 0, -1, "Points").bits();
+ }
+
+ void markVoteReplayUnsafe(UUID voteId) throws SQLException {
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ AccountingRow row = findAndLockAccountingVote(connection, voteId);
+ if (row == null) throw new SQLException("Vote accounting marker is missing");
+ if (row.replayUnsafe()) {
+ rollback(connection);
+ return;
+ }
+ try (PreparedStatement statement = connection.prepareStatement("UPDATE " + qiAccounting()
+ + " SET " + qi("replay_unsafe") + " = 1 WHERE " + qi("vote_id") + " = ?")) {
+ statement.setString(1, voteId.toString());
+ if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing");
+ }
+ try {
+ connection.commit();
+ } catch (SQLException ambiguousCommit) {
+ closeQuietly(connection);
+ AccountingRow confirmed = findAccountingVote(voteId);
+ if (confirmed != null && confirmed.replayUnsafe()) return;
+ throw ambiguousCommit;
+ }
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ /** Retains a bounded replay tombstone after the durable delivery owner completes. */
+ void retireVoteAccounting(UUID voteId, long retiredAt) throws SQLException {
+ if (voteId == null) return;
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ String retire = "UPDATE " + qiAccounting() + " SET " + qi("retired_at") + " = COALESCE("
+ + qi("retired_at") + ", ?) WHERE " + qi("vote_id") + " = ?";
+ try (PreparedStatement statement = connection.prepareStatement(retire)) {
+ statement.setLong(1, retiredAt);
+ statement.setString(2, voteId.toString());
+ statement.executeUpdate();
+ }
+ long cutoff = retiredAt - TERMINAL_RETENTION_MILLIS;
+ String select = "SELECT " + qi("vote_id") + " FROM " + qiAccounting() + " WHERE "
+ + qi("retired_at") + " IS NOT NULL AND " + qi("retired_at")
+ + " <= ? AND " + qi("requested") + " = " + qi("completed") + " ORDER BY "
+ + qi("retired_at") + " ASC LIMIT ?";
+ List expired = new ArrayList<>();
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setLong(1, cutoff);
+ statement.setInt(2, CLEANUP_BATCH_SIZE);
+ try (ResultSet result = statement.executeQuery()) {
+ while (result.next()) expired.add(result.getString(1));
+ }
+ }
+ String delete = "DELETE FROM " + qiAccounting() + " WHERE " + qi("vote_id") + " = ? AND "
+ + qi("retired_at") + " IS NOT NULL AND " + qi("retired_at") + " <= ? AND "
+ + qi("requested") + " = " + qi("completed");
+ try (PreparedStatement statement = connection.prepareStatement(delete)) {
+ for (String expiredId : expired) {
+ statement.setString(1, expiredId);
+ statement.setLong(2, cutoff);
+ statement.executeUpdate();
+ }
+ }
+ connection.commit();
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ private int countPendingPeriodTotals(Connection connection, UUID voteId, String uuid, int operation)
+ throws SQLException {
+ String select = "SELECT COUNT(*) FROM " + qiAccounting() + " WHERE " + qi("player_uuid")
+ + " = ? AND " + qi("vote_id") + " <> ? AND (" + qi("requested") + " & ?) <> 0 AND ("
+ + qi("completed") + " & ?) = 0 AND (" + qi("completed") + " & ?) <> 0";
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, uuid);
+ statement.setString(2, voteId.toString());
+ statement.setInt(3, operation);
+ statement.setInt(4, operation);
+ statement.setInt(5, ACCOUNTING_DECIDED);
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next() ? result.getInt(1) : 0;
+ }
+ }
+ }
+
+ private int findPeriodTotal(Connection connection, String uuid, String totalColumn, String previousColumn,
+ boolean resetPending) throws SQLException {
+ String total = resetPending
+ ? "GREATEST(0, COALESCE(" + qi(totalColumn) + ", 0) - COALESCE(" + qi(previousColumn) + ", 0))"
+ : "COALESCE(" + qi(totalColumn) + ", 0)";
+ String select = "SELECT " + total + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid")
+ + uuidCast();
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, uuid);
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next() ? result.getInt(1) : 0;
+ }
+ }
+ }
+
+ private DailyStreakCandidate findDailyStreakCandidate(Connection connection, String uuid, boolean resetPending)
+ throws SQLException {
+ String dailyTotal = resetPending
+ ? "GREATEST(0, COALESCE(" + qi("DailyTotal") + ", 0) - COALESCE(" + qi("LastDailyTotal") + ", 0))"
+ : "COALESCE(" + qi("DailyTotal") + ", 0)";
+ String select = "SELECT " + dailyTotal + ", COALESCE(" + qi("DayVoteStreak")
+ + ", 0), " + qi("DayVoteStreakLastUpdate") + " FROM " + qi(table.getTableName()) + " WHERE "
+ + qi("uuid") + uuidCast() + " FOR UPDATE";
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, uuid);
+ try (ResultSet result = statement.executeQuery()) {
+ if (!result.next()) return new DailyStreakCandidate(0, 0, null);
+ return new DailyStreakCandidate(result.getInt(1), result.getInt(2), result.getString(3));
+ }
+ }
+ }
+
+ private void applyPeriodTotals(UUID voteId, String uuid, String boundaryColumn, String previousColumn,
+ List columns, Integer maximum, int operation) throws SQLException {
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ EpochRow copyMarker = lockLimitEpochRow(connection, "period-copy:" + boundaryColumn);
+ AccountingRow accounting = findAndLockAccountingVote(connection, voteId);
+ if (accounting == null) throw new SQLException("Vote accounting marker is missing");
+ int completed = accounting.completed();
+ if ((completed & operation) != 0) {
+ rollback(connection);
+ return;
+ }
+ boolean resetPending = maximum != null && resetPending(connection, boundaryColumn, copyMarker);
+ StringBuilder sql = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ");
+ for (int index = 0; index < columns.size(); index++) {
+ if (index > 0) sql.append(", ");
+ String quoted = qi(columns.get(index));
+ String increment = "COALESCE(" + quoted + ", 0) + 1";
+ sql.append(quoted).append(" = ");
+ if (maximum != null && resetPending && columns.get(index).equals(boundaryColumn)) {
+ sql.append("GREATEST(COALESCE(").append(quoted).append(", 0), LEAST(COALESCE(")
+ .append(qi(previousColumn)).append(", 0) + ?, ").append(increment).append("))");
+ } else if (maximum != null) {
+ sql.append("GREATEST(COALESCE(").append(quoted).append(", 0), LEAST(?, ")
+ .append(increment).append("))");
+ }
+ else sql.append(increment);
+ }
+ sql.append(" WHERE ").append(qi("uuid")).append(uuidCast());
+ try (PreparedStatement update = connection.prepareStatement(sql.toString())) {
+ int parameter = 1;
+ if (maximum != null) {
+ for (int ignored = 0; ignored < columns.size(); ignored++) {
+ update.setInt(parameter++, maximum.intValue());
+ }
+ }
+ update.setString(parameter, uuid);
+ if (update.executeUpdate() != 1) throw new SQLException("Period total user row is missing");
+ }
+ markAccountingComplete(connection, voteId, completed | operation);
+ commitAndConfirmAccounting(connection, voteId, operation);
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ private static int accountingOperation(String boundaryColumn) throws SQLException {
+ return switch (boundaryColumn) {
+ case "DailyTotal" -> DAILY_TOTAL;
+ case "WeeklyTotal" -> WEEKLY_TOTAL;
+ case "MonthTotal" -> MONTH_TOTAL;
+ case "VotePartyVotes" -> VOTE_PARTY_TOTAL;
+ case "AllTimeTotal" -> ALL_TIME_TOTAL;
+ default -> throw new SQLException("Unsupported period total boundary");
+ };
+ }
+
+ private void requestAccounting(UUID voteId, String uuid, int operation, String boundaryMarker, String monthColumn, Integer monthMaximum,
+ Integer streakValue, Long streakUpdatedAt) throws SQLException {
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ if (boundaryMarker != null) lockLimitEpochRow(connection, boundaryMarker);
+ ensureAccountingVote(connection, voteId, uuid);
+ AccountingRow row = findAndLockAccountingVote(connection, voteId);
+ if (row == null || row.uuid() != null && !row.uuid().equals(uuid)) {
+ throw new SQLException("Vote accounting identity does not match");
+ }
+ String update = "UPDATE " + qiAccounting() + " SET " + qi("player_uuid") + " = ?, "
+ + qi("requested") + " = ?, " + qi("month_column") + " = COALESCE(?, "
+ + qi("month_column") + "), " + qi("month_maximum") + " = COALESCE(?, "
+ + qi("month_maximum") + "), " + qi("streak_value") + " = COALESCE(?, "
+ + qi("streak_value") + "), " + qi("streak_updated_at") + " = COALESCE(?, "
+ + qi("streak_updated_at") + ") WHERE " + qi("vote_id") + " = ?";
+ try (PreparedStatement statement = connection.prepareStatement(update)) {
+ statement.setString(1, uuid);
+ statement.setInt(2, row.requested() | operation);
+ statement.setString(3, monthColumn);
+ if (monthMaximum == null) statement.setNull(4, java.sql.Types.INTEGER);
+ else statement.setInt(4, monthMaximum.intValue());
+ if (streakValue == null) statement.setNull(5, java.sql.Types.INTEGER);
+ else statement.setInt(5, streakValue.intValue());
+ if (streakUpdatedAt == null) statement.setNull(6, java.sql.Types.BIGINT);
+ else statement.setLong(6, streakUpdatedAt.longValue());
+ statement.setString(7, voteId.toString());
+ if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing");
+ }
+ commitAndConfirmRequested(connection, voteId, operation);
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ private void ensureAccountingVote(Connection connection, UUID voteId, String uuid) throws SQLException {
+ String insert = table.getDbType() == DbType.POSTGRESQL
+ ? "INSERT INTO " + qiAccounting() + " (" + qi("vote_id") + ", " + qi("player_uuid") + ", "
+ + qi("requested") + ", " + qi("completed") + ", " + qi("created_at")
+ + ") VALUES (?, ?, 0, 0, ?) ON CONFLICT DO NOTHING"
+ : "INSERT IGNORE INTO " + qiAccounting() + " (" + qi("vote_id") + ", " + qi("player_uuid") + ", "
+ + qi("requested") + ", " + qi("completed") + ", " + qi("created_at")
+ + ") VALUES (?, ?, 0, 0, ?)";
+ try (PreparedStatement statement = connection.prepareStatement(insert)) {
+ statement.setString(1, voteId.toString());
+ statement.setString(2, uuid);
+ statement.setLong(3, System.currentTimeMillis());
+ statement.executeUpdate();
+ }
+ }
+
+ private AccountingRow findAndLockAccountingVote(Connection connection, UUID voteId) throws SQLException {
+ String select = "SELECT " + accountingColumns() + " FROM " + qiAccounting() + " WHERE "
+ + qi("vote_id") + " = ? FOR UPDATE";
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, voteId.toString());
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next() ? accountingRow(result) : null;
+ }
+ }
+ }
+
+ private void markAccountingComplete(Connection connection, UUID voteId, int completed) throws SQLException {
+ markAccountingComplete(connection, voteId, completed, null);
+ }
+
+ private void markAccountingComplete(Connection connection, UUID voteId, int completed, Integer appliedStreak)
+ throws SQLException {
+ String update = "UPDATE " + qiAccounting() + " SET " + qi("completed") + " = ?, "
+ + qi("streak_applied_value") + " = COALESCE(?, " + qi("streak_applied_value") + "), "
+ + qi("created_at") + " = ? WHERE "
+ + qi("vote_id") + " = ?";
+ try (PreparedStatement statement = connection.prepareStatement(update)) {
+ statement.setInt(1, completed);
+ if (appliedStreak == null) statement.setNull(2, java.sql.Types.INTEGER);
+ else statement.setInt(2, appliedStreak.intValue());
+ statement.setLong(3, System.currentTimeMillis());
+ statement.setString(4, voteId.toString());
+ if (statement.executeUpdate() != 1) throw new SQLException("Vote accounting marker is missing");
+ }
+ }
+
+ private void commitAndConfirmAccounting(Connection connection, UUID voteId, int operation) throws SQLException {
+ try {
+ connection.commit();
+ } catch (SQLException ambiguousCommit) {
+ closeQuietly(connection);
+ try {
+ AccountingRow row = findAccountingVote(voteId);
+ if (row != null && (row.completed() & operation) == operation) return;
+ } catch (SQLException confirmationFailure) {
+ ambiguousCommit.addSuppressed(confirmationFailure);
+ }
+ throw ambiguousCommit;
+ }
+ }
+
+ private AccountingRow findAccountingVote(UUID voteId) throws SQLException {
+ String select = "SELECT " + accountingColumns() + " FROM " + qiAccounting() + " WHERE "
+ + qi("vote_id") + " = ?";
+ try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, voteId.toString());
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next() ? accountingRow(result) : null;
+ }
+ }
+ }
+
+ RecoveredDailyStreak claimDailyStreakReward(UUID voteId) throws SQLException {
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ AccountingRow row = findAndLockAccountingVote(connection, voteId);
+ if (row == null || (row.requested() & DAILY_STREAK_REWARD) == 0
+ || (row.completed() & DAILY_STREAK) == 0
+ || (row.completed() & DAILY_STREAK_REWARD) != 0) {
+ rollback(connection);
+ return null;
+ }
+ if ((row.completed() & DAILY_STREAK_REWARD_CLAIMED) != 0) {
+ throw new SQLException("Daily streak reward may already have run and requires manual reconciliation");
+ }
+ if (row.streakAppliedValue() == null) {
+ throw new SQLException("Applied daily streak value is missing");
+ }
+ int streak = row.streakAppliedValue().intValue();
+ markAccountingComplete(connection, voteId, row.completed() | DAILY_STREAK_REWARD_CLAIMED);
+ commitAndConfirmAccounting(connection, voteId, DAILY_STREAK_REWARD_CLAIMED);
+ return new RecoveredDailyStreak(voteId, row.uuid(), streak,
+ row.streakForceProxy() != null && row.streakForceProxy().intValue() != 0);
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ void completeDailyStreakReward(UUID voteId) throws SQLException {
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ AccountingRow row = findAndLockAccountingVote(connection, voteId);
+ if (row == null) throw new SQLException("Vote accounting marker is missing");
+ if ((row.completed() & DAILY_STREAK_REWARD) != 0) {
+ rollback(connection);
+ return;
+ }
+ if ((row.completed() & DAILY_STREAK_REWARD_CLAIMED) == 0) {
+ throw new SQLException("Daily streak reward was not claimed");
+ }
+ int completed = (row.completed() | DAILY_STREAK_REWARD) & ~DAILY_STREAK_REWARD_CLAIMED;
+ markAccountingComplete(connection, voteId, completed);
+ commitAndConfirmAccounting(connection, voteId, DAILY_STREAK_REWARD);
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ private String accountingColumns() {
+ return qi("player_uuid") + ", " + qi("requested") + ", " + qi("completed") + ", "
+ + qi("month_column") + ", " + qi("month_maximum") + ", " + qi("streak_value") + ", "
+ + qi("streak_updated_at") + ", " + qi("streak_force_proxy") + ", "
+ + qi("streak_applied_value") + ", " + qi("point_amount") + ", " + qi("point_cap") + ", "
+ + qi("point_column") + ", "
+ + qi("replay_unsafe");
+ }
+
+ private static AccountingRow accountingRow(ResultSet result) throws SQLException {
+ return new AccountingRow(result.getString(1), result.getInt(2), result.getInt(3), result.getString(4),
+ nullableInteger(result, 5), nullableInteger(result, 6), nullableLong(result, 7),
+ nullableInteger(result, 8), nullableInteger(result, 9), nullableInteger(result, 10),
+ nullableInteger(result, 11), result.getString(12), result.getInt(13) != 0);
+ }
+
+ private boolean resetPending(Connection connection, String boundaryColumn, EpochRow copyMarker)
+ throws SQLException {
+ if (copyMarker == null) return false;
+ String copyTransition = generationTransition(copyMarker.lastResetGeneration(), "time-copy:");
+ if (copyTransition == null) return false;
+ EpochRow resetMarker = lockLimitEpochRow(connection, "period-reset:" + boundaryColumn);
+ String resetTransition = generationTransition(resetMarker.lastResetGeneration(), "time-total:");
+ return copyTransition != null && !copyTransition.equals(resetTransition);
+ }
+
+ private static String generationTransition(String generation, String prefix) {
+ return generation != null && generation.startsWith(prefix) ? generation.substring(prefix.length()) : null;
+ }
+
+ /** Serializes the accepted daily-streak value and timestamp with its shared boundary. */
+ DailyStreakResult updateDailyStreak(UUID voteId, String uuid, int streak, long updatedAt,
+ boolean alreadyRequested) throws SQLException {
+ if (alreadyRequested) {
+ AccountingRow row = findAccountingVote(voteId);
+ if (row == null || (row.requested() & DAILY_STREAK) == 0) {
+ return new DailyStreakResult(DailyStreakOutcome.ALREADY_UPDATED, 0);
+ }
+ if (row.streakValue() == null || row.streakUpdatedAt() == null) {
+ throw new SQLException("Admitted daily streak payload is incomplete");
+ }
+ streak = row.streakValue().intValue();
+ updatedAt = row.streakUpdatedAt().longValue();
+ } else {
+ requestAccounting(voteId, uuid, DAILY_STREAK | DAILY_STREAK_REWARD,
+ "streak-copy:DayVoteStreak", null, null, Integer.valueOf(streak), Long.valueOf(updatedAt));
+ }
+ try {
+ return applyDailyStreak(voteId, uuid, streak, updatedAt);
+ } catch (SQLException deferred) {
+ return new DailyStreakResult(DailyStreakOutcome.DEFERRED, 0);
+ }
+ }
+
+ private DailyStreakResult applyDailyStreak(UUID voteId, String uuid, int streak, long updatedAt) throws SQLException {
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ EpochRow copyMarker = lockLimitEpochRow(connection, "streak-copy:DayVoteStreak");
+ EpochRow resetMarker = lockLimitEpochRow(connection, "streak-reset:DayVoteStreak");
+ String copyTransition = generationTransition(copyMarker.lastResetGeneration(), "time-streak-copy:");
+ String resetTransition = generationTransition(resetMarker.lastResetGeneration(), "time-streak-reset:");
+ if (copyTransition != null && !copyTransition.equals(resetTransition)) {
+ throw new SQLException("Daily streak boundary reset is still active");
+ }
+ AccountingRow accounting = findAndLockAccountingVote(connection, voteId);
+ if (accounting == null) throw new SQLException("Vote accounting marker is missing");
+ int completed = accounting.completed();
+ if ((completed & DAILY_STREAK) != 0) {
+ Integer appliedStreak = accounting.streakAppliedValue();
+ rollback(connection);
+ DailyStreakOutcome outcome = (completed & DAILY_STREAK_REWARD) == 0
+ ? DailyStreakOutcome.APPLIED : DailyStreakOutcome.ALREADY_UPDATED;
+ if (outcome == DailyStreakOutcome.APPLIED && appliedStreak == null) {
+ throw new SQLException("Applied daily streak value is missing");
+ }
+ return new DailyStreakResult(outcome, appliedStreak == null ? 0 : appliedStreak.intValue());
+ }
+ if (hasEarlierPendingDailyStreak(connection, voteId, uuid, updatedAt)) {
+ throw new SQLException("An earlier daily streak is still pending");
+ }
+ String persistedUpdate = findDailyStreakUpdate(connection, uuid);
+ if (sameLocalDay(persistedUpdate, updatedAt)) {
+ int persistedStreak = findDailyStreakValue(connection, uuid);
+ markAccountingComplete(connection, voteId,
+ completed | DAILY_STREAK | (accounting.requested() & DAILY_STREAK_REWARD));
+ commitAndConfirmAccounting(connection, voteId,
+ DAILY_STREAK | (accounting.requested() & DAILY_STREAK_REWARD));
+ return new DailyStreakResult(DailyStreakOutcome.ALREADY_UPDATED, persistedStreak);
+ }
+ String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = COALESCE("
+ + qi("DayVoteStreak") + ", 0) + 1, " + qi("DayVoteStreakLastUpdate") + " = ? WHERE "
+ + qi("uuid") + uuidCast();
+ try (PreparedStatement update = connection.prepareStatement(sql)) {
+ update.setString(1, Long.toString(updatedAt));
+ update.setString(2, uuid);
+ if (update.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing");
+ }
+ int persistedStreak = findDailyStreakValue(connection, uuid);
+ markAccountingComplete(connection, voteId, completed | DAILY_STREAK,
+ Integer.valueOf(persistedStreak));
+ commitAndConfirmAccounting(connection, voteId, DAILY_STREAK);
+ return new DailyStreakResult(DailyStreakOutcome.APPLIED, persistedStreak);
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ private boolean hasEarlierPendingDailyStreak(Connection connection, UUID voteId, String uuid, long updatedAt)
+ throws SQLException {
+ String select = "SELECT " + qi("vote_id") + " FROM " + qiAccounting() + " WHERE "
+ + qi("player_uuid") + " = ? AND " + qi("vote_id") + " <> ? AND " + qi("streak_updated_at")
+ + " < ? AND (" + qi("requested") + " & ?) <> (" + qi("completed")
+ + " & ?) LIMIT 1 FOR UPDATE";
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, uuid);
+ statement.setString(2, voteId.toString());
+ statement.setLong(3, updatedAt);
+ // Only the ordered streak mutation blocks its successor. A reward claim can
+ // remain intentionally ambiguous forever, but its journaled streak value is
+ // independent and must not starve later-day mutations.
+ statement.setInt(4, DAILY_STREAK);
+ statement.setInt(5, DAILY_STREAK);
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next();
+ }
+ }
+ }
+
+ private int findDailyStreakValue(Connection connection, String uuid) throws SQLException {
+ String select = "SELECT COALESCE(" + qi("DayVoteStreak") + ", 0) FROM " + qi(table.getTableName())
+ + " WHERE " + qi("uuid") + uuidCast();
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, uuid);
+ try (ResultSet result = statement.executeQuery()) {
+ if (!result.next()) throw new SQLException("Daily streak user row is missing");
+ return result.getInt(1);
+ }
+ }
+ }
+
+ private String findDailyStreakUpdate(Connection connection, String uuid) throws SQLException {
+ String select = "SELECT " + qi("DayVoteStreakLastUpdate") + " FROM " + qi(table.getTableName())
+ + " WHERE " + qi("uuid") + uuidCast() + " FOR UPDATE";
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, uuid);
+ try (ResultSet result = statement.executeQuery()) {
+ if (!result.next()) throw new SQLException("Daily streak user row is missing");
+ return result.getString(1);
+ }
+ }
+ }
+
+ static boolean sameLocalDay(String persistedUpdate, long updatedAt) {
+ if (persistedUpdate == null || persistedUpdate.isEmpty()) return false;
+ try {
+ long persisted = Long.parseLong(persistedUpdate);
+ ZoneId zone = ZoneId.systemDefault();
+ return Instant.ofEpochMilli(persisted).atZone(zone).toLocalDate()
+ .equals(Instant.ofEpochMilli(updatedAt).atZone(zone).toLocalDate());
+ } catch (NumberFormatException invalidLegacyTimestamp) {
+ return false;
+ }
+ }
+
+ void resetDailyStreakAtBoundary(String uuid, long boundaryUpdatedAt) throws SQLException {
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ lockLimitEpochRow(connection, "streak-copy:DayVoteStreak");
+ String streak = qi("DayVoteStreak");
+ String updated = qi("DayVoteStreakLastUpdate");
+ String sql = "UPDATE " + qi(table.getTableName()) + " SET " + streak + " = CASE WHEN COALESCE("
+ + updated + ", '') = ? THEN 0 ELSE GREATEST(COALESCE(" + streak + ", 0), 1) END WHERE "
+ + qi("uuid") + uuidCast();
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, Long.toString(boundaryUpdatedAt));
+ statement.setString(2, uuid);
+ if (statement.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing");
+ }
+ connection.commit();
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ /** Publishes that every copied daily streak has been reset for this transition. */
+ boolean hasDailyStreakBoundary(String generation) throws SQLException {
+ if (generation == null || !generation.startsWith("time-streak-copy:") || generation.length() > 128) {
+ throw new SQLException("Invalid daily streak copy generation");
+ }
+ EpochRow marker = findLimitEpoch("streak-copy:DayVoteStreak");
+ return marker != null && generation.equals(marker.lastResetGeneration());
+ }
+
+ void completeDailyStreakReset(String generation) throws SQLException {
+ if (generation == null || !generation.startsWith("time-streak-reset:") || generation.length() > 128) {
+ throw new SQLException("Invalid daily streak reset generation");
+ }
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ EpochRow copyMarker = lockLimitEpochRow(connection, "streak-copy:DayVoteStreak");
+ EpochRow resetMarker = lockLimitEpochRow(connection, "streak-reset:DayVoteStreak");
+ if (generation.equals(resetMarker.lastResetGeneration())) {
+ rollback(connection);
+ return;
+ }
+ String copiedTransition = generationTransition(copyMarker.lastResetGeneration(), "time-streak-copy:");
+ String resetTransition = generationTransition(generation, "time-streak-reset:");
+ if (!resetTransition.equals(copiedTransition)) {
+ rollback(connection);
+ return;
+ }
+ String update = "UPDATE " + qiEpoch() + " SET " + qi("epoch") + " = ?, "
+ + qi("last_reset_generation") + " = ? WHERE " + qi("limit_column") + " = ?";
+ try (PreparedStatement statement = connection.prepareStatement(update)) {
+ statement.setLong(1, resetMarker.epoch() + 1L);
+ statement.setString(2, generation);
+ statement.setString(3, "streak-reset:DayVoteStreak");
+ if (statement.executeUpdate() != 1) throw new SQLException("Daily streak reset marker is missing");
+ }
+ connection.commit();
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ /** Copies the period boundary once so a phase-receipt retry cannot move it. */
+ void copyPeriodBoundary(String totalColumn, String previousColumn, String generation) throws SQLException {
+ if (!isSafeColumn(totalColumn) || !isSafeColumn(previousColumn)) {
+ throw new SQLException("Unsafe period boundary column");
+ }
+ copyBoundary("period-copy:" + totalColumn, generation, accountingOperation(totalColumn),
+ qi(previousColumn) + " = COALESCE(" + qi(totalColumn) + ", 0)");
+ }
+
+ /** Copies the daily-streak value and timestamp in one recoverable transaction. */
+ void copyDailyStreakBoundary(String streakColumn, String previousStreakColumn, String updateColumn,
+ String previousUpdateColumn, String generation) throws SQLException {
+ if (!isSafeColumn(streakColumn) || !isSafeColumn(previousStreakColumn) || !isSafeColumn(updateColumn)
+ || !isSafeColumn(previousUpdateColumn)) {
+ throw new SQLException("Unsafe daily streak boundary column");
+ }
+ copyBoundary("streak-copy:" + streakColumn, generation, DAILY_STREAK,
+ qi(previousStreakColumn) + " = COALESCE(" + qi(streakColumn) + ", 0), "
+ + qi(previousUpdateColumn) + " = COALESCE(" + qi(updateColumn) + ", '')");
+ }
+
+ private void copyBoundary(String markerKey, String generation, int accountingOperation, String assignments) throws SQLException {
+ if (generation == null || generation.isEmpty() || generation.length() > 128) {
+ throw new SQLException("Invalid period boundary generation");
+ }
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ EpochRow marker = lockLimitEpochRow(connection, markerKey);
+ if (generation.equals(marker.lastResetGeneration())) {
+ rollback(connection);
+ return;
+ }
+ long oldEpoch = marker.epoch();
+ if (oldEpoch == Long.MAX_VALUE) throw new SQLException("Period boundary epoch overflow");
+ drainPendingAccounting(connection, accountingOperation);
+ try (PreparedStatement copy = connection.prepareStatement("UPDATE " + qi(table.getTableName())
+ + " SET " + assignments);
+ PreparedStatement advance = connection.prepareStatement("UPDATE " + qiEpoch() + " SET "
+ + qi("epoch") + " = ?, " + qi("last_reset_generation") + " = ? WHERE "
+ + qi("limit_column") + " = ?")) {
+ copy.executeUpdate();
+ advance.setLong(1, oldEpoch + 1L);
+ advance.setString(2, generation);
+ advance.setString(3, markerKey);
+ if (advance.executeUpdate() != 1) throw new SQLException("Period boundary epoch marker missing");
+ }
+ connection.commit();
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ private void drainPendingAccounting(Connection connection, int operation) throws SQLException {
+ String order = operation == DAILY_STREAK
+ ? " ORDER BY " + qi("streak_updated_at") + " ASC, " + qi("created_at") + " ASC" : "";
+ String select = "SELECT " + qi("vote_id") + ", " + accountingColumns() + " FROM " + qiAccounting()
+ + " WHERE (" + qi("requested") + " & ?) <> 0 AND (" + qi("completed")
+ + " & ?) = 0" + order + " FOR UPDATE";
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setInt(1, operation);
+ statement.setInt(2, operation);
+ try (ResultSet result = statement.executeQuery()) {
+ while (result.next()) {
+ UUID voteId = UUID.fromString(result.getString(1));
+ AccountingRow row = new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4),
+ result.getString(5), nullableInteger(result, 6), nullableInteger(result, 7),
+ nullableLong(result, 8), nullableInteger(result, 9), nullableInteger(result, 10),
+ nullableInteger(result, 11), nullableInteger(result, 12), result.getString(13),
+ result.getInt(14) != 0);
+ applyPendingAccounting(connection, voteId, row, operation);
+ }
+ }
+ }
+ }
+
+ private void applyPendingAccounting(Connection connection, UUID voteId, AccountingRow row, int operation)
+ throws SQLException {
+ if (operation == DAILY_STREAK) {
+ if (row.streakUpdatedAt() == null) throw new SQLException("Pending daily streak accounting payload is incomplete");
+ if (sameLocalDay(findDailyStreakUpdate(connection, row.uuid()), row.streakUpdatedAt().longValue())) {
+ markAccountingComplete(connection, voteId,
+ row.completed() | DAILY_STREAK | (row.requested() & DAILY_STREAK_REWARD));
+ return;
+ }
+ String sql = "UPDATE " + qi(table.getTableName()) + " SET " + qi("DayVoteStreak") + " = COALESCE("
+ + qi("DayVoteStreak") + ", 0) + 1, " + qi("DayVoteStreakLastUpdate") + " = ? WHERE "
+ + qi("uuid") + uuidCast();
+ try (PreparedStatement update = connection.prepareStatement(sql)) {
+ update.setString(1, Long.toString(row.streakUpdatedAt().longValue()));
+ update.setString(2, row.uuid());
+ if (update.executeUpdate() != 1) throw new SQLException("Daily streak user row is missing");
+ }
+ int persistedStreak = findDailyStreakValue(connection, row.uuid());
+ markAccountingComplete(connection, voteId, row.completed() | operation,
+ Integer.valueOf(persistedStreak));
+ return;
+ }
+ String boundary = operation == DAILY_TOTAL ? "DailyTotal"
+ : operation == WEEKLY_TOTAL ? "WeeklyTotal"
+ : operation == MONTH_TOTAL ? "MonthTotal"
+ : operation == VOTE_PARTY_TOTAL ? "VotePartyVotes" : "AllTimeTotal";
+ List columns = operation == MONTH_TOTAL && row.monthColumn() != null
+ ? List.of(boundary, row.monthColumn()) : List.of(boundary);
+ Integer maximum = pendingAccountingMaximum(operation, row.requested(), row.monthMaximum());
+ StringBuilder sql = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ");
+ for (int index = 0; index < columns.size(); index++) {
+ if (index > 0) sql.append(", ");
+ String quoted = qi(columns.get(index));
+ sql.append(quoted).append(" = ");
+ if (maximum != null) sql.append("LEAST(?, ");
+ sql.append("COALESCE(").append(quoted).append(", 0) + 1");
+ if (maximum != null) sql.append(')');
+ }
+ sql.append(" WHERE ").append(qi("uuid")).append(uuidCast());
+ try (PreparedStatement update = connection.prepareStatement(sql.toString())) {
+ int parameter = 1;
+ if (maximum != null) {
+ for (int ignored = 0; ignored < columns.size(); ignored++) {
+ update.setInt(parameter++, maximum.intValue());
+ }
+ }
+ update.setString(parameter, row.uuid());
+ if (update.executeUpdate() != 1) throw new SQLException("Period total user row is missing");
+ }
+ markAccountingComplete(connection, voteId, row.completed() | operation);
+ }
+
+ static Integer pendingAccountingMaximum(int operation, int requested, Integer monthMaximum) {
+ return operation == MONTH_TOTAL && (requested & MONTH_TOTAL_RESERVED) == 0 ? monthMaximum : null;
+ }
+
/**
* A JDBC commit error does not prove that the database discarded the
* transaction. Close the possibly-broken handle before looking up the same
@@ -500,6 +1473,68 @@ List recoverAndCleanup(long now) throws SQLException {
return List.copyOf(refunded);
}
+ AccountingRecoveryBatch recoverAccounting(long now) throws SQLException {
+ List pendingRewards = new ArrayList<>();
+ List pending = findPendingAccounting(RECOVERY_BATCH_SIZE);
+ for (AccountingRecovery recovery : pending) {
+ AccountingRow row = recovery.row();
+ int missing = row.requested() & ~row.completed();
+ if ((missing & DAILY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "DailyTotal",
+ "LastDailyTotal", List.of("DailyTotal"), null, DAILY_TOTAL);
+ if ((missing & WEEKLY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(), "WeeklyTotal",
+ "LastWeeklyTotal", List.of("WeeklyTotal"), null, WEEKLY_TOTAL);
+ if ((missing & MONTH_TOTAL) != 0) {
+ List columns = row.monthColumn() == null ? List.of("MonthTotal")
+ : List.of("MonthTotal", row.monthColumn());
+ applyPeriodTotals(recovery.voteId(), row.uuid(), "MonthTotal", "LastMonthTotal", columns,
+ (row.requested() & MONTH_TOTAL_RESERVED) != 0 ? null : row.monthMaximum(), MONTH_TOTAL);
+ }
+ if ((missing & VOTE_PARTY_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(),
+ "VotePartyVotes", "LastVotePartyVotes", List.of("VotePartyVotes"), null, VOTE_PARTY_TOTAL);
+ if ((missing & ALL_TIME_TOTAL) != 0) applyPeriodTotals(recovery.voteId(), row.uuid(),
+ "AllTimeTotal", "AllTimeTotal", List.of("AllTimeTotal"), null, ALL_TIME_TOTAL);
+ if ((missing & DAILY_STREAK) != 0) {
+ if (row.streakValue() == null || row.streakUpdatedAt() == null) {
+ throw new SQLException("Pending daily streak accounting payload is incomplete");
+ }
+ applyDailyStreak(recovery.voteId(), row.uuid(), row.streakValue().intValue(),
+ row.streakUpdatedAt().longValue());
+ }
+ if ((row.requested() & DAILY_STREAK_REWARD) != 0
+ && (row.completed() & (DAILY_STREAK_REWARD | DAILY_STREAK_REWARD_CLAIMED)) == 0) {
+ pendingRewards.add(recovery.voteId());
+ break;
+ }
+ }
+ // Durable Votifier and proxy queues have no age limit. Completed vote
+ // accounting receipts must therefore outlive every possible same-ID replay.
+ return new AccountingRecoveryBatch(!pending.isEmpty(), List.copyOf(pendingRewards));
+ }
+
+ private List findPendingAccounting(int limit) throws SQLException {
+ String requested = qi("requested");
+ String completed = qi("completed");
+ String select = "SELECT " + qi("vote_id") + ", " + accountingColumns() + " FROM " + qiAccounting()
+ + " WHERE " + requested + " <> " + completed + " AND (((" + requested + " & "
+ + RECOVERABLE_NON_REWARD_ACCOUNTING + ") <> (" + completed + " & "
+ + RECOVERABLE_NON_REWARD_ACCOUNTING + ")) OR ((" + requested + " & " + DAILY_STREAK_REWARD
+ + ") <> 0 AND (" + completed + " & " + DAILY_STREAK_REWARD + ") = 0 AND (" + completed
+ + " & " + DAILY_STREAK_REWARD_CLAIMED + ") = 0)) ORDER BY " + qi("created_at")
+ + " ASC LIMIT ?";
+ List pending = new ArrayList<>();
+ try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setInt(1, limit);
+ try (ResultSet result = statement.executeQuery()) {
+ while (result.next()) pending.add(new AccountingRecovery(UUID.fromString(result.getString(1)),
+ new AccountingRow(result.getString(2), result.getInt(3), result.getInt(4), result.getString(5),
+ nullableInteger(result, 6), nullableInteger(result, 7), nullableLong(result, 8),
+ nullableInteger(result, 9), nullableInteger(result, 10), nullableInteger(result, 11),
+ nullableInteger(result, 12), result.getString(13), result.getInt(14) != 0)));
+ }
+ }
+ return pending;
+ }
+
private List findTransferIds(String state, int limit) throws SQLException {
String select = "SELECT " + qi("purchase_id") + " FROM " + qiJournal() + " WHERE " + qi("state")
+ " = ? ORDER BY " + qi("created_at") + " ASC LIMIT ?";
@@ -554,6 +1589,7 @@ private void ensureSchema() throws SQLException {
ensureColumn(connection, "limit_generation_expires_at", "BIGINT NULL");
ensureColumn(connection, "limit_epoch", "BIGINT NULL");
ensureEpochSchema(connection);
+ ensureAccountingSchema(connection);
String index = "vp_vsp_" + Integer.toUnsignedString(journalTable.hashCode(), 36) + "_state_created";
String createIndex = "CREATE INDEX " + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "")
+ qi(index) + " ON " + qiJournal() + " (" + qi("state") + ", " + qi("created_at") + ");";
@@ -565,6 +1601,69 @@ private void ensureSchema() throws SQLException {
}
}
+ private void ensureAccountingSchema(Connection connection) throws SQLException {
+ String create = "CREATE TABLE IF NOT EXISTS " + qiAccounting() + " (" + qi("vote_id")
+ + " VARCHAR(36) NOT NULL, " + qi("player_uuid") + " VARCHAR(37) NULL, "
+ + qi("requested") + " INT NOT NULL DEFAULT 0, " + qi("completed") + " INT NOT NULL, "
+ + qi("month_column") + " VARCHAR(128) NULL, " + qi("month_maximum") + " INT NULL, "
+ + qi("streak_value") + " INT NULL, " + qi("streak_updated_at") + " BIGINT NULL, "
+ + qi("streak_force_proxy") + " INT NULL, " + qi("streak_applied_value") + " INT NULL, "
+ + qi("point_amount") + " INT NULL, " + qi("point_cap") + " INT NULL, "
+ + qi("point_column") + " VARCHAR(128) NULL, "
+ + qi("replay_unsafe") + " INT NOT NULL DEFAULT 0, "
+ + qi("retired_at") + " BIGINT NULL, "
+ + qi("created_at")
+ + " BIGINT NOT NULL, PRIMARY KEY (" + qi("vote_id") + "));";
+ try (PreparedStatement statement = connection.prepareStatement(create)) {
+ statement.executeUpdate();
+ }
+ ensureAccountingColumn(connection, "player_uuid", "VARCHAR(37) NULL");
+ ensureAccountingColumn(connection, "requested", "INT NOT NULL DEFAULT 0");
+ ensureAccountingColumn(connection, "month_column", "VARCHAR(128) NULL");
+ ensureAccountingColumn(connection, "month_maximum", "INT NULL");
+ ensureAccountingColumn(connection, "streak_value", "INT NULL");
+ ensureAccountingColumn(connection, "streak_updated_at", "BIGINT NULL");
+ ensureAccountingColumn(connection, "streak_force_proxy", "INT NULL");
+ ensureAccountingColumn(connection, "streak_applied_value", "INT NULL");
+ ensureAccountingColumn(connection, "point_amount", "INT NULL");
+ ensureAccountingColumn(connection, "point_cap", "INT NULL");
+ ensureAccountingColumn(connection, "point_column", "VARCHAR(128) NULL");
+ ensureAccountingColumn(connection, "replay_unsafe", "INT NOT NULL DEFAULT 0");
+ ensureAccountingColumn(connection, "retired_at", "BIGINT NULL");
+ try (PreparedStatement migrate = connection.prepareStatement("UPDATE " + qiAccounting() + " SET "
+ + qi("requested") + " = " + qi("completed") + " WHERE " + qi("requested") + " = 0 AND "
+ + qi("completed") + " <> 0")) {
+ migrate.executeUpdate();
+ }
+ String index = "vp_vsa_streak_" + hash(accountingTable).substring(0, 16);
+ String createIndex = "CREATE INDEX " + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "")
+ + qi(index) + " ON " + qiAccounting() + " (" + qi("player_uuid") + ", "
+ + qi("streak_updated_at") + ");";
+ try (PreparedStatement statement = connection.prepareStatement(createIndex)) {
+ statement.executeUpdate();
+ } catch (SQLException failure) {
+ if (failure.getErrorCode() != 1061 && !"42P07".equals(failure.getSQLState())) throw failure;
+ }
+ String retiredIndex = "vp_vsa_retired_" + hash(accountingTable).substring(0, 16);
+ String createRetiredIndex = "CREATE INDEX "
+ + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "") + qi(retiredIndex) + " ON "
+ + qiAccounting() + " (" + qi("retired_at") + ");";
+ try (PreparedStatement statement = connection.prepareStatement(createRetiredIndex)) {
+ statement.executeUpdate();
+ } catch (SQLException failure) {
+ if (failure.getErrorCode() != 1061 && !"42P07".equals(failure.getSQLState())) throw failure;
+ }
+ }
+
+ private void ensureAccountingColumn(Connection connection, String column, String definition) throws SQLException {
+ String alter = "ALTER TABLE " + qiAccounting() + " ADD COLUMN " + qi(column) + " " + definition;
+ try (PreparedStatement statement = connection.prepareStatement(alter)) {
+ statement.executeUpdate();
+ } catch (SQLException failure) {
+ if (failure.getErrorCode() != 1060 && !"42701".equals(failure.getSQLState())) throw failure;
+ }
+ }
+
private void ensureEpochSchema(Connection connection) throws SQLException {
String create = "CREATE TABLE IF NOT EXISTS " + qiEpoch() + " (" + qi("limit_column")
+ " VARCHAR(128) NOT NULL, " + qi("epoch") + " BIGINT NOT NULL, "
@@ -602,6 +1701,7 @@ private Connection connection() throws SQLException {
private String qiJournal() { return table.qi(journalTable); }
private String qiEpoch() { return table.qi(epochTable); }
+ private String qiAccounting() { return table.qi(accountingTable); }
private String qi(String identifier) { return table.qi(identifier); }
private String uuidCast() { return table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; }
@@ -657,9 +1757,27 @@ private static Long nullableLong(ResultSet result, int index) throws SQLExceptio
return value instanceof Number number ? number.longValue() : null;
}
+ private static Integer nullableInteger(ResultSet result, int index) throws SQLException {
+ Object value = result.getObject(index);
+ return value instanceof Number number ? number.intValue() : null;
+ }
+
private record EpochRow(long epoch, String lastResetGeneration) {
}
+ private record AccountingRow(String uuid, int requested, int completed, String monthColumn,
+ Integer monthMaximum, Integer streakValue, Long streakUpdatedAt, Integer streakForceProxy,
+ Integer streakAppliedValue, Integer pointAmount, Integer pointCap, String pointColumn,
+ boolean replayUnsafe) {
+ }
+
+ record PeriodTotalResult(boolean applied, List columns) { }
+
+ private record DailyStreakCandidate(int dailyTotal, int streak, String lastUpdate) { }
+
+ private record AccountingRecovery(UUID voteId, AccountingRow row) {
+ }
+
private static boolean isSafeColumn(String column) {
// Columns are passed through AbstractSqlTable.qi(), which escapes the
// database-specific identifier delimiter. Preserve configured shop keys
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopLimitMutationFence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopLimitMutationFence.java
new file mode 100644
index 0000000000..4b30535222
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopLimitMutationFence.java
@@ -0,0 +1,28 @@
+package com.bencodez.votingplugin.voteshop.service;
+
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Supplier;
+
+/** Orders local VoteShop limit mutations against period resets. */
+public final class VoteShopLimitMutationFence {
+ private static final ReentrantLock FENCE = new ReentrantLock(true);
+
+ private VoteShopLimitMutationFence() {
+ }
+
+ public static T withLock(Supplier action) {
+ FENCE.lock();
+ try {
+ return action.get();
+ } finally {
+ FENCE.unlock();
+ }
+ }
+
+ public static void withLock(Runnable action) {
+ withLock(() -> {
+ action.run();
+ return null;
+ });
+ }
+}
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 93fdc964f4..7ce878e767 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
@@ -10,9 +10,12 @@
import java.time.ZoneId;
import java.time.temporal.WeekFields;
import java.util.HashMap;
+import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -24,6 +27,7 @@
import com.bencodez.advancedcore.api.messages.PlaceholderUtils;
import com.bencodez.advancedcore.api.rewards.RewardOptions;
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
import com.bencodez.advancedcore.api.user.UserStorage;
import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
@@ -48,6 +52,14 @@
@Getter
@Setter
public class VoteShopPurchaseService {
+ public enum MysqlDailyStreakResult { APPLIED, ALREADY_UPDATED, NOT_REQUESTED, DEFERRED, FAILED }
+ public record MysqlDailyStreakUpdate(MysqlDailyStreakResult result, int streak, boolean forceProxyRouting) { }
+ public record VoteAccountingAdmission(boolean success, boolean countTotals, boolean awardPoints,
+ boolean countVoteParty, int pointAmount, int pointCap, String pointColumn, boolean replayUnsafe) { }
+ private static final ConcurrentMap ADMITTED_ACCOUNTING = new ConcurrentHashMap<>();
+ private static final int ACCOUNTING_DAILY_STREAK = 16;
+ private static final int ACCOUNTING_POINTS = 256;
+ private static final int ACCOUNTING_ALL_TIME = 512;
private static final int PURCHASE_LOCK_STRIPES = 256;
private static final Object[] PURCHASE_LOCKS = createPurchaseLocks();
private static final int COMPLETION_PENDING = 0;
@@ -555,20 +567,24 @@ VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item
if (usesSharedMysqlPoints()) {
return debitSharedMysql(user, item);
}
- if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) {
- return VoteShopPurchaseResult.LIMIT_REACHED;
- }
- if (!user.removePoints(item.getCost(), true)) {
- return VoteShopPurchaseResult.NOT_ENOUGH_POINTS;
- }
- if (item.getLimit() > 0) {
- user.setVoteShopIdentifierLimit(item.getIdentifier(),
- user.getVoteShopIdentifierLimit(item.getIdentifier()) + 1);
- }
- return VoteShopPurchaseResult.SUCCESS;
+ return VoteShopLimitMutationFence.withLock(() -> debitLocal(user, item));
}
}
+ private VoteShopPurchaseResult debitLocal(VotingPluginUser user, VoteShopItem item) {
+ if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) {
+ return VoteShopPurchaseResult.LIMIT_REACHED;
+ }
+ if (!user.removePoints(item.getCost(), true)) {
+ return VoteShopPurchaseResult.NOT_ENOUGH_POINTS;
+ }
+ if (item.getLimit() > 0) {
+ user.setVoteShopIdentifierLimit(item.getIdentifier(),
+ user.getVoteShopIdentifierLimit(item.getIdentifier()) + 1);
+ }
+ return VoteShopPurchaseResult.SUCCESS;
+ }
+
private boolean usesSharedMysqlPoints() {
return usesSharedMysqlPoints(plugin);
}
@@ -653,6 +669,296 @@ public static boolean resetMysqlLimitWithPurchaseFence(VotingPluginMain plugin,
return reset.get();
}
+ /** Atomically removes the copied boundary total while retaining later votes. */
+ public static boolean resetMysqlPeriodTotal(VotingPluginMain plugin, String totalColumn, String previousColumn,
+ String resetGeneration) {
+ if (!canRecoverSharedMysqlPurchases(plugin)) return false;
+ AtomicBoolean reset = new AtomicBoolean();
+ try {
+ MySQL table = plugin.getMysql();
+ table.checkColumn(totalColumn, DataType.INTEGER);
+ table.checkColumn(previousColumn, DataType.INTEGER);
+ SharedMysqlPurchaseJournal.forTable(table).resetPeriodTotal(totalColumn, previousColumn, resetGeneration);
+ reset.set(true);
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically reset MySQL period total: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ } finally {
+ SharedMysqlCacheReconciler.invalidateAll(plugin, totalColumn);
+ }
+ return reset.get();
+ }
+
+ /** Atomically increments period totals under the cross-backend boundary lock. */
+ public static boolean incrementMysqlPeriodTotals(VotingPluginMain plugin, UUID voteId, String uuid, String boundaryColumn,
+ String previousColumn, List columns, Integer maximum) {
+ if (!canRecoverSharedMysqlPurchases(plugin) || columns == null || columns.isEmpty()) return false;
+ List invalidatedColumns = columns;
+ try {
+ MySQL table = plugin.getMysql();
+ for (String column : columns) table.checkColumn(column, DataType.INTEGER);
+ if (maximum != null) table.checkColumn(previousColumn, DataType.INTEGER);
+ UUID accountingId = voteId == null ? UUID.randomUUID() : voteId;
+ int operation = switch (boundaryColumn) {
+ case "DailyTotal" -> 1;
+ case "WeeklyTotal" -> 2;
+ case "MonthTotal" -> 4;
+ case "VotePartyVotes" -> 8;
+ case "AllTimeTotal" -> ACCOUNTING_ALL_TIME;
+ default -> 0;
+ };
+ if (operation == 0) return false;
+ Integer admittedDecision = ADMITTED_ACCOUNTING.get(accountingId);
+ int admitted = admittedDecision == null ? 0 : admittedDecision.intValue();
+ if (admittedDecision != null && (admitted & operation) == 0) return true;
+ SharedMysqlPurchaseJournal.PeriodTotalResult result = SharedMysqlPurchaseJournal.forTable(table)
+ .incrementPeriodTotalsResolved(accountingId, uuid, boundaryColumn, previousColumn, columns,
+ maximum, (admitted & operation) != 0);
+ invalidatedColumns = result.columns();
+ if (!result.applied()) plugin.getLogger().warning(
+ "Shared MySQL period total was retained for retry after a persistence failure");
+ return true;
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically increment MySQL period total: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return false;
+ } finally {
+ for (String column : invalidatedColumns) SharedMysqlCacheReconciler.invalidate(plugin, uuid, column);
+ }
+ }
+
+ /** Durably admits shared total mutations before vote rewards or broadcasts run. */
+ public static VoteAccountingAdmission prepareMysqlVoteAccounting(VotingPluginMain plugin, UUID voteId, String uuid,
+ boolean countTotals, boolean awardPoints, boolean countVoteParty, boolean forceProxyRouting,
+ int pointAmount, int pointCap, String pointColumn) {
+ if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) {
+ return new VoteAccountingAdmission(true, countTotals, awardPoints, countVoteParty, pointAmount, pointCap,
+ pointColumn, voteId != null && plugin.getServerData().isVoteReplayUnsafe(voteId));
+ }
+ try {
+ MySQL table = plugin.getMysql();
+ String monthColumn = plugin.getConfigFile().isStoreMonthTotalsWithDate()
+ ? plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath() : null;
+ Integer maximum = plugin.getConfigFile().isLimitMonthlyVotes()
+ ? Integer.valueOf(plugin.getTimeChecker().getTime().getDayOfMonth()
+ * plugin.getVoteSiteManager().getVoteSitesEnabled().size()) : null;
+ if (countTotals) {
+ table.checkColumn("DailyTotal", DataType.INTEGER);
+ table.checkColumn("WeeklyTotal", DataType.INTEGER);
+ table.checkColumn("MonthTotal", DataType.INTEGER);
+ table.checkColumn("AllTimeTotal", DataType.INTEGER);
+ if (monthColumn != null) table.checkColumn(monthColumn, DataType.INTEGER);
+ if (maximum != null) table.checkColumn("LastMonthTotal", DataType.INTEGER);
+ }
+ if (countVoteParty) table.checkColumn("VotePartyVotes", DataType.INTEGER);
+ table.checkColumn("DailyTotal", DataType.INTEGER);
+ table.checkColumn("DayVoteStreak", DataType.INTEGER);
+ table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING);
+ SharedMysqlPurchaseJournal.VoteAccountingDecision decision = SharedMysqlPurchaseJournal.forTable(table)
+ .prepareVoteAccounting(voteId, uuid, countTotals,
+ awardPoints, countVoteParty, monthColumn, maximum,
+ plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage(),
+ plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(),
+ plugin.getVoteSiteManager().getVoteSitesEnabled().size(), forceProxyRouting,
+ System.currentTimeMillis(), pointAmount, pointCap, pointColumn);
+ int bits = decision.bits();
+ if ((bits & ACCOUNTING_POINTS) != 0) table.checkColumn(decision.pointColumn(), DataType.INTEGER);
+ ADMITTED_ACCOUNTING.put(voteId, Integer.valueOf(bits));
+ return new VoteAccountingAdmission(true, (bits & 7) != 0,
+ (bits & ACCOUNTING_POINTS) != 0, (bits & 8) != 0, decision.pointAmount(),
+ decision.pointCap(), decision.pointColumn(), decision.replayUnsafe());
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to admit shared MySQL vote accounting: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return new VoteAccountingAdmission(false, false, false, false, pointAmount, pointCap, pointColumn, false);
+ }
+ }
+
+ /** Commits the boundary before any uncheckpointed vote effect can execute. */
+ public static boolean markVoteReplayUnsafe(VotingPluginMain plugin, UUID voteId) {
+ if (voteId == null) return false;
+ if (!canRecoverSharedMysqlPurchases(plugin)) {
+ plugin.getServerData().markVoteReplayUnsafe(voteId);
+ return true;
+ }
+ try {
+ SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).markVoteReplayUnsafe(voteId);
+ return true;
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to persist shared MySQL vote effect boundary: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return false;
+ }
+ }
+
+ /** Retires the YAML replay fence once the delivery owner has durably acknowledged success. */
+ public static void completeVoteDelivery(VotingPluginMain plugin, UUID voteId) {
+ if (voteId == null) return;
+ plugin.getServerData().clearVotePartyAccounting(voteId);
+ if (canRecoverSharedMysqlPurchases(plugin)) {
+ try {
+ SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).retireVoteAccounting(voteId,
+ System.currentTimeMillis());
+ } catch (SQLException failure) {
+ throw new IllegalStateException("Unable to retire shared MySQL vote accounting", failure);
+ }
+ } else plugin.getServerData().clearVoteReplayUnsafe(voteId);
+ VotingPluginUser.acknowledgeVotePointDelivery(plugin, voteId);
+ }
+
+ public static void finishMysqlVoteAccounting(UUID voteId) {
+ if (voteId != null) ADMITTED_ACCOUNTING.remove(voteId);
+ }
+
+ /** Applies only daily-streak work that was durably admitted before vote effects. */
+ public static MysqlDailyStreakUpdate applyPreparedMysqlDailyStreak(VotingPluginMain plugin, UUID voteId,
+ String uuid) {
+ if (!canRecoverSharedMysqlPurchases(plugin)) {
+ return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.FAILED, 0, false);
+ }
+ Integer admittedBits = voteId == null ? null : ADMITTED_ACCOUNTING.get(voteId);
+ if (admittedBits == null) return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.FAILED, 0, false);
+ if ((admittedBits.intValue() & ACCOUNTING_DAILY_STREAK) == 0) {
+ return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.NOT_REQUESTED, 0, false);
+ }
+ boolean admitted = (admittedBits.intValue() & ACCOUNTING_DAILY_STREAK) != 0;
+ try {
+ MySQL table = plugin.getMysql();
+ table.checkColumn("DayVoteStreak", DataType.INTEGER);
+ table.checkColumn("DayVoteStreakLastUpdate", DataType.STRING);
+ UUID accountingId = voteId;
+ SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table);
+ SharedMysqlPurchaseJournal.DailyStreakResult update = journal.updateDailyStreak(
+ accountingId, uuid, 0, 0L, true);
+ if (update.outcome() == SharedMysqlPurchaseJournal.DailyStreakOutcome.DEFERRED) plugin.getLogger().warning(
+ "Shared MySQL daily streak was retained for retry after a persistence failure");
+ if (update.outcome() == SharedMysqlPurchaseJournal.DailyStreakOutcome.APPLIED) {
+ SharedMysqlPurchaseJournal.RecoveredDailyStreak reward = journal.claimDailyStreakReward(accountingId);
+ if (reward != null) return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.APPLIED,
+ reward.streak(), reward.forceProxyRouting());
+ return new MysqlDailyStreakUpdate(MysqlDailyStreakResult.ALREADY_UPDATED, update.streak(), false);
+ }
+ return new MysqlDailyStreakUpdate(
+ update.outcome() == SharedMysqlPurchaseJournal.DailyStreakOutcome.ALREADY_UPDATED
+ ? MysqlDailyStreakResult.ALREADY_UPDATED : MysqlDailyStreakResult.DEFERRED,
+ update.streak(), false);
+ } catch (SQLException failure) {
+ plugin.getLogger().log(admitted ? java.util.logging.Level.WARNING : java.util.logging.Level.SEVERE,
+ admitted ? "Shared MySQL daily streak remains retained for retry"
+ : "Unable to atomically update MySQL daily streak", failure);
+ plugin.debug(failure);
+ return new MysqlDailyStreakUpdate(
+ admitted ? MysqlDailyStreakResult.DEFERRED : MysqlDailyStreakResult.FAILED, 0, false);
+ } finally {
+ SharedMysqlCacheReconciler.invalidate(plugin, uuid, "DayVoteStreak", "DayVoteStreakLastUpdate");
+ }
+ }
+
+ /** Completes a claimed daily-streak reward only after its existing reward API returned. */
+ public static boolean completeMysqlDailyStreakReward(VotingPluginMain plugin, UUID voteId) {
+ if (!canRecoverSharedMysqlPurchases(plugin) || voteId == null) return false;
+ try {
+ SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).completeDailyStreakReward(voteId);
+ return true;
+ } catch (SQLException failure) {
+ plugin.getLogger().log(java.util.logging.Level.SEVERE,
+ "Daily streak reward may have run but its completion could not be persisted", failure);
+ plugin.debug(failure);
+ return false;
+ }
+ }
+
+ /** Resets the copied daily streak without overwriting a new-day vote from another backend. */
+ public static boolean resetMysqlDailyStreakAtBoundary(VotingPluginMain plugin, String uuid,
+ long boundaryUpdatedAt) {
+ if (!canRecoverSharedMysqlPurchases(plugin)) return false;
+ try {
+ SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).resetDailyStreakAtBoundary(uuid, boundaryUpdatedAt);
+ return true;
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically reset MySQL daily streak: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return false;
+ } finally {
+ SharedMysqlCacheReconciler.invalidate(plugin, uuid, "DayVoteStreak");
+ }
+ }
+
+ /** Releases accepted daily streak increments after every copied user is reset. */
+ public static boolean hasMysqlDailyStreakBoundary(VotingPluginMain plugin, String generation) {
+ if (!canRecoverSharedMysqlPurchases(plugin)) return false;
+ try {
+ return SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).hasDailyStreakBoundary(generation);
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to read the shared MySQL daily streak boundary: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ throw new IllegalStateException("Unable to read the shared MySQL daily streak boundary", failure);
+ }
+ }
+
+ public static boolean completeMysqlDailyStreakReset(VotingPluginMain plugin, String generation) {
+ if (!canRecoverSharedMysqlPurchases(plugin)) return false;
+ try {
+ SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).completeDailyStreakReset(generation);
+ return true;
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to complete the shared MySQL daily streak reset: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return false;
+ }
+ }
+
+ /** Atomically captures one period boundary for a recoverable transition. */
+ public static boolean copyMysqlPeriodBoundary(VotingPluginMain plugin, String totalColumn, String previousColumn,
+ String generation) {
+ if (!canRecoverSharedMysqlPurchases(plugin)) return false;
+ try {
+ MySQL table = plugin.getMysql();
+ table.checkColumn(totalColumn, DataType.INTEGER);
+ table.checkColumn(previousColumn, DataType.INTEGER);
+ SharedMysqlPurchaseJournal.forTable(table).copyPeriodBoundary(totalColumn, previousColumn, generation);
+ return true;
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically copy MySQL period boundary: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return false;
+ } finally {
+ SharedMysqlCacheReconciler.invalidateAll(plugin, previousColumn);
+ }
+ }
+
+ /** Atomically captures the daily-streak value and timestamp boundary. */
+ public static boolean copyMysqlDailyStreakBoundary(VotingPluginMain plugin, String streakColumn,
+ String previousStreakColumn, String updateColumn, String previousUpdateColumn, String generation) {
+ if (!canRecoverSharedMysqlPurchases(plugin)) return false;
+ try {
+ MySQL table = plugin.getMysql();
+ table.checkColumn(streakColumn, DataType.INTEGER);
+ table.checkColumn(previousStreakColumn, DataType.INTEGER);
+ table.checkColumn(updateColumn, DataType.STRING);
+ table.checkColumn(previousUpdateColumn, DataType.STRING);
+ SharedMysqlPurchaseJournal.forTable(table).copyDailyStreakBoundary(streakColumn, previousStreakColumn,
+ updateColumn, previousUpdateColumn, generation);
+ return true;
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically copy MySQL daily streak boundary: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return false;
+ } finally {
+ SharedMysqlCacheReconciler.invalidateAll(plugin, previousStreakColumn);
+ SharedMysqlCacheReconciler.invalidateAll(plugin, previousUpdateColumn);
+ }
+ }
+
static void withSharedMysqlCacheResetFence(Runnable action) {
SharedMysqlCacheReconciler.withResetFence(action);
}
@@ -676,6 +982,31 @@ public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) {
static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurchaseJournal journal)
throws SQLException {
retryPendingCompensationMarkers(plugin, journal);
+ SharedMysqlPurchaseJournal.AccountingRecoveryBatch accounting;
+ do {
+ accounting = journal.recoverAccounting(System.currentTimeMillis());
+ boolean deferredReward = false;
+ for (UUID voteId : accounting.pendingRewards()) {
+ // Startup recovery begins asynchronously. Do not claim a durable reward
+ // until the service that executes it is ready, because claimed rows are
+ // intentionally excluded from later recovery scans.
+ if (plugin.getSpecialRewards() == null) {
+ deferredReward = true;
+ break;
+ }
+ SharedMysqlPurchaseJournal.RecoveredDailyStreak streak = journal.claimDailyStreakReward(voteId);
+ if (streak == null) {
+ deferredReward = true;
+ continue;
+ }
+ SharedMysqlCacheReconciler.invalidate(plugin, streak.uuid(), "DayVoteStreak", "DayVoteStreakLastUpdate");
+ VotingPluginUser user = plugin.getVotingPluginUserManager()
+ .getVotingPluginUser(UUID.fromString(streak.uuid()), false);
+ user.completeRecoveredDailyStreak(streak.streak(), streak.forceProxyRouting());
+ journal.completeDailyStreakReward(voteId);
+ }
+ if (deferredReward) break;
+ } while (accounting.hadRows());
for (SharedMysqlPurchaseJournal.RefundedPurchase refund : journal.recoverAndCleanup(System.currentTimeMillis())) {
SharedMysqlCacheReconciler.invalidateAndRefresh(plugin, refund.uuid(), refund.pointsColumn(),
refund.limitColumn());
@@ -887,6 +1218,11 @@ public static String currentLimitGenerationId(VotingPluginMain plugin, String id
return limitGeneration(plugin, identifier, System.currentTimeMillis()).value();
}
+ /** Stable reset generation derived from the durable period transition. */
+ public static String limitGenerationIdForTransition(TimeChangeTransition transition) {
+ return "time-shop:" + transition.getType() + ':' + transition.getPeriodKey();
+ }
+
private static LimitGeneration limitGeneration(VotingPluginMain plugin, String identifier, long nowMillis) {
boolean daily = plugin.getShopFile().getVoteShopResetDaily(identifier);
boolean weekly = plugin.getShopFile().getVoteShopResetWeekly(identifier);
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java
index 844db86ee3..425df90670 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java
@@ -25,10 +25,10 @@
import java.lang.reflect.Field;
import java.net.ServerSocket;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.UUID;
-import java.nio.file.Path;
-import java.nio.file.Files;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
@@ -64,6 +64,7 @@
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter.OrderedVoteOutcome;
import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager;
import com.bencodez.votingplugin.config.BungeeSettings;
+import com.bencodez.votingplugin.data.ServerData;
import com.bencodez.votingplugin.proxy.VotingPluginWire;
import com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport;
import com.bencodez.votingplugin.backendproxy.transport.MysqlBackendProxyTransport;
@@ -76,6 +77,98 @@
import com.bencodez.votingplugin.proxy.BungeeMethod;
class BackendProxyHandlerLifecycleTest {
+ @Test
+ void completedLegacyVoteRetiresTheTotalsReplayFence() throws Exception {
+ com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ ServerData serverData = mock(ServerData.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ when(plugin.getServerData()).thenReturn(serverData);
+ ArrayDeque asyncTasks = new ArrayDeque<>();
+ doAnswer(invocation -> {
+ asyncTasks.addLast(invocation.getArgument(1));
+ return null;
+ }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
+ BackendProxyHandler handler = new BackendProxyHandler(plugin);
+ BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
+ setField(handler, "messageRouter", router);
+ doAnswer(invocation -> {
+ invocation.>getArgument(1)
+ .accept(OrderedVoteOutcome.COMPLETE);
+ return null;
+ }).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
+ handler.activateInboundMessages();
+ UUID voteId = UUID.randomUUID();
+ String legacyTotals = "1//2//3//4//5//0//6//7//8//" + voteId;
+
+ handler.dispatchIncomingAfterPublication(VotingPluginWire.vote("Player", UUID.randomUUID().toString(),
+ "Service", 100L, true, true, legacyTotals, null, true, false, 1, 1), mock(Runnable.class));
+ asyncTasks.removeFirst().run();
+ asyncTasks.removeFirst().run();
+
+ verify(serverData).clearVoteReplayUnsafe(voteId);
+ verify(router).handleOrderedVote(any(JsonEnvelope.class), any());
+ }
+
+ @Test
+ void failedLegacyVoteRetirementKeepsDurableQueueOwnershipAndRetries(@TempDir Path tempDir) throws Exception {
+ com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ ServerData serverData = mock(ServerData.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getDataFolder()).thenReturn(tempDir.toFile());
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("legacy-retirement-retry-test"));
+ LinkedBlockingQueue asyncTasks = new LinkedBlockingQueue<>();
+ doAnswer(invocation -> {
+ asyncTasks.add(invocation.getArgument(1));
+ return null;
+ }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
+ BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
+ BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
+ BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
+ setField(handler, "messageRouter", router);
+ doAnswer(invocation -> {
+ invocation.>getArgument(1)
+ .accept(OrderedVoteOutcome.COMPLETE);
+ return null;
+ }).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
+ handler.activateInboundMessages();
+ UUID voteId = UUID.randomUUID();
+ doThrow(new IllegalStateException("storage unavailable")).doNothing()
+ .when(serverData).clearVoteReplayUnsafe(voteId);
+ JsonEnvelope vote = VotingPluginWire.vote("Player", UUID.randomUUID().toString(),
+ "Service", 100L, true, true, "1//2//3//4//5//0//6//7//8//" + voteId,
+ null, true, false, 1, 1);
+
+ try {
+ assertTrue(overflow.enqueue(vote));
+ asyncTasks.poll(3, TimeUnit.SECONDS).run();
+ Runnable retirement = asyncTasks.poll(3, TimeUnit.SECONDS);
+ assertNotNull(retirement, "durable vote must be replaced by retirement work");
+
+ BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
+ try {
+ assertEquals(1, recovered.size(), "retirement ownership must survive restart");
+ assertTrue(BackendProxyHandler.isLegacyDeliveryRetirement(recovered.peekDurable().envelope()));
+ } finally {
+ recovered.close();
+ }
+
+ retirement.run();
+ Runnable retry = asyncTasks.poll(3, TimeUnit.SECONDS);
+ assertNotNull(retry, "failed retirement must retry from the durable queue");
+ retry.run();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
+ while (overflow.size() != 0 && System.nanoTime() < deadline) Thread.sleep(10L);
+ assertEquals(0, overflow.size());
+ verify(serverData, times(2)).clearVoteReplayUnsafe(voteId);
+ verify(router).handleOrderedVote(any(JsonEnvelope.class), any());
+ } finally {
+ overflow.close();
+ }
+ }
+
@Test
void globalDataWakeupMovesOffTheCallingAndPrimaryThreads() {
com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class);
@@ -284,8 +377,10 @@ void reliableVoteIsAcknowledgedOnlyAfterOrderedCompletion() throws Exception {
com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class);
BukkitScheduler scheduler = mock(BukkitScheduler.class);
BungeeSettings settings = mock(BungeeSettings.class);
+ ServerData serverData = mock(ServerData.class);
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
when(plugin.getBungeeSettings()).thenReturn(settings);
+ when(plugin.getServerData()).thenReturn(serverData);
when(settings.getServer()).thenReturn("survival");
BackendProxyHandler handler = new BackendProxyHandler(plugin);
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
@@ -819,7 +914,7 @@ void durableCompletionIsPersistedBeforeNextVoteRuns(@TempDir Path tempDir) throw
setField(handler, "messageRouter", router);
doAnswer(invocation -> {
invocation.>getArgument(1)
- .accept(OrderedVoteOutcome.COMPLETE);
+ .accept(OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT);
return null;
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
handler.activateInboundMessages();
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java
index 03ac1fd554..6542dcebde 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java
@@ -37,6 +37,7 @@
import com.bencodez.simpleapi.sql.data.DataValueString;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.config.BungeeSettings;
+import com.bencodez.votingplugin.proxy.VotingPluginWire;
class BackendGlobalDataSyncTest {
@@ -604,6 +605,8 @@ private void assertTimeChangeRunsOnTheTimeCheckerExecutor(TimeType type) {
data.put("LastUpdated", new DataValueString(
"" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli()));
data.put(type.toString(), new DataValueBoolean(true));
+ data.put(VotingPluginWire.timeChangeTransitionKey(type.toString()),
+ new DataValueString("transition-1"));
assertTrue(sync.checkGlobalDataTime(type, data));
@@ -612,8 +615,44 @@ private void assertTimeChangeRunsOnTheTimeCheckerExecutor(TimeType type) {
assertNotNull(scheduled.get());
scheduled.get().run();
verify(timeChecker).forceChanged(type, false, true, true);
- verify(globalDataHandler).setBoolean("lobby", type.toString(), false);
- verify(globalDataHandler).setData(eq("lobby"), any());
+ verify(globalDataHandler).setData(eq("lobby"), org.mockito.ArgumentMatchers.argThat(values ->
+ values.containsKey(type.toString())
+ && values.containsKey(VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString()))
+ && !values.get(type.toString()).getBoolean()
+ && "transition-1".equals(values.get(
+ VotingPluginWire.timeChangeBoundaryCapturedKey(type.toString())).getString())));
+ verify(globalDataHandler).setData(eq("lobby"), org.mockito.ArgumentMatchers.argThat(values ->
+ values.containsKey("FinishedProcessing")));
+ }
+
+ @Test
+ void legacyProxyTimeChangeStillRunsWithoutBoundaryConfirmation() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ BungeeSettings bungeeSettings = mock(BungeeSettings.class);
+ TimeChecker timeChecker = mock(TimeChecker.class);
+ ScheduledExecutorService timeCheckerExecutor = mock(ScheduledExecutorService.class);
+ GlobalDataHandler globalDataHandler = mock(GlobalDataHandler.class);
+ AtomicReference scheduled = new AtomicReference<>();
+ when(plugin.getBungeeSettings()).thenReturn(bungeeSettings);
+ when(bungeeSettings.getServer()).thenReturn("lobby");
+ when(plugin.getTimeChecker()).thenReturn(timeChecker);
+ when(timeChecker.getTimer()).thenReturn(timeCheckerExecutor);
+ org.mockito.Mockito.doAnswer(invocation -> {
+ scheduled.set(invocation.getArgument(0));
+ return null;
+ }).when(timeCheckerExecutor).execute(any(Runnable.class));
+ BackendGlobalDataSync sync = new BackendGlobalDataSync(plugin, ignored -> { });
+ setField(sync, "globalDataHandler", globalDataHandler);
+ HashMap data = new HashMap<>();
+ data.put("LastUpdated", new DataValueString(
+ "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli()));
+ data.put(TimeType.DAY.toString(), new DataValueBoolean(true));
+
+ assertTrue(sync.checkGlobalDataTime(TimeType.DAY, data));
+ scheduled.get().run();
+
+ verify(timeChecker).forceChanged(TimeType.DAY, false, true, true);
+ verify(globalDataHandler).setBoolean("lobby", TimeType.DAY.toString(), false);
}
@Test
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java
index 38312a592a..db38f9b383 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java
@@ -79,6 +79,8 @@ void setUp() {
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
when(plugin.getLogger()).thenReturn(logger);
when(votingUserManager.getVotingPluginUser(resolvedUser)).thenReturn(user);
+ when(user.bungeeVotePluginMessagingAccepted(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
+ anyBoolean(), anyInt(), any())).thenReturn(true);
doAnswer(invocation -> {
Runnable task = invocation.getArgument(1);
task.run();
@@ -188,8 +190,8 @@ void voteRewardFailureRequestsQuarantineAfterVoteIdReservation() {
when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class));
when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player"))
.thenReturn(user);
- doThrow(new IllegalStateException("partial reward")).when(user).bungeeVotePluginMessaging(
- any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt());
+ when(user.bungeeVotePluginMessagingAccepted(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
+ anyBoolean(), anyInt(), any())).thenThrow(new IllegalStateException("partial reward"));
BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
mock(BackendVotePartySync.class), cache);
@@ -234,8 +236,8 @@ void reliableVoteRetriesReceiptPersistenceWithoutRepeatingEffectsInProcess() {
voteRouter.handleOrderedVote(vote, outcome::set);
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
verify(cache, times(2)).complete(voteId);
- verify(user, times(1)).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
- anyBoolean(), anyInt());
+ verify(user, times(1)).bungeeVotePluginMessagingAccepted(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
+ anyBoolean(), anyInt(), eq(voteId));
}
@Test
@@ -307,4 +309,43 @@ void receiptReleaseBypassesOrderingOnlyAfterItsReceiptIsDurable() {
assertTrue(voteRouter.hasDurableReceiptForRelease(release));
}
+ @Test
+ void accountingAdmissionFailureReleasesVoteIdAndRetriesWithoutQuarantine() {
+ UUID voteId = UUID.randomUUID();
+ ProcessedVoteCache cache = mock(ProcessedVoteCache.class);
+ when(cache.reserve(voteId)).thenReturn(true);
+ when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class));
+ when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")).thenReturn(user);
+ when(user.bungeeVotePluginMessagingAccepted(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
+ anyBoolean(), anyInt(), any())).thenReturn(false);
+ BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
+ mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
+ mock(BackendVotePartySync.class), cache);
+ AtomicReference outcome = new AtomicReference<>();
+
+ voteRouter.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example",
+ LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), outcome::set);
+
+ assertEquals(OrderedVoteOutcome.RETRY, outcome.get());
+ verify(cache).release(voteId);
+ }
+
+ @Test
+ void duplicateVoteCompletesLaneWithoutRetiringOriginalDeliveryFence() {
+ UUID voteId = UUID.randomUUID();
+ ProcessedVoteCache cache = mock(ProcessedVoteCache.class);
+ when(cache.reserve(voteId)).thenReturn(false);
+ BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
+ mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
+ mock(BackendVotePartySync.class), cache);
+ AtomicReference outcome = new AtomicReference<>();
+
+ voteRouter.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example",
+ LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), outcome::set);
+
+ assertEquals(OrderedVoteOutcome.COMPLETE_WITHOUT_RETIREMENT, outcome.get());
+ verify(user, never()).bungeeVotePluginMessagingAccepted(any(), anyLong(), any(), anyBoolean(),
+ anyBoolean(), anyBoolean(), anyInt(), any());
+ }
+
}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java
new file mode 100644
index 0000000000..41bf8361a5
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/admin/AdminVoteVotePlayerTest.java
@@ -0,0 +1,104 @@
+package com.bencodez.votingplugin.commands.gui.admin;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.concurrent.RejectedExecutionException;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Server;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.PluginManager;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+import com.bencodez.simpleapi.scheduler.BukkitScheduler;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.events.PlayerVoteEvent;
+
+class AdminVoteVotePlayerTest {
+ @Test
+ void dispatchesFromPlayerThreadBeforeVoteWorkerAdmission() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ Server server = mock(Server.class);
+ PluginManager pluginManager = mock(PluginManager.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ Player sender = mock(Player.class);
+ Player target = mock(Player.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "Steve", "example.org", false, false);
+ when(plugin.getServer()).thenReturn(server);
+ when(server.getPluginManager()).thenReturn(pluginManager);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ ArgumentCaptor dispatched = ArgumentCaptor.forClass(Runnable.class);
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) {
+ bukkit.when(() -> Bukkit.getPlayerExact("Steve")).thenReturn(target);
+ new AdminVoteVotePlayer(plugin, sender, "Steve").dispatchVote(sender, event);
+ }
+
+ assertFalse(event.isAsynchronous());
+ verify(scheduler).runTask(same(plugin), dispatched.capture(), same(target));
+ verify(pluginManager, never()).callEvent(event);
+ dispatched.getValue().run();
+ verify(pluginManager).callEvent(event);
+ }
+
+ @Test
+ void existingVoteEventConstructorRetainsItsAsynchronousContract() {
+ assertTrue(new PlayerVoteEvent(null, "Steve", "example.org", false).isAsynchronous());
+ }
+
+ @Test
+ void rejectedOwnerDispatchCompletesVoteAsFailed() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ Player target = mock(Player.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "Steve", "example.org", false, false);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ doThrow(new RejectedExecutionException("disabled"))
+ .when(scheduler).runTask(same(plugin), org.mockito.ArgumentMatchers.any(Runnable.class), same(target));
+ doThrow(new RejectedExecutionException("disabled"))
+ .when(scheduler).runTask(same(plugin), org.mockito.ArgumentMatchers.any(Runnable.class));
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) {
+ bukkit.when(() -> Bukkit.getPlayerExact("Steve")).thenReturn(target);
+ com.bencodez.votingplugin.util.BukkitVoteEventDispatcher.dispatch(plugin, event);
+ }
+
+ assertTrue(event.isProcessingFailed());
+ assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone());
+ }
+
+ @Test
+ void eventDispatchFailureCompletesVoteAsFailed() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ Server server = mock(Server.class);
+ PluginManager pluginManager = mock(PluginManager.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "Steve", "example.org", false, false);
+ when(plugin.getServer()).thenReturn(server);
+ when(server.getPluginManager()).thenReturn(pluginManager);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ doThrow(new IllegalStateException("dispatch failed")).when(pluginManager).callEvent(event);
+ ArgumentCaptor dispatched = ArgumentCaptor.forClass(Runnable.class);
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) {
+ bukkit.when(() -> Bukkit.getPlayerExact("Steve")).thenReturn(null);
+ com.bencodez.votingplugin.util.BukkitVoteEventDispatcher.dispatch(plugin, event);
+ }
+ verify(scheduler).runTask(same(plugin), dispatched.capture());
+ assertThrows(IllegalStateException.class, () -> dispatched.getValue().run());
+
+ assertTrue(event.isProcessingFailed());
+ assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone());
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java
index cf53cc7c52..02ed0cba90 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java
@@ -1,10 +1,14 @@
package com.bencodez.votingplugin.core.vote;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -38,12 +42,20 @@ private SharedVoteProcessor.Operations accepted() {
when(ops.siteKey(site)).thenReturn("ExampleKey");
when(ops.userId(user)).thenReturn("user-id");
when(ops.userUuid(user)).thenReturn(UUID.randomUUID());
+ when(ops.configuredPointAmount()).thenReturn(3);
+ when(ops.configuredPointCap()).thenReturn(100);
+ when(ops.prepareAccounting(eq(user), any(UUID.class), anyBoolean(), anyBoolean(), anyInt(), anyInt()))
+ .thenAnswer(invocation -> new SharedVoteProcessor.AccountingAdmission(
+ invocation.getArgument(2), invocation.getArgument(3), true,
+ invocation.getArgument(4), invocation.getArgument(5), "Points", false));
return ops;
}
@Test
void onlineVoteKeepsProductionPhaseOrderAndCounts() {
var ops = accepted();
+ UUID voteId = UUID.randomUUID();
+ when(ops.incomingVoteId()).thenReturn(voteId);
when(ops.userOnline(user)).thenReturn(true);
when(ops.processRewards()).thenReturn(true);
when(ops.broadcastEnabled()).thenReturn(true);
@@ -60,19 +72,21 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() {
InOrder order = inOrder(ops);
order.verify(ops).lastVoteTime(user, site);
+ order.verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true), eq(true), eq(3), eq(100));
order.verify(ops).cache(user);
order.verify(ops).updateName(user);
- order.verify(ops).voteParty(user, true, false, true);
+ order.verify(ops).markReplayUnsafe(voteId);
+ order.verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(true));
order.verify(ops).broadcast(any(UUID.class), eq("Ben"), any(), eq(true));
order.verify(ops).setTime(user, site, 123L);
order.verify(ops).playerVote(user, site, true, false);
order.verify(ops).sendVoteEffects(user, true);
order.verify(ops).closeInventory(user);
- order.verify(ops).addTotal(user);
- order.verify(ops).addTotalDaily(user);
- order.verify(ops).addTotalWeekly(user);
- order.verify(ops).addPoints(user);
- order.verify(ops).checkDayVoteStreak(user, false);
+ order.verify(ops).addTotal(eq(user), any(UUID.class));
+ order.verify(ops).addTotalDaily(eq(user), any(UUID.class));
+ order.verify(ops).addTotalWeekly(eq(user), any(UUID.class));
+ order.verify(ops).addPoints(user, voteId, 3, 100, "Points");
+ order.verify(ops).checkDayVoteStreak(eq(user), eq(false), any(UUID.class));
order.verify(ops).setMonthTotal(user, 2);
order.verify(ops).milestones(eq(user), any(UUID.class), eq(false));
order.verify(ops).cooldown(user, site);
@@ -80,15 +94,104 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() {
order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(123L), any(UUID.class), eq(false));
order.verify(ops).updatePlaceholders(user);
order.verify(ops).setUpdate();
+ order.verify(ops).completeDelivery(voteId);
+ order.verify(ops).finishAccounting(any(UUID.class));
}
+ @Test
+ void durableProducerRetainsReplayFenceUntilItsOwnAcknowledgement() {
+ var ops = accepted();
+ UUID voteId = UUID.randomUUID();
+ when(ops.incomingVoteId()).thenReturn(voteId);
+ when(ops.deferDeliveryCompletion()).thenReturn(true);
+
+ SharedVoteProcessor.process(ops);
+
+ verify(ops).markReplayUnsafe(voteId);
+ verify(ops, never()).completeDelivery(voteId);
+ }
+
+ @Test
+ void votePartyFailureKeepsTheFenceForQuarantine() {
+ var ops = accepted();
+ UUID voteId = UUID.randomUUID();
+ when(ops.incomingVoteId()).thenReturn(voteId);
+ doThrow(new IllegalStateException("reward failed"))
+ .when(ops).voteParty(user, false, voteId, true);
+
+ assertThrows(IllegalStateException.class, () -> SharedVoteProcessor.process(ops));
+
+ InOrder order = inOrder(ops);
+ order.verify(ops).markReplayUnsafe(voteId);
+ order.verify(ops).voteParty(user, false, voteId, true);
+ verify(ops, never()).completeDelivery(voteId);
+ verify(ops).finishAccounting(voteId);
+ }
+
+ @Test
+ void totalsUseTheEligibilityCapturedAtAdmission() {
+ var ops = accepted();
+ when(ops.userOnline(user)).thenReturn(true, false);
+
+ SharedVoteProcessor.process(ops);
+
+ verify(ops).prepareAccounting(eq(user), any(UUID.class), eq(true), eq(true), eq(3), eq(100));
+ verify(ops).addTotal(eq(user), any(UUID.class));
+ verify(ops).addTotalDaily(eq(user), any(UUID.class));
+ verify(ops).addTotalWeekly(eq(user), any(UUID.class));
+ }
+
+ @Test
+ void replayUsesPersistedAccountingEligibilityInsteadOfTheCurrentProposal() {
+ var ops = accepted();
+ when(ops.addTotals()).thenReturn(false);
+ when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false), eq(3), eq(100)))
+ .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, false, 7, 50, "Points", false));
+
+ SharedVoteProcessor.process(ops);
+
+ verify(ops).addTotal(eq(user), any(UUID.class));
+ verify(ops).addTotalDaily(eq(user), any(UUID.class));
+ verify(ops).addTotalWeekly(eq(user), any(UUID.class));
+ verify(ops).voteParty(eq(user), eq(false), any(UUID.class), eq(false));
+ }
+
+ @Test
+ void replayUsesPersistedPointsEligibilityWhenTotalsWereNotAdmitted() {
+ var ops = accepted();
+ when(ops.addTotals()).thenReturn(false);
+ when(ops.prepareAccounting(eq(user), any(UUID.class), eq(false), eq(false), eq(3), eq(100)))
+ .thenReturn(new SharedVoteProcessor.AccountingAdmission(false, true, false, 7, 50, "Points", false));
+
+ SharedVoteProcessor.process(ops);
+
+ verify(ops).addPoints(eq(user), any(UUID.class), eq(7), eq(50), eq("Points"));
+ verify(ops, never()).addTotal(eq(user), any(UUID.class));
+ }
+
+ @Test
+ void recoveredReplayUnsafeVoteStopsBeforeAnyEffect() {
+ var ops = accepted();
+ UUID voteId = UUID.randomUUID();
+ when(ops.incomingVoteId()).thenReturn(voteId);
+ when(ops.prepareAccounting(eq(user), eq(voteId), anyBoolean(), anyBoolean(), anyInt(), anyInt()))
+ .thenReturn(new SharedVoteProcessor.AccountingAdmission(true, true, true, 3, 100, "Points", true));
+
+ assertThrows(SharedVoteReplayUnsafeException.class, () -> SharedVoteProcessor.process(ops));
+
+ verify(ops).restoreReplayUnsafe();
+ verify(ops, never()).cache(user);
+ verify(ops, never()).markReplayUnsafe(any(UUID.class));
+ verify(ops, never()).broadcast(any(), any(), any(), anyBoolean());
+ }
+
@Test
void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() {
var ops = accepted();
UUID proxyId = UUID.randomUUID();
when(ops.proxyVote()).thenReturn(true);
when(ops.hasProxyTextTotals()).thenReturn(true);
- when(ops.proxyVoteId()).thenReturn(proxyId);
+ when(ops.incomingVoteId()).thenReturn(proxyId);
when(ops.wasOnline()).thenReturn(true);
when(ops.incomingTime()).thenReturn(321L);
when(ops.lastVoteTime(user, site)).thenReturn(321L);
@@ -102,37 +205,13 @@ void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() {
verify(ops).broadcast(any(UUID.class), eq("Ben"), any(), eq(true));
verify(ops).playerVote(user, site, true, false);
verify(ops, never()).addOfflineVote(any(), any());
- verify(ops, never()).addTotal(user);
- verify(ops).addPoints(user);
+ verify(ops, never()).addTotal(eq(user), any(UUID.class));
+ verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt(), anyString());
ArgumentCaptor id = ArgumentCaptor.forClass(UUID.class);
verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(321L), id.capture(), eq(false));
assertEquals(proxyId, id.getValue());
}
- @Test
- void proxyVoteUsesHistoricalOfflineStateForVotePartyWhenPlayerIsNowOnline() {
- var ops = accepted();
- when(ops.proxyVote()).thenReturn(true);
- when(ops.wasOnline()).thenReturn(false);
- when(ops.userOnline(user)).thenReturn(true);
-
- SharedVoteProcessor.process(ops);
-
- verify(ops).voteParty(user, true, false, false);
- }
-
- @Test
- void proxyVoteUsesHistoricalOnlineStateForVotePartyWhenPlayerIsNowOffline() {
- var ops = accepted();
- when(ops.proxyVote()).thenReturn(true);
- when(ops.wasOnline()).thenReturn(true);
- when(ops.userOnline(user)).thenReturn(false);
-
- SharedVoteProcessor.process(ops);
-
- verify(ops).voteParty(user, true, false, true);
- }
-
@Test
void proxyVoteWithoutTextTotalsSkipsMonthlyLimitEnforcement() {
var ops = accepted();
@@ -186,10 +265,10 @@ void offlineVoteQueuesBeforeAccountingAndMarksPostEventCached() {
InOrder order = inOrder(ops);
order.verify(ops).addOfflineVote(user, "ExampleKey");
- order.verify(ops).addPoints(user);
+ order.verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt(), anyString());
order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(456L), any(UUID.class), eq(true));
verify(ops, never()).playerVote(any(), any(), eq(false), eq(false));
- verify(ops, never()).addTotal(user);
+ verify(ops, never()).addTotal(eq(user), any(UUID.class));
verify(ops).clearCache(user);
}
@@ -230,6 +309,6 @@ void offlineQueueLimitPreservesUncachedPostEvent() {
verify(ops, never()).addOfflineVote(any(), any());
verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(457L), any(UUID.class), eq(false));
- verify(ops).addPoints(user);
+ verify(ops).addPoints(eq(user), any(UUID.class), anyInt(), anyInt(), anyString());
}
}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java
new file mode 100644
index 0000000000..9f700c07b6
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerAdmissionTest.java
@@ -0,0 +1,232 @@
+package com.bencodez.votingplugin.listeners;
+
+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.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.UUID;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.logging.Logger;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import com.bencodez.simpleapi.scheduler.BukkitScheduler;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.core.vote.SharedVoteAdmissionException;
+import com.bencodez.votingplugin.core.vote.SharedVoteProcessor;
+import com.bencodez.votingplugin.events.PlayerVoteEvent;
+import com.bencodez.votingplugin.user.UserManager;
+import com.bencodez.votingplugin.user.VotingPluginUser;
+
+class PlayerVoteListenerAdmissionTest {
+ @Test
+ void cancelledEventCompletesProducerSignalWithoutProcessing() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false);
+ event.setCancelled(true);
+ when(plugin.getVoteTimer()).thenReturn(voteExecutor);
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class);
+ MockedStatic processor = org.mockito.Mockito.mockStatic(SharedVoteProcessor.class)) {
+ bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
+ new PlayerVoteListener(plugin).onplayerVote(event);
+
+ processor.verifyNoInteractions();
+ }
+
+ assertTrue(event.isCancelled());
+ assertFalse(event.isProcessingPending());
+ assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone());
+ verify(voteExecutor, never()).submit(any(Runnable.class));
+ }
+
+ @Test
+ void primaryThreadEventDefersAccountingToTheVoteExecutor() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false);
+ when(plugin.getVoteTimer()).thenReturn(voteExecutor);
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
+ new PlayerVoteListener(plugin).onplayerVote(event);
+ }
+
+ verify(voteExecutor).submit(any(Runnable.class));
+ assertTrue(event.isProcessingPending());
+ assertFalse(event.isProcessingIncomplete());
+ assertFalse(event.getProcessingCompletion().toCompletableFuture().isDone());
+ }
+
+ @Test
+ void synchronousFoliaOwnedEventDefersAccountingWhenNotOnPrimaryThread() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false, false);
+ when(plugin.getVoteTimer()).thenReturn(voteExecutor);
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::isPrimaryThread).thenReturn(false);
+ bukkit.when(() -> Bukkit.getPlayerExact("player")).thenReturn(null);
+ new PlayerVoteListener(plugin).onplayerVote(event);
+ }
+
+ verify(voteExecutor).submit(any(Runnable.class));
+ assertTrue(event.isProcessingPending());
+ assertFalse(event.getProcessingCompletion().toCompletableFuture().isDone());
+ }
+
+ @Test
+ void asynchronousFailureCompletesTheProducerSignal() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false);
+ when(plugin.getVoteTimer()).thenReturn(voteExecutor);
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("PlayerVoteListenerAdmissionTest"));
+ org.mockito.ArgumentCaptor submitted = org.mockito.ArgumentCaptor.forClass(Runnable.class);
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class);
+ MockedStatic processor = org.mockito.Mockito.mockStatic(SharedVoteProcessor.class)) {
+ bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
+ processor.when(() -> SharedVoteProcessor.process(any()))
+ .thenThrow(new SharedVoteAdmissionException("storage unavailable"));
+ new PlayerVoteListener(plugin).onplayerVote(event);
+ verify(voteExecutor).submit(submitted.capture());
+
+ submitted.getValue().run();
+ }
+
+ assertTrue(event.isAccountingAdmissionFailed());
+ assertFalse(event.isProcessingPending());
+ assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone());
+ }
+
+ @Test
+ void rejectedDeferralSurfacesAccountingFailure() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false);
+ when(plugin.getVoteTimer()).thenReturn(voteExecutor);
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("PlayerVoteListenerAdmissionTest"));
+ doThrow(new RejectedExecutionException("full")).when(voteExecutor).submit(any(Runnable.class));
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
+ new PlayerVoteListener(plugin).onplayerVote(event);
+ }
+
+ assertTrue(event.isAccountingAdmissionFailed());
+ assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone());
+ }
+
+ @Test
+ void primaryThreadDurableProducerIsRejectedForOwnedRetry() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ ScheduledExecutorService voteExecutor = mock(ScheduledExecutorService.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false);
+ event.setDeferredDeliveryCompletion(true);
+ when(plugin.getVoteTimer()).thenReturn(voteExecutor);
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("PlayerVoteListenerAdmissionTest"));
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
+ new PlayerVoteListener(plugin).onplayerVote(event);
+ }
+
+ assertTrue(event.isAccountingAdmissionFailed());
+ assertFalse(event.isProcessingPending());
+ assertTrue(event.getProcessingCompletion().toCompletableFuture().isDone());
+ verify(voteExecutor, never()).submit(any(Runnable.class));
+ }
+
+ @Test
+ void postAdmissionFailureIsVisibleToDurableProducers() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false);
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("PlayerVoteListenerAdmissionTest"));
+
+ try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class);
+ MockedStatic processor = org.mockito.Mockito.mockStatic(SharedVoteProcessor.class)) {
+ bukkit.when(Bukkit::isPrimaryThread).thenReturn(false);
+ processor.when(() -> SharedVoteProcessor.process(any()))
+ .thenThrow(new IllegalStateException("storage failed"));
+ new PlayerVoteListener(plugin).onplayerVote(event);
+ }
+
+ assertTrue(event.isProcessingFailed());
+ }
+
+ @Test
+ void capturedPlatformStateAvoidsWorkerPlayerReads() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ PlayerVoteEvent event = new PlayerVoteEvent(null, "player", "site", false);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ UserManager users = mock(UserManager.class);
+ UUID uuid = UUID.randomUUID();
+ when(plugin.getVotingPluginUserManager()).thenReturn(users);
+ when(users.getVotingPluginUser(uuid, "Player")).thenReturn(user);
+ when(user.getJavaUUID()).thenReturn(uuid);
+ PlayerVoteListener.PlatformVoteState state = new PlayerVoteListener.PlatformVoteState(true, mock(Player.class),
+ uuid, "Player", true, true, true);
+ PlayerVoteListener.BukkitOperations operations = new PlayerVoteListener.BukkitOperations(plugin, event, state);
+
+ assertTrue(operations.userOnline(user));
+ assertTrue(operations.userVanished(user));
+ assertTrue(operations.bypassWaitPermission(user));
+ assertTrue(operations.resolveUser("player") == user);
+ verify(user, never()).isOnline();
+ verify(user, never()).isVanished();
+ verify(user, never()).hasPermission(any());
+ }
+
+ @Test
+ void capturedPlatformStateSchedulesBroadcastForThePlayerOwner() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ Player owner = mock(Player.class);
+ UUID uuid = UUID.randomUUID();
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ PlayerVoteListener.PlatformVoteState state = new PlayerVoteListener.PlatformVoteState(true, owner, uuid,
+ "Player", true, false, false);
+ PlayerVoteListener.BukkitOperations operations = new PlayerVoteListener.BukkitOperations(plugin,
+ new PlayerVoteEvent(null, "player", "site", false), state);
+
+ operations.broadcast(uuid, "Player", "Site", true);
+
+ verify(scheduler).runTask(org.mockito.ArgumentMatchers.same(plugin), any(Runnable.class),
+ org.mockito.ArgumentMatchers.same(owner));
+ }
+
+ @Test
+ void closeInventoryRunsOnlyThroughTheCapturedPlayerOwner() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ Player owner = mock(Player.class);
+ UUID uuid = UUID.randomUUID();
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ PlayerVoteListener.PlatformVoteState state = new PlayerVoteListener.PlatformVoteState(true, owner, uuid,
+ "Player", true, false, false);
+ PlayerVoteListener.BukkitOperations operations = new PlayerVoteListener.BukkitOperations(plugin,
+ new PlayerVoteEvent(null, "player", "site", false), state);
+ org.mockito.ArgumentCaptor close = org.mockito.ArgumentCaptor.forClass(Runnable.class);
+
+ operations.closeInventory(user);
+
+ verify(scheduler).runTask(org.mockito.ArgumentMatchers.same(plugin), close.capture(),
+ org.mockito.ArgumentMatchers.same(owner));
+ verify(user, never()).closeInv();
+ close.getValue().run();
+ verify(user).closeInv();
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/SpecialRewardsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/SpecialRewardsTest.java
new file mode 100644
index 0000000000..10a11d6596
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/SpecialRewardsTest.java
@@ -0,0 +1,46 @@
+package com.bencodez.votingplugin.specialrewards;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Set;
+
+import org.bukkit.configuration.file.FileConfiguration;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import com.bencodez.advancedcore.api.rewards.RewardHandler;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.config.SpecialRewardsConfig;
+import com.bencodez.votingplugin.user.VotingPluginUser;
+
+class SpecialRewardsTest {
+ @Test
+ void recoveredStreakRewardUsesJournaledValueInsteadOfMutableUserValue() {
+ VotingPluginMain plugin = Mockito.mock(VotingPluginMain.class);
+ SpecialRewardsConfig config = Mockito.mock(SpecialRewardsConfig.class);
+ RewardHandler rewardHandler = Mockito.mock(RewardHandler.class);
+ FileConfiguration data = Mockito.mock(FileConfiguration.class);
+ VotingPluginUser user = Mockito.mock(VotingPluginUser.class);
+ SpecialRewards rewards = spy(new SpecialRewards(plugin));
+ when(plugin.getSpecialRewardsConfig()).thenReturn(config);
+ when(plugin.getRewardHandler()).thenReturn(rewardHandler);
+ when(config.getVoteStreakVotes("Day")).thenReturn(Set.of("7"));
+ when(config.getVoteStreakRewardEnabled("Day", "7")).thenReturn(true);
+ when(config.getVoteStreakRewardsPath("Day", "7")).thenReturn("VoteStreak.Day.7");
+ when(config.getData()).thenReturn(data);
+ when(rewardHandler.hasRewards(any(), anyString())).thenReturn(true);
+ when(user.isOnline()).thenReturn(true);
+ doNothing().when(rewards).giveVoteStreakReward(null, user, true, "Day", "7", 7, true);
+
+ rewards.checkVoteStreakAt(null, user, "Day", 7, true);
+
+ verify(rewards).giveVoteStreakReward(null, user, true, "Day", "7", 7, true);
+ verify(user, never()).getDayVoteStreak();
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java
index e89d6376f5..daffdc8aa5 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java
@@ -8,10 +8,12 @@
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doReturn;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
+import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
@@ -25,7 +27,10 @@
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
+import org.mockito.InOrder;
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
+import com.bencodez.advancedcore.api.time.events.DayChangeEvent;
import com.bencodez.advancedcore.api.user.UserManager;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.config.Config;
@@ -86,9 +91,28 @@ public void tearDown() {
@Test
public void addTotal_IncrementsTotalVotes() {
- when(plugin.getServerData().getData().getInt("VoteParty.Total")).thenReturn(5);
+ when(plugin.getServerData().incrementVotePartyTotal(null)).thenReturn(true);
voteParty.addTotal(user);
- verify(plugin.getServerData().getData()).set("VoteParty.Total", 6);
+ verify(plugin.getServerData()).incrementVotePartyTotal(null);
+ verify(user).addVotePartyVote(null);
+ verify(plugin.getPlaceholders()).onVotePartyUpdate();
+ }
+
+ @Test
+ public void admittedVotePartyUsesTheCapturedEligibilityWithoutRecheckingOnlineState() {
+ UUID voteId = UUID.randomUUID();
+ Mockito.doNothing().when(voteParty).addTotal(user, voteId);
+ Mockito.doNothing().when(voteParty).addVotePlayer(user);
+ Mockito.doNothing().when(voteParty).check(user, false);
+ Mockito.doNothing().when(voteParty).checkVoteReminder(user);
+
+ voteParty.voteAdmitted(user, false, voteId, true);
+
+ verify(voteParty).addTotal(user, voteId);
+ verify(voteParty).addVotePlayer(user);
+ verify(voteParty).check(user, false);
+ verify(voteParty).checkVoteReminder(user);
+ verify(user, never()).isOnline();
}
@Test
@@ -112,6 +136,61 @@ public void check_NotEnoughVotes_DoesNotTriggerVoteParty() {
verify(voteParty, never()).giveRewards(any(), eq(false));
}
+ @Test
+ public void dayResetRecordsTransitionEffectBeforeCompletingLease() {
+ TimeChangeTransition transition = Mockito.mock(TimeChangeTransition.class);
+ TimeChangeTransition.Lease lease = Mockito.mock(TimeChangeTransition.Lease.class);
+ when(transition.retain()).thenReturn(lease);
+ when(transition.getType()).thenReturn(com.bencodez.advancedcore.api.time.TimeType.DAY);
+ when(transition.getId()).thenReturn("DAY:2026-09-21");
+ when(plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()).thenReturn(true);
+ when(plugin.getConfigFile().isAutomaticTimeChangeEnabled(com.bencodez.advancedcore.api.time.TimeType.DAY))
+ .thenReturn(true);
+ when(plugin.getServerData().prepareTimeChangeEffectPolicy(transition, "VotePartyDayReset", true))
+ .thenReturn(true);
+ doReturn(true).when(voteParty)
+ .copyRecoverableUserCountBoundary("vote-party-copy:DAY:2026-09-21:VotePartyDayReset");
+ doReturn(true).when(voteParty)
+ .resetRecoverableUserCounts("vote-party-reset:DAY:2026-09-21:VotePartyDayReset");
+
+ voteParty.onDayChange(new DayChangeEvent(transition));
+
+ verify(plugin.getServerData()).beginTimeChangeRecovery(transition);
+ verify(plugin.getServerData()).prepareTimeChangeEffectPolicy(transition, "VotePartyDayReset", true);
+ InOrder completionOrder = Mockito.inOrder(voteParty, plugin.getServerData(), lease);
+ completionOrder.verify(voteParty)
+ .copyRecoverableUserCountBoundary("vote-party-copy:DAY:2026-09-21:VotePartyDayReset");
+ completionOrder.verify(plugin.getServerData())
+ .prepareTimeChangeVotePartyReset(transition, "VotePartyDayReset");
+ completionOrder.verify(voteParty)
+ .resetRecoverableUserCounts("vote-party-reset:DAY:2026-09-21:VotePartyDayReset");
+ completionOrder.verify(plugin.getServerData())
+ .completeTimeChangeVotePartyReset(transition, "VotePartyDayReset");
+ completionOrder.verify(lease).complete();
+ verify(plugin.getServerData(), never()).completeTimeChangeEffect(transition, "VotePartyDayReset");
+ }
+
+ @Test
+ public void dayResetResumesWhenConfigurationWasDisabledAfterAdmission() {
+ TimeChangeTransition transition = Mockito.mock(TimeChangeTransition.class);
+ TimeChangeTransition.Lease lease = Mockito.mock(TimeChangeTransition.Lease.class);
+ when(transition.retain()).thenReturn(lease);
+ when(transition.getId()).thenReturn("DAY:2026-09-21");
+ when(plugin.getSpecialRewardsConfig().isVotePartyResetEachDay()).thenReturn(false);
+ when(plugin.getServerData().isTimeChangeRecoveryActive(transition)).thenReturn(true);
+ when(plugin.getServerData().prepareTimeChangeEffectPolicy(transition, "VotePartyDayReset", false))
+ .thenReturn(true);
+ doReturn(true).when(voteParty)
+ .copyRecoverableUserCountBoundary("vote-party-copy:DAY:2026-09-21:VotePartyDayReset");
+ doReturn(true).when(voteParty)
+ .resetRecoverableUserCounts("vote-party-reset:DAY:2026-09-21:VotePartyDayReset");
+
+ voteParty.onDayChange(new DayChangeEvent(transition));
+
+ verify(plugin.getServerData()).completeTimeChangeVotePartyReset(transition, "VotePartyDayReset");
+ verify(lease).complete();
+ }
+
@Test
public void commandVoteParty_VotePartyEnabled_SendsMessage() {
CommandSender sender = Mockito.mock(CommandSender.class);
@@ -156,7 +235,7 @@ public void vote_OnlineHistoricalStateCountsEvenIfPlayerIsCurrentlyOffline() {
voteParty.vote(user, true, false, true);
- verify(voteParty).addTotal(user);
+ verify(voteParty).addTotal(user, null);
}
@Test
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java
index ff09a65832..132753ae4c 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java
@@ -21,6 +21,7 @@
import com.bencodez.advancedcore.api.time.TimeType;
import com.bencodez.advancedcore.bungeeapi.globaldata.GlobalDataHandlerProxy;
+import com.bencodez.advancedcore.bungeeapi.globaldata.GlobalMySQL;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
import com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer;
import com.bencodez.simpleapi.servercomm.mysql.MySqlMessenger;
@@ -183,6 +184,74 @@ void backendControlEnrollmentDoesNotChallengeAnExternalControlEndpoint() throws
assertEquals(0, proxy.getControlEnrollmentInstallCount());
}
+ @Test
+ void monthlyCompletionOnlyClearsTheBoundaryAlreadyCapturedByTheBackend() {
+ GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class);
+ Mockito.when(globalDataHandler.getGlobalMysql()).thenReturn(globalMysql);
+ Mockito.when(globalMysql.containsKey("Server1")).thenReturn(true);
+ Mockito.when(globalDataHandler.getString("Server1", "BoundaryTransitionMONTH")).thenReturn("transition-1");
+ Mockito.when(globalDataHandler.getString("Server1", "BoundaryCapturedMONTH")).thenReturn("transition-1");
+
+ votingPluginProxy.onTimeChangedFinished(TimeType.MONTH);
+
+ verify(proxyMySQL).wipeColumnData("MonthTotal", com.bencodez.simpleapi.sql.DataType.INTEGER);
+ verify(proxyMySQL, never()).copyColumnData(Mockito.anyString(), Mockito.anyString());
+ }
+
+ @Test
+ void monthlyCompletionUsesLegacyBoundaryWhenAnyBackendLacksConfirmationSupport() {
+ GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class);
+ Mockito.when(globalDataHandler.getGlobalMysql()).thenReturn(globalMysql);
+ Mockito.when(globalMysql.containsKey("Server1")).thenReturn(true);
+ Mockito.when(globalDataHandler.getString("Server1", "BoundaryTransitionMONTH"))
+ .thenReturn(VotingPluginWire.LEGACY_TIME_CHANGE_TRANSITION);
+
+ votingPluginProxy.onTimeChangedFinished(TimeType.MONTH);
+
+ org.mockito.InOrder boundaryThenReset = Mockito.inOrder(proxyMySQL);
+ boundaryThenReset.verify(proxyMySQL).copyColumnData("MonthTotal", "LastMonthTotal");
+ boundaryThenReset.verify(proxyMySQL).wipeColumnData("MonthTotal",
+ com.bencodez.simpleapi.sql.DataType.INTEGER);
+ }
+
+ @Test
+ void failedLegacyBackendCannotAuthorizeTheProxyBoundaryReset() {
+ votingPluginProxy.onTimeChangedFailed("Server1", TimeType.MONTH);
+
+ verify(globalDataHandler).setData(Mockito.eq("Server1"), Mockito.argThat(values ->
+ !values.get("MONTH").getBoolean()
+ && values.get("BoundaryTransitionMONTH").getString().isEmpty()
+ && values.get("BoundaryCapturedMONTH").getString().isEmpty()));
+ }
+
+ @Test
+ void monthlyCompletionRetainsTotalsWhenEveryBackendFailedBeforeBoundaryCapture() {
+ GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class);
+ Mockito.when(globalDataHandler.getGlobalMysql()).thenReturn(globalMysql);
+ Mockito.when(globalMysql.containsKey(Mockito.anyString())).thenReturn(true);
+
+ votingPluginProxy.onTimeChangedFinished(TimeType.MONTH);
+
+ verify(proxyMySQL, never()).wipeColumnData(Mockito.anyString(), Mockito.any());
+ assertTrue(votingPluginProxy.getWarnings().stream()
+ .anyMatch(message -> message.contains("no backend confirmed the time-change boundary")));
+ }
+
+ @Test
+ void monthlyCompletionRejectsALateConfirmationFromAnOlderTransition() {
+ GlobalMySQL globalMysql = Mockito.mock(GlobalMySQL.class);
+ Mockito.when(globalDataHandler.getGlobalMysql()).thenReturn(globalMysql);
+ Mockito.when(globalMysql.containsKey("Server1")).thenReturn(true);
+ Mockito.when(globalDataHandler.getString("Server1", "BoundaryTransitionMONTH"))
+ .thenReturn("transition-2");
+ Mockito.when(globalDataHandler.getString("Server1", "BoundaryCapturedMONTH"))
+ .thenReturn("transition-1");
+
+ votingPluginProxy.onTimeChangedFinished(TimeType.MONTH);
+
+ verify(proxyMySQL, never()).wipeColumnData(Mockito.anyString(), Mockito.any());
+ }
+
@Test
void httpMethodPreparationRejectsAnOccupiedPortBeforeConfigurationPublication() throws Exception {
try (java.net.ServerSocket occupied = new java.net.ServerSocket(0, 1,
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java
index 640af6eb71..cef6f2651a 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginWireTest.java
@@ -19,6 +19,14 @@
* Tests proxy vote wire encoding and decoding.
*/
public class VotingPluginWireTest {
+ @Test
+ void boundaryProtocolCapabilityMustMatchTheCurrentBackendHeartbeat() {
+ assertTrue(VotingPluginWire.supportsTimeChangeBoundaryProtocol("200",
+ VotingPluginWire.timeChangeBoundaryProtocolHeartbeat("200")));
+ assertFalse(VotingPluginWire.supportsTimeChangeBoundaryProtocol("201",
+ VotingPluginWire.timeChangeBoundaryProtocolHeartbeat("200")));
+ assertFalse(VotingPluginWire.supportsTimeChangeBoundaryProtocol("200", ""));
+ }
@Test
public void voteRoundTripPreservesVoteId() {
@@ -63,6 +71,16 @@ public void voteOnlineRoundTripAllowsMissingVoteId() {
assertNull(vote.voteId);
}
+ @Test
+ public void legacyTotalsSupplyTheVoteIdWhenTheTopLevelFieldIsMissing() {
+ UUID voteId = UUID.randomUUID();
+ String legacyTotals = "1//2//3//4//5//0//6//7//8//" + voteId;
+ JsonEnvelope envelope = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 100L,
+ true, true, legacyTotals, null, true, false, 1, 1);
+
+ assertEquals(voteId, VotingPluginWire.resolveVoteId(VotingPluginWire.readVote(envelope)));
+ }
+
@Test
public void voteDelayRejectedRoundTripPreservesContext() {
String uuid = UUID.randomUUID().toString();
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java
new file mode 100644
index 0000000000..51f175f999
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/data/ServerDataTimeChangeRecoveryTest.java
@@ -0,0 +1,653 @@
+package com.bencodez.votingplugin.tests.data;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.mockStatic;
+
+import java.util.List;
+import java.util.UUID;
+import java.nio.file.Path;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
+import com.bencodez.advancedcore.api.time.TimeType;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.data.ServerData;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSection;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeArchiveSnapshot;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeTopPolicy;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy;
+import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
+import com.bencodez.votingplugin.util.DurableFiles;
+
+class ServerDataTimeChangeRecoveryTest {
+ @TempDir
+ Path temporaryDirectory;
+
+ @Test
+ void repeatedVotePartyIdentityIncrementsTheGlobalTotalOnlyOnce() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ UUID voteId = UUID.randomUUID();
+
+ assertTrue(data.incrementVotePartyTotal(voteId));
+ assertFalse(data.incrementVotePartyTotal(voteId));
+
+ assertEquals(1, yaml.getInt("VotingPlugin.VoteParty.Total"));
+ assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + voteId));
+ verify(coreData).saveData();
+ }
+
+ @Test
+ void oldVotePartyReceiptsRemainForUnboundedQueueReplay() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ UUID oldVote = UUID.randomUUID();
+ yaml.set("VotingPlugin.VoteParty.Accounting." + oldVote, 1L);
+
+ assertTrue(data.incrementVotePartyTotal(UUID.randomUUID()));
+ assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + oldVote));
+ }
+
+ @Test
+ void completedDeliveryRetiresOnlyItsVotePartyReceipt() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ UUID completed = UUID.randomUUID();
+ UUID pending = UUID.randomUUID();
+ assertTrue(data.incrementVotePartyTotal(completed));
+ assertTrue(data.incrementVotePartyTotal(pending));
+
+ data.clearVotePartyAccounting(completed);
+
+ assertFalse(yaml.contains("VotingPlugin.VoteParty.Accounting." + completed));
+ assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + pending));
+ }
+
+ @Test
+ void acknowledgedVoteRemovesOnlyItsReplayFence() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ UUID completed = UUID.randomUUID();
+ UUID pending = UUID.randomUUID();
+
+ data.markVoteReplayUnsafe(completed);
+ data.markVoteReplayUnsafe(pending);
+ data.clearVoteReplayUnsafe(completed);
+
+ assertFalse(data.isVoteReplayUnsafe(completed));
+ assertTrue(data.isVoteReplayUnsafe(pending));
+ verify(coreData, org.mockito.Mockito.times(3)).saveData();
+ }
+
+ @Test
+ void failedReplayFenceSaveRestoresThePreviousYamlState() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ UUID existing = UUID.randomUUID();
+ UUID failed = UUID.randomUUID();
+ data.markVoteReplayUnsafe(existing);
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+
+ assertThrows(IllegalStateException.class, () -> data.markVoteReplayUnsafe(failed));
+
+ assertTrue(data.isVoteReplayUnsafe(existing));
+ assertFalse(data.isVoteReplayUnsafe(failed));
+ }
+
+ @Test
+ void failedReplayFenceRetirementRestoresThePreviousYamlState() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ UUID voteId = UUID.randomUUID();
+ data.markVoteReplayUnsafe(voteId);
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+
+ assertThrows(IllegalStateException.class, () -> data.clearVoteReplayUnsafe(voteId));
+
+ assertTrue(data.isVoteReplayUnsafe(voteId));
+ }
+
+ @Test
+ void failedVotePartyRetirementRestoresThePreviousYamlState() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ UUID voteId = UUID.randomUUID();
+ assertTrue(data.incrementVotePartyTotal(voteId));
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+
+ assertThrows(IllegalStateException.class, () -> data.clearVotePartyAccounting(voteId));
+
+ assertTrue(yaml.contains("VotingPlugin.VoteParty.Accounting." + voteId));
+ }
+
+ @Test
+ void publishedCheckpointDurabilityFailureRequiresAnotherPublicationAttempt() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK);
+ AtomicInteger attempts = new AtomicInteger();
+
+ try (org.mockito.MockedStatic files = mockStatic(DurableFiles.class)) {
+ files.when(() -> DurableFiles.publishStagedFile(
+ org.mockito.ArgumentMatchers.any(Path.class), org.mockito.ArgumentMatchers.any(Path.class)))
+ .thenAnswer(ignored -> {
+ if (attempts.getAndIncrement() == 0)
+ throw new DurableFiles.PublishedException(new IOException("directory force failed"));
+ return null;
+ });
+
+ assertThrows(IllegalStateException.class, () -> data.beginTimeChangeRecovery(transition));
+ data.beginTimeChangeRecovery(transition);
+ }
+
+ assertEquals(2, attempts.get());
+ }
+
+ @Test
+ void failedTimedVoteSnapshotSaveRestoresThePreviousYamlState() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ VoteTimeQueue existing = new VoteTimeQueue(UUID.randomUUID(), "Alex", "old.example", 123L);
+ VoteTimeQueue failed = new VoteTimeQueue(UUID.randomUUID(), "Steve", "new.example", 124L);
+ data.replaceTimedVoteCache(List.of(existing));
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+
+ assertThrows(IllegalStateException.class, () -> data.replaceTimedVoteCache(List.of(failed)));
+
+ assertEquals("Alex", yaml.getString("VotingPlugin.TimedVoteCache.0.Name"));
+ assertEquals("old.example", yaml.getString("VotingPlugin.TimedVoteCache.0.Service"));
+ assertEquals(existing.getVoteId().toString(),
+ yaml.getString("VotingPlugin.TimedVoteCache.0.VoteId"));
+ assertFalse(yaml.contains("VotingPlugin.TimedVoteCache.1"));
+ }
+
+ @Test
+ void timedVoteStatePersistsRetirementsAndRollsBackBothSectionsTogether() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ VoteTimeQueue existing = new VoteTimeQueue(UUID.randomUUID(), "Alex", "old.example", 123L);
+ UUID existingRetirement = UUID.randomUUID();
+ data.replaceTimedVoteState(List.of(existing), List.of(existingRetirement));
+ VoteTimeQueue failed = new VoteTimeQueue(UUID.randomUUID(), "Steve", "new.example", 124L);
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+
+ assertThrows(IllegalStateException.class,
+ () -> data.replaceTimedVoteState(List.of(failed), List.of(UUID.randomUUID())));
+
+ assertEquals("Alex", yaml.getString("VotingPlugin.TimedVoteCache.0.Name"));
+ assertEquals(List.of(existingRetirement), data.getTimedVoteRetirements());
+ }
+
+ @Test
+ void ambiguousTimedVoteIsStoredOutsideTheAutomaticReplayQueue() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L);
+
+ data.quarantineTimedVote(vote);
+
+ assertEquals("Alex", yaml.getString("VotingPlugin.TimedVoteQuarantine." + vote.getVoteId() + ".Name"));
+ assertFalse(yaml.contains("VotingPlugin.TimedVoteCache"));
+ verify(coreData).saveData();
+ }
+
+ @Test
+ void perUserCursorUsesTheCompactDurableCheckpointWithoutRewritingServerData() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK);
+ ServerData data = new ServerData(plugin);
+ String uuid = "00000000-0000-0000-0000-000000000002";
+
+ data.beginTimeChangeRecovery(transition);
+ clearInvocations(coreData);
+ data.completeTimeChangeUser(transition, uuid);
+ String nextUuid = "00000000-0000-0000-0000-000000000003";
+ data.prepareTimeChangeUserStreak(transition, nextUuid, 4, true);
+ data.claimTimeChangeUserStreakReward(transition, nextUuid);
+
+ verify(coreData, never()).saveData();
+ ServerData recovered = new ServerData(plugin);
+ assertEquals(uuid, recovered.getTimeChangeCursor(transition));
+ assertEquals(TimeChangeRewardState.CLAIMED,
+ recovered.getTimeChangeUserStreakRewardState(transition, nextUuid));
+ assertTrue(temporaryDirectory.resolve("TimeChangeUserCheckpoint.properties").toFile().length() < 4096L);
+ }
+
+ @Test
+ void compactCheckpointImportsAnExistingServerDataCursor() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ TimeChangeTransition transition = transition("MONTH:2026-08", "2026-08", TimeType.MONTH);
+ String cursor = "00000000-0000-0000-0000-000000000009";
+ yaml.set("VotingPlugin.TimeChangeRecovery.MONTH.Id", transition.getId());
+ yaml.set("VotingPlugin.TimeChangeRecovery.MONTH.Cursor", cursor);
+
+ ServerData data = new ServerData(plugin);
+ data.beginTimeChangeRecovery(transition);
+
+ assertEquals(cursor, data.getTimeChangeCursor(transition));
+ assertEquals(cursor, new ServerData(plugin).getTimeChangeCursor(transition));
+ }
+
+ @Test
+ void checkpointRetainsPhaseCursorAndRewardReceiptsForTheTransition() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK);
+ ServerData data = new ServerData(plugin);
+
+ data.beginTimeChangeRecovery(transition);
+ assertFalse(data.hasTimeChangePhase(transition, "SNAPSHOT"));
+ data.completeTimeChangePhase(transition, "SNAPSHOT");
+ data.completeTimeChangePhase(transition, "USER_UPDATES");
+ data.completeTimeChangeUser(transition, "00000000-0000-0000-0000-000000000002");
+ data.completeTimeChangeReward(transition, "00000000-0000-0000-0000-000000000001");
+
+ assertTrue(data.hasTimeChangePhase(transition, "SNAPSHOT"));
+ assertTrue(data.hasTimeChangePhase(transition, "COPY_TOTALS"));
+ assertTrue(data.hasTimeChangePhase(transition, "USER_UPDATES"));
+ assertTrue(data.getTimeChangeCursor(transition).endsWith("0002"));
+ assertTrue(data.hasTimeChangeRewardReceipt(transition, "00000000-0000-0000-0000-000000000001"));
+ verify(coreData, atLeastOnce()).saveData();
+ }
+
+ @Test
+ void retryKeepsTheDailyStreakBoundaryDecisionFromTransitionStart() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY);
+ ServerData data = new ServerData(plugin);
+ data.beginTimeChangeRecovery(transition);
+
+ TimeChangeUserPolicy original = new TimeChangeUserPolicy(false, true, false, true, 50, 60, 70, true, false, true, 4);
+ TimeChangeUserPolicy changed = new TimeChangeUserPolicy(true, false, true, false, 1, 2, 3, false, true, false, 8);
+ assertEquals(original, data.prepareTimeChangeUserPolicy(transition, original));
+ assertEquals(original, data.prepareTimeChangeUserPolicy(transition, changed));
+ assertEquals(original, new ServerData(plugin).getTimeChangeUserPolicy(transition));
+ }
+
+ @Test
+ void failedDailyStreakBoundarySaveDoesNotPublishTheDecision() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY);
+ ServerData data = new ServerData(plugin);
+ data.beginTimeChangeRecovery(transition);
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+
+ TimeChangeUserPolicy policy = new TimeChangeUserPolicy(true, true, true, true, 50, 60, 70, true, false, true);
+ assertThrows(IllegalStateException.class, () -> data.prepareTimeChangeUserPolicy(transition, policy));
+
+ assertThrows(IllegalStateException.class, () -> data.getTimeChangeUserPolicy(transition));
+ doNothing().when(coreData).saveData();
+ assertEquals(policy, data.prepareTimeChangeUserPolicy(transition, policy));
+ }
+
+ @Test
+ void retryKeepsTheVoteShopTargetsSelectedBeforeAnyReset() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK);
+ ServerData data = new ServerData(plugin);
+ data.beginTimeChangeRecovery(transition);
+
+ assertEquals(List.of("weekly"),
+ data.prepareTimeChangeVoteShopTargets(transition, List.of("weekly")));
+ assertEquals(List.of("weekly"),
+ data.prepareTimeChangeVoteShopTargets(transition, List.of("newly-enabled")));
+ assertEquals(List.of("weekly"), new ServerData(plugin).getTimeChangeVoteShopTargets(transition));
+ }
+
+ @Test
+ void retryKeepsTopRewardAndArchivePolicySelectedBeforeTheBoundary() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK);
+ ServerData data = new ServerData(plugin);
+ data.beginTimeChangeRecovery(transition);
+ TimeChangeTopPolicy original = new TimeChangeTopPolicy(true, true, true, true,
+ List.of("1", "2-4"), List.of("blocked"));
+ TimeChangeTopPolicy changed = new TimeChangeTopPolicy(false, false, false, false,
+ List.of("5"), List.of());
+
+ assertEquals(original, data.prepareTimeChangeTopPolicy(transition, original));
+ assertEquals(original, data.prepareTimeChangeTopPolicy(transition, changed));
+ assertEquals(original, new ServerData(plugin).getTimeChangeTopPolicy(transition));
+ }
+
+ @Test
+ void newPeriodReplacesOnlyThatTimeTypesOldCheckpoint() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition oldTransition = transition("MONTH:2026-08", "2026-08", TimeType.MONTH);
+ TimeChangeTransition newTransition = transition("MONTH:2026-09", "2026-09", TimeType.MONTH);
+
+ data.beginTimeChangeRecovery(oldTransition);
+ data.completeTimeChangeReward(oldTransition, "00000000-0000-0000-0000-000000000001");
+ data.beginTimeChangeRecovery(newTransition);
+
+ assertFalse(data.hasTimeChangeRewardReceipt(newTransition, "00000000-0000-0000-0000-000000000001"));
+ assertFalse(data.hasTimeChangePhase(oldTransition, "START"));
+ }
+
+ @Test
+ void interruptedUserKeepsItsAbsoluteStreakTargetUntilCursorAdvances() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ data.beginTimeChangeRecovery(transition);
+
+ TimeChangeUserProgress first = data.prepareTimeChangeUserStreak(transition, uuid, 8, true);
+ TimeChangeUserProgress retry = data.prepareTimeChangeUserStreak(transition, uuid, 9, true);
+
+ assertEquals(8, first.streakTarget());
+ assertEquals(8, retry.streakTarget());
+ assertFalse(retry.rewardComplete());
+ data.completeTimeChangeUserStreakReward(transition, uuid);
+ assertTrue(data.prepareTimeChangeUserStreak(transition, uuid, 9, true).rewardComplete());
+ data.completeTimeChangeUser(transition, uuid);
+ assertEquals(uuid, data.getTimeChangeCursor(transition));
+ }
+
+ @Test
+ void durableRewardClaimsPreventAmbiguousAutomaticReplay() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ data.beginTimeChangeRecovery(transition);
+
+ assertEquals(TimeChangeRewardState.UNCLAIMED, data.getTimeChangeRewardState(transition, uuid));
+ data.claimTimeChangeReward(transition, uuid);
+ assertEquals(TimeChangeRewardState.CLAIMED, data.getTimeChangeRewardState(transition, uuid));
+ assertThrows(IllegalStateException.class, () -> data.claimTimeChangeReward(transition, uuid));
+ data.completeTimeChangeReward(transition, uuid);
+ assertEquals(TimeChangeRewardState.COMPLETE, data.getTimeChangeRewardState(transition, uuid));
+ assertTrue(data.hasTimeChangeRewardReceipt(transition, uuid));
+ }
+
+ @Test
+ void failedRewardClaimSaveRollsBackToUnclaimed() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ data.beginTimeChangeRecovery(transition);
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+
+ assertThrows(IllegalStateException.class, () -> data.claimTimeChangeReward(transition, uuid));
+ assertEquals(TimeChangeRewardState.UNCLAIMED, data.getTimeChangeRewardState(transition, uuid));
+ }
+
+ @Test
+ void failedRewardCompletionRemainsClaimed() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ data.beginTimeChangeRecovery(transition);
+ data.claimTimeChangeReward(transition, uuid);
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+
+ assertThrows(IllegalStateException.class, () -> data.completeTimeChangeReward(transition, uuid));
+ assertEquals(TimeChangeRewardState.CLAIMED, data.getTimeChangeRewardState(transition, uuid));
+ assertFalse(data.hasTimeChangeRewardReceipt(transition, uuid));
+ }
+
+ @Test
+ void retryKeepsTheFirstDurableRankedRewardSelection() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY);
+ data.beginTimeChangeRecovery(transition);
+ List original = List.of(
+ new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000001", "first", 1, "1", 20),
+ new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000002", "second", 2, "2", 10));
+ List changed = List.of(
+ new TimeChangeRewardTarget("00000000-0000-0000-0000-000000000003", "late", 1, "1", 99));
+
+ assertEquals(original, data.prepareTimeChangeRewardTargets(transition, original));
+ assertEquals(original, data.prepareTimeChangeRewardTargets(transition, changed));
+ assertEquals(original, new ServerData(plugin).getTimeChangeRewardTargets(transition));
+ }
+
+ @Test
+ void retryKeepsTheFirstDurableArchiveSnapshot() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY);
+ TimeChangeArchiveSnapshot original = new TimeChangeArchiveSnapshot(List.of(
+ new TimeChangeArchiveSection("Daily", List.of("Combined total: 20", "1: first: 20"))));
+ TimeChangeArchiveSnapshot changed = new TimeChangeArchiveSnapshot(List.of(
+ new TimeChangeArchiveSection("Daily", List.of("Combined total: 99", "1: late: 99"))));
+ data.beginTimeChangeRecovery(transition);
+
+ assertEquals(original, data.prepareTimeChangeArchive(transition, original));
+ assertEquals(original, data.prepareTimeChangeArchive(transition, changed));
+ assertEquals(original, new ServerData(plugin).getTimeChangeArchive(transition));
+ }
+
+ @Test
+ void rewardAndArchiveBoundaryDataShareOneDurableCheckpoint() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("DAY:2026-09-21", "2026-09-21", TimeType.DAY);
+ List targets = List.of(new TimeChangeRewardTarget(
+ "00000000-0000-0000-0000-000000000001", "first", 1, "1", 20));
+ TimeChangeArchiveSnapshot archive = new TimeChangeArchiveSnapshot(List.of(
+ new TimeChangeArchiveSection("Daily", List.of("Combined total: 20", "1: first: 20"))));
+ data.beginTimeChangeRecovery(transition);
+
+ data.prepareTimeChangeSnapshot(transition, targets, archive);
+ data.prepareTimeChangeSnapshot(transition, List.of(), new TimeChangeArchiveSnapshot(List.of()));
+
+ assertEquals(targets, data.getTimeChangeRewardTargets(transition));
+ assertEquals(archive, data.getTimeChangeArchive(transition));
+ }
+
+ @Test
+ void votePartyStateResetIsPreparedBeforeItsEffectReceipt() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK);
+ data.beginTimeChangeRecovery(transition);
+ data.getData().set("VoteParty.Total", 12);
+ data.getData().set("VoteParty.Voted", List.of("player"));
+
+ data.prepareTimeChangeVotePartyReset(transition, "VotePartyWeekReset");
+ data.completeTimeChangeVotePartyReset(transition, "VotePartyWeekReset");
+
+ assertEquals(0, data.getData().getInt("VoteParty.Total"));
+ assertTrue(data.getData().getStringList("VoteParty.Voted").isEmpty());
+ assertTrue(data.hasTimeChangeEffect(transition, "VotePartyWeekReset"));
+ }
+
+ @Test
+ void failedVotePartyStateSaveKeepsTheLogicalResetForLaterVotes() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData = mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(plugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(plugin);
+ TimeChangeTransition transition = transition("WEEK:2026-W38", "2026-W38", TimeType.WEEK);
+ data.beginTimeChangeRecovery(transition);
+ data.getData().set("VoteParty.Total", 12);
+ data.getData().set("VoteParty.Voted", List.of("player"));
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+
+ assertThrows(IllegalStateException.class,
+ () -> data.prepareTimeChangeVotePartyReset(transition, "VotePartyWeekReset"));
+
+ assertEquals(0, data.getData().getInt("VoteParty.Total"));
+ assertTrue(data.getData().getStringList("VoteParty.Voted").isEmpty());
+ assertFalse(data.hasTimeChangeEffect(transition, "VotePartyWeekReset"));
+
+ data.getData().set("VoteParty.Total", 2);
+ data.getData().set("VoteParty.Voted", List.of("new-player"));
+ doNothing().when(coreData).saveData();
+ data.prepareTimeChangeVotePartyReset(transition, "VotePartyWeekReset");
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+ assertThrows(IllegalStateException.class,
+ () -> data.completeTimeChangeVotePartyReset(transition, "VotePartyWeekReset"));
+ assertEquals(2, data.getData().getInt("VoteParty.Total"));
+ assertEquals(List.of("new-player"), data.getData().getStringList("VoteParty.Voted"));
+ assertFalse(data.hasTimeChangeEffect(transition, "VotePartyWeekReset"));
+
+ doNothing().when(coreData).saveData();
+ data.completeTimeChangeVotePartyReset(transition, "VotePartyWeekReset");
+ assertEquals(2, data.getData().getInt("VoteParty.Total"));
+ assertEquals(List.of("new-player"), data.getData().getStringList("VoteParty.Voted"));
+ assertTrue(data.hasTimeChangeEffect(transition, "VotePartyWeekReset"));
+
+ data.getData().set("VotePartyExtraRequired", 7);
+ doThrow(new IllegalStateException("disk unavailable")).when(coreData).saveData();
+ assertThrows(IllegalStateException.class,
+ () -> data.completeTimeChangeVotePartyExtraReset(transition, "VotePartyWeekExtraVotes"));
+ assertEquals(7, data.getData().getInt("VotePartyExtraRequired"));
+ assertFalse(data.hasTimeChangeEffect(transition, "VotePartyWeekExtraVotes"));
+ }
+
+ private TimeChangeTransition transition(String id, String period, TimeType type) {
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ when(transition.getId()).thenReturn(id);
+ when(transition.getPeriodKey()).thenReturn(period);
+ when(transition.getType()).thenReturn(type);
+ return transition;
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java
index b667f52987..481e379462 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java
@@ -1,5 +1,7 @@
package com.bencodez.votingplugin.tests.listeners;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
@@ -10,6 +12,7 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
@@ -25,6 +28,7 @@
import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.listeners.VotiferEvent;
import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue;
+import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
import com.bencodez.votingplugin.votesites.VoteSiteManager;
import com.vexsoftware.votifier.model.Vote;
@@ -148,11 +152,59 @@ public void testUnknownSiteStillAttemptsGenerationByVotifierPath() {
public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() {
doThrow(new RejectedExecutionException("capacity exhausted"))
.when(voteTimer).submit(any(Runnable.class));
- when(overflowQueue.enqueue("Steve", SERVICE_SITE)).thenReturn(true);
+ when(overflowQueue.enqueueDurably(org.mockito.ArgumentMatchers.eq("Steve"),
+ org.mockito.ArgumentMatchers.eq(SERVICE_SITE), any())).thenReturn(true);
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE));
- verify(overflowQueue).enqueue("Steve", SERVICE_SITE);
+ verify(overflowQueue).enqueueDurably(org.mockito.ArgumentMatchers.eq("Steve"),
+ org.mockito.ArgumentMatchers.eq(SERVICE_SITE), any());
verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class));
}
+
+ @Test
+ void timeChangeQueueReceivesTheOriginalVoteId() {
+ TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
+ UUID voteId = UUID.randomUUID();
+ when(plugin.getTimeQueueHandler()).thenReturn(timeQueue);
+ when(plugin.getTimeChecker().isActiveProcessing()).thenReturn(true);
+ when(plugin.getConfigFile().isQueueVotesDuringTimeChange()).thenReturn(true);
+ when(timeQueue.addVoteDurably(voteId, "Steve", SERVICE_SITE)).thenReturn(true);
+
+ listener.processVote(SERVICE_SITE, "Steve", voteId);
+
+ verify(timeQueue).addVoteDurably(voteId, "Steve", SERVICE_SITE);
+ verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class));
+ }
+
+ @Test
+ void queuedVoteIsRetriedWhenTimeQueueHandoffIsNotDurable() {
+ TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
+ UUID voteId = UUID.randomUUID();
+ when(plugin.getTimeQueueHandler()).thenReturn(timeQueue);
+ when(plugin.getTimeChecker().isActiveProcessing()).thenReturn(true);
+ when(plugin.getConfigFile().isQueueVotesDuringTimeChange()).thenReturn(true);
+
+ assertEquals(VotifierVoteOverflowQueue.VoteOutcome.RETRY,
+ listener.processQueuedVoteOutcome(SERVICE_SITE, "Steve", voteId));
+
+ verify(timeQueue).addVoteDurably(voteId, "Steve", SERVICE_SITE);
+ verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class));
+ }
+
+ @Test
+ void queuedVoteIsNotAcknowledgedWhenPostAdmissionProcessingFails() {
+ when(voteSiteManager.getVoteSiteName(false, SERVICE_SITE, "")).thenReturn(SERVICE_SITE);
+ when(voteSiteManager.getVoteSiteName(true, SERVICE_SITE, "")).thenReturn(SERVICE_SITE);
+ doAnswer(invocation -> {
+ PlayerVoteEvent event = invocation.getArgument(0);
+ event.setProcessingFailed(true);
+ event.setReplayUnsafe(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerVoteEvent.class));
+
+ assertFalse(listener.processQueuedVote(SERVICE_SITE, "Steve", UUID.randomUUID()));
+ assertEquals(VotifierVoteOverflowQueue.VoteOutcome.QUARANTINE,
+ listener.processQueuedVoteOutcome(SERVICE_SITE, "Steve", UUID.randomUUID()));
+ }
}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java
index 6c3994cf37..37a49294f6 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java
@@ -20,15 +20,227 @@
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.votingplugin.data.ServerData;
import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue;
class VotifierVoteOverflowQueueTest {
+ @Test
+ void durableEnqueuePublishesSnapshotBeforeReportingSuccess(@TempDir Path dataFolder) throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ java.util.UUID voteId = java.util.UUID.randomUUID();
+ when(plugin.getDataFolder()).thenReturn(dataFolder.toFile());
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("VotifierVoteOverflowQueueTest"));
+ VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user) -> { });
+ try {
+ assertTrue(queue.enqueueDurably("Steve", "example.org", voteId));
+
+ assertTrue(Files.readString(dataFolder.resolve("VotifierVoteQueue.yml")).contains(voteId.toString()));
+ } finally {
+ queue.close();
+ }
+ }
+
+ @Test
+ void successfulVoteRetiresReplayFenceAfterQueueSnapshot(@TempDir Path dataFolder) throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor();
+ CountDownLatch processed = new CountDownLatch(1);
+ java.util.UUID voteId = java.util.UUID.randomUUID();
+ when(plugin.getDataFolder()).thenReturn(dataFolder.toFile());
+ when(plugin.getVoteTimer()).thenReturn(voteTimer);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("VotifierVoteOverflowQueueTest"));
+ VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user, id) -> {
+ processed.countDown();
+ return VotifierVoteOverflowQueue.VoteOutcome.COMPLETE;
+ });
+ try {
+ assertTrue(queue.enqueue("Steve", "example.org", voteId));
+ queue.start();
+
+ assertTrue(processed.await(2, TimeUnit.SECONDS));
+ Path queueFile = dataFolder.resolve("VotifierVoteQueue.yml");
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while ((!Files.exists(queueFile) || Files.readString(queueFile).contains(voteId.toString()))
+ && System.nanoTime() < deadline) Thread.sleep(10L);
+ assertTrue(Files.exists(queueFile));
+ assertTrue(!Files.readString(queueFile).contains(voteId.toString()));
+ deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while (org.mockito.Mockito.mockingDetails(serverData).getInvocations().isEmpty()
+ && System.nanoTime() < deadline) Thread.sleep(10L);
+ verify(serverData).clearVoteReplayUnsafe(voteId);
+ assertEquals(0, queue.size());
+ } finally {
+ queue.close();
+ voteTimer.shutdownNow();
+ }
+ }
+
+ @Test
+ void failedReceiptRetirementSurvivesRestartWithoutReplayingVote(@TempDir Path dataFolder) throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor();
+ AtomicBoolean failRetirement = new AtomicBoolean(true);
+ AtomicInteger retirementAttempts = new AtomicInteger();
+ AtomicInteger processed = new AtomicInteger();
+ java.util.UUID voteId = java.util.UUID.randomUUID();
+ when(plugin.getDataFolder()).thenReturn(dataFolder.toFile());
+ when(plugin.getVoteTimer()).thenReturn(voteTimer);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
+ when(plugin.getLogger()).thenReturn(Logger.getLogger("VotifierVoteOverflowQueueTest"));
+ doAnswer(invocation -> {
+ retirementAttempts.incrementAndGet();
+ if (failRetirement.get()) throw new IllegalStateException("storage unavailable");
+ return null;
+ }).when(serverData).clearVotePartyAccounting(voteId);
+
+ VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user, id) -> {
+ processed.incrementAndGet();
+ return VotifierVoteOverflowQueue.VoteOutcome.COMPLETE;
+ });
+ try {
+ assertTrue(queue.enqueue("Steve", "example.org", voteId));
+ queue.start();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
+ Path file = dataFolder.resolve("VotifierVoteQueue.yml");
+ while ((retirementAttempts.get() == 0 || !Files.exists(file)
+ || !Files.readString(file).contains(voteId.toString())) && System.nanoTime() < deadline) {
+ Thread.sleep(10L);
+ }
+ assertTrue(Files.readString(file).contains(voteId.toString()));
+ assertEquals(1, processed.get());
+ } finally {
+ queue.close();
+ voteTimer.shutdownNow();
+ }
+
+ failRetirement.set(false);
+ ScheduledExecutorService restartedTimer = Executors.newSingleThreadScheduledExecutor();
+ when(plugin.getVoteTimer()).thenReturn(restartedTimer);
+ VotifierVoteOverflowQueue restarted = new VotifierVoteOverflowQueue(plugin, (site, user, id) -> {
+ processed.incrementAndGet();
+ return VotifierVoteOverflowQueue.VoteOutcome.COMPLETE;
+ });
+ try {
+ restarted.start();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
+ Path file = dataFolder.resolve("VotifierVoteQueue.yml");
+ while ((restarted.size() != 0 || Files.readString(file).contains(voteId.toString()))
+ && System.nanoTime() < deadline) Thread.sleep(10L);
+ assertEquals(0, restarted.size());
+ assertTrue(!Files.readString(file).contains(voteId.toString()));
+ assertEquals(1, processed.get());
+ assertTrue(retirementAttempts.get() >= 2);
+ } finally {
+ restarted.close();
+ restartedTimer.shutdownNow();
+ }
+ }
+
+ @Test
+ void failedQueuedAttemptKeepsTheExistingEntry(@TempDir Path dataFolder) throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor();
+ CountDownLatch attempted = new CountDownLatch(1);
+ when(plugin.getDataFolder()).thenReturn(dataFolder.toFile());
+ when(plugin.getVoteTimer()).thenReturn(voteTimer);
+ VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user, voteId) -> {
+ attempted.countDown();
+ return VotifierVoteOverflowQueue.VoteOutcome.RETRY;
+ });
+ try {
+ assertTrue(queue.enqueue("Steve", "example.org", java.util.UUID.randomUUID()));
+ queue.start();
+ assertTrue(attempted.await(2, TimeUnit.SECONDS));
+ assertEquals(1, queue.size());
+ } finally {
+ queue.close();
+ voteTimer.shutdownNow();
+ }
+ }
+
+ @Test
+ void ambiguousAttemptIsDurablyQuarantinedAcrossRestart(@TempDir Path dataFolder) throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor();
+ CountDownLatch attempted = new CountDownLatch(1);
+ when(plugin.getDataFolder()).thenReturn(dataFolder.toFile());
+ when(plugin.getVoteTimer()).thenReturn(voteTimer);
+ VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user, voteId) -> {
+ attempted.countDown();
+ return VotifierVoteOverflowQueue.VoteOutcome.QUARANTINE;
+ });
+ try {
+ assertTrue(queue.enqueue("Steve", "example.org", java.util.UUID.randomUUID()));
+ queue.start();
+ assertTrue(attempted.await(2, TimeUnit.SECONDS));
+ Path file = dataFolder.resolve("VotifierVoteQueue.yml");
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while ((!Files.exists(file) || !Files.readString(file).contains("Quarantined: true"))
+ && System.nanoTime() < deadline) Thread.sleep(10L);
+ assertTrue(Files.readString(file).contains("Quarantined: true"));
+ } finally {
+ queue.close();
+ voteTimer.shutdownNow();
+ }
+
+ ScheduledExecutorService restartedTimer = mock(ScheduledExecutorService.class);
+ when(plugin.getVoteTimer()).thenReturn(restartedTimer);
+ VotifierVoteOverflowQueue restarted = new VotifierVoteOverflowQueue(plugin, (site, user) -> { });
+ try {
+ restarted.start();
+ Thread.sleep(100L);
+ assertEquals(1, restarted.size());
+ verify(restartedTimer, org.mockito.Mockito.never()).submit(any(Runnable.class));
+ } finally {
+ restarted.close();
+ }
+ }
+
+ @Test
+ void directQuarantineReturnsOnlyAfterItsSnapshotIsDurable(@TempDir Path dataFolder) throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
+ when(plugin.getDataFolder()).thenReturn(dataFolder.toFile());
+ when(plugin.getVoteTimer()).thenReturn(mock(ScheduledExecutorService.class));
+ VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user) -> { });
+ java.lang.reflect.Field writeLockField = VotifierVoteOverflowQueue.class
+ .getDeclaredField("persistenceWriteLock");
+ writeLockField.setAccessible(true);
+ Object writeLock = writeLockField.get(queue);
+ java.util.concurrent.ExecutorService caller = Executors.newSingleThreadExecutor();
+ java.util.UUID voteId = java.util.UUID.randomUUID();
+ java.util.concurrent.Future stored;
+ try {
+ synchronized (writeLock) {
+ stored = caller.submit(() -> queue.quarantine("Steve", "example.org", voteId));
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while (queue.size() == 0 && System.nanoTime() < deadline) Thread.onSpinWait();
+ assertEquals(1, queue.size());
+ assertThrows(TimeoutException.class, () -> stored.get(100, TimeUnit.MILLISECONDS));
+ }
+ assertTrue(stored.get(2, TimeUnit.SECONDS));
+ String persisted = Files.readString(dataFolder.resolve("VotifierVoteQueue.yml"));
+ assertTrue(persisted.contains(voteId.toString()));
+ assertTrue(persisted.contains("Quarantined: true"));
+ } finally {
+ caller.shutdownNow();
+ queue.close();
+ }
+ }
+
@Test
void enqueueCannotChangeVersionDuringDurableAdmission(@TempDir Path dataFolder) throws Exception {
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java
index 37ba7a49a9..9fb0981239 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java
@@ -2,31 +2,44 @@
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import static org.mockito.Mockito.reset;
+import java.util.List;
import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
import com.bencodez.advancedcore.api.time.events.DateChangedEvent;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.data.ServerData;
+import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
+import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
class TimeQueueHandlerRejectionTest {
private VotingPluginMain plugin;
@@ -46,6 +59,8 @@ void setUp() {
when(plugin.getServerData()).thenReturn(serverData);
when(plugin.getVoteTimer()).thenReturn(voteTimer);
when(plugin.getLogger()).thenReturn(logger);
+ when(plugin.isEnabled()).thenReturn(true);
+ when(serverData.getTimedVoteRetirements()).thenReturn(List.of());
when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of("0"));
when(serverData.getTimedVoteCacheSection("0")).thenReturn(cachedVote);
when(cachedVote.getString("Name")).thenReturn("Steve");
@@ -73,6 +88,64 @@ void dateChangeRejectionDoesNotEscapeBukkitEventHandler() {
verify(logger, org.mockito.Mockito.atLeastOnce()).warning(anyString());
}
+ @Test
+ void queueAdmissionPreservesTheReceptionVoteId() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ UUID voteId = UUID.randomUUID();
+
+ handler.addVote(voteId, "Alex", "example.org");
+
+ assertEquals(voteId, handler.getTimeChangeQueue().peek().getVoteId());
+ }
+
+ @Test
+ void durableAdmissionPersistsBeforeReportingSuccess() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ UUID voteId = UUID.randomUUID();
+
+ assertTrue(handler.addVoteDurably(voteId, "Alex", "example.org"));
+
+ @SuppressWarnings("unchecked")
+ org.mockito.ArgumentCaptor> persisted = org.mockito.ArgumentCaptor.forClass(List.class);
+ verify(serverData).replaceTimedVoteState(persisted.capture(), org.mockito.ArgumentMatchers.eq(List.of()));
+ assertEquals(voteId, persisted.getValue().getFirst().getVoteId());
+ }
+
+ @Test
+ void processingRetiresEachTimedVoteFromTheDurableSnapshot() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ VoteTimeQueue first = new VoteTimeQueue(UUID.randomUUID(), "Alex", "first.example", 123L);
+ VoteTimeQueue second = new VoteTimeQueue(UUID.randomUUID(), "Steve", "second.example", 124L);
+ handler.getTimeChangeQueue().add(first);
+ handler.getTimeChangeQueue().add(second);
+
+ handler.processQueue();
+
+ org.mockito.InOrder retirement = org.mockito.Mockito.inOrder(serverData);
+ retirement.verify(serverData).replaceTimedVoteState(List.of(second), List.of(first.getVoteId()));
+ retirement.verify(serverData).clearVotePartyAccounting(first.getVoteId());
+ retirement.verify(serverData).replaceTimedVoteState(List.of(second), List.of());
+ retirement.verify(serverData).replaceTimedVoteState(List.of(), List.of(second.getVoteId()));
+ retirement.verify(serverData).clearVotePartyAccounting(second.getVoteId());
+ retirement.verify(serverData).replaceTimedVoteState(List.of(), List.of());
+ verify(serverData, never()).clearTimedVoteCache();
+ }
+
+ @Test
+ void failedDurableAdmissionLeavesTheVoteWithItsSourceOwner() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ doThrow(new IllegalStateException("disk unavailable")).when(serverData).replaceTimedVoteState(any(), any());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+
+ org.junit.jupiter.api.Assertions.assertFalse(
+ handler.addVoteDurably(UUID.randomUUID(), "Alex", "example.org"));
+
+ assertTrue(handler.getTimeChangeQueue().isEmpty());
+ }
+
@Test
void rejectedProcessingSchedulesOneBoundedRetry() {
TimeQueueHandler handler = new TimeQueueHandler(plugin);
@@ -87,4 +160,286 @@ void rejectedProcessingSchedulesOneBoundedRetry() {
org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS));
assertEquals(1, handler.getTimeChangeQueue().size());
}
+
+ @Test
+ void accountingAdmissionFailureRetainsTheSameQueuedVote() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ VoteTimeQueue vote = new VoteTimeQueue(java.util.UUID.randomUUID(), "Alex", "example.org", 123L);
+ VoteTimeQueue following = new VoteTimeQueue(java.util.UUID.randomUUID(), "Steve", "second.example", 124L);
+ handler.getTimeChangeQueue().add(vote);
+ handler.getTimeChangeQueue().add(following);
+ org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager();
+ doAnswer(invocation -> {
+ PlayerVoteEvent event = invocation.getArgument(0);
+ event.setAccountingAdmissionFailed(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerVoteEvent.class));
+
+ handler.processQueue();
+
+ assertEquals(2, handler.getTimeChangeQueue().size());
+ assertEquals(vote, handler.getTimeChangeQueue().peek());
+ @SuppressWarnings("unchecked")
+ org.mockito.ArgumentCaptor> persisted = org.mockito.ArgumentCaptor.forClass(List.class);
+ verify(serverData).replaceTimedVoteState(persisted.capture(), org.mockito.ArgumentMatchers.eq(List.of()));
+ assertEquals(List.of(vote, following), persisted.getValue());
+ }
+
+ @Test
+ void postAdmissionProcessingFailureRetainsTheSameQueuedVote() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L);
+ handler.getTimeChangeQueue().add(vote);
+ org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager();
+ doAnswer(invocation -> {
+ PlayerVoteEvent event = invocation.getArgument(0);
+ event.setProcessingFailed(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerVoteEvent.class));
+
+ handler.processQueue();
+
+ assertEquals(vote, handler.getTimeChangeQueue().peek());
+ verify(serverData).replaceTimedVoteState(List.of(vote), List.of());
+ }
+
+ @Test
+ void shutdownSnapshotIncludesTheVoteCurrentlyBeingProcessed() throws Exception {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ VoteTimeQueue first = new VoteTimeQueue(UUID.randomUUID(), "Alex", "first.example", 123L);
+ VoteTimeQueue second = new VoteTimeQueue(UUID.randomUUID(), "Steve", "second.example", 124L);
+ handler.getTimeChangeQueue().add(first);
+ handler.getTimeChangeQueue().add(second);
+ CountDownLatch processing = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager();
+ doAnswer(invocation -> {
+ processing.countDown();
+ release.await();
+ throw new CancellationException("shutdown");
+ }).when(pluginManager).callEvent(any(PlayerVoteEvent.class));
+ AtomicReference failure = new AtomicReference<>();
+ Thread worker = new Thread(() -> {
+ try {
+ handler.processQueue();
+ } catch (Throwable thrown) {
+ failure.set(thrown);
+ }
+ });
+ worker.start();
+ assertTrue(processing.await(1, TimeUnit.SECONDS));
+
+ handler.save();
+
+ verify(serverData).replaceTimedVoteState(List.of(first, second), List.of());
+ release.countDown();
+ worker.join(1000L);
+ assertFalse(worker.isAlive());
+ assertTrue(failure.get() instanceof CancellationException);
+ }
+
+ @Test
+ void ambiguousPostEffectFailureIsQuarantinedInsteadOfReplayed() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L);
+ handler.getTimeChangeQueue().add(vote);
+ org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager();
+ doAnswer(invocation -> {
+ PlayerVoteEvent event = invocation.getArgument(0);
+ event.setProcessingFailed(true);
+ event.setReplayUnsafe(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerVoteEvent.class));
+
+ handler.processQueue();
+
+ assertTrue(handler.getTimeChangeQueue().isEmpty());
+ verify(serverData).quarantineTimedVote(vote);
+ verify(plugin.getBukkitScheduler(), never()).runTaskLaterAsynchronously(
+ org.mockito.ArgumentMatchers.eq(plugin), any(Runnable.class), anyLong());
+ }
+
+ @Test
+ void failedAmbiguousQuarantineReturnsVoteToDurableRetryQueue() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ clearInvocations(plugin.getBukkitScheduler());
+ VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L);
+ handler.getTimeChangeQueue().add(vote);
+ doThrow(new IllegalStateException("disk unavailable")).when(serverData).quarantineTimedVote(vote);
+ org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager();
+ doAnswer(invocation -> {
+ PlayerVoteEvent event = invocation.getArgument(0);
+ event.setProcessingFailed(true);
+ event.setReplayUnsafe(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerVoteEvent.class));
+
+ assertDoesNotThrow(handler::processQueue);
+
+ assertEquals(vote, handler.getTimeChangeQueue().peek());
+ verify(serverData).replaceTimedVoteState(List.of(vote), List.of());
+ verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously(
+ org.mockito.ArgumentMatchers.eq(plugin), any(Runnable.class), anyLong());
+ }
+
+ @Test
+ void timedVoteSnapshotPersistsStableIdsWithOneSave() {
+ VotingPluginMain snapshotPlugin = mock(VotingPluginMain.class);
+ com.bencodez.advancedcore.data.ServerData coreData =
+ mock(com.bencodez.advancedcore.data.ServerData.class);
+ YamlConfiguration yaml = new YamlConfiguration();
+ when(snapshotPlugin.getServerDataFile()).thenReturn(coreData);
+ when(coreData.getData()).thenReturn(yaml);
+ ServerData data = new ServerData(snapshotPlugin);
+ UUID firstId = UUID.randomUUID();
+ UUID secondId = UUID.randomUUID();
+
+ data.replaceTimedVoteCache(List.of(
+ new VoteTimeQueue(firstId, "Alex", "first.example", 123L),
+ new VoteTimeQueue(secondId, "Steve", "second.example", 124L)));
+
+ assertEquals(firstId.toString(), yaml.getString("VotingPlugin.TimedVoteCache.0.VoteId"));
+ assertEquals(secondId.toString(), yaml.getString("VotingPlugin.TimedVoteCache.1.VoteId"));
+ assertEquals("Alex", yaml.getString("VotingPlugin.TimedVoteCache.0.Name"));
+ verify(coreData).saveData();
+ }
+
+ @Test
+ void processingRetryWaitsForFailedSnapshotPersistence() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ reset(voteTimer);
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ reset(voteTimer);
+ VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L);
+ handler.getTimeChangeQueue().add(vote);
+ doThrow(new IllegalStateException("disk unavailable")).doNothing()
+ .when(serverData).replaceTimedVoteState(any(), any());
+ org.bukkit.plugin.PluginManager pluginManager = plugin.getServer().getPluginManager();
+ doAnswer(invocation -> {
+ PlayerVoteEvent event = invocation.getArgument(0);
+ event.setAccountingAdmissionFailed(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerVoteEvent.class));
+
+ handler.processQueue();
+
+ org.mockito.ArgumentCaptor retry = org.mockito.ArgumentCaptor.forClass(Runnable.class);
+ verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously(
+ org.mockito.ArgumentMatchers.eq(plugin), retry.capture(), org.mockito.ArgumentMatchers.eq(20L));
+ verify(voteTimer, never()).schedule(any(Runnable.class), org.mockito.ArgumentMatchers.eq(0L),
+ org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS));
+
+ retry.getValue().run();
+
+ org.mockito.InOrder persistedBeforeProcessing = org.mockito.Mockito.inOrder(serverData, voteTimer);
+ persistedBeforeProcessing.verify(serverData, org.mockito.Mockito.times(2)).replaceTimedVoteState(any(), any());
+ persistedBeforeProcessing.verify(voteTimer).schedule(any(Runnable.class),
+ org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS));
+ }
+
+ @Test
+ void failedCompletionSnapshotKeepsTheInFlightVoteForShutdownRecovery() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Alex", "example.org", 123L);
+ handler.getTimeChangeQueue().add(vote);
+ doThrow(new IllegalStateException("disk unavailable")).doNothing()
+ .when(serverData).replaceTimedVoteState(any(), any());
+
+ handler.processQueue();
+ handler.save();
+
+ org.mockito.InOrder persistence = org.mockito.Mockito.inOrder(serverData);
+ persistence.verify(serverData).replaceTimedVoteState(List.of(), List.of(vote.getVoteId()));
+ persistence.verify(serverData).replaceTimedVoteState(List.of(vote), List.of());
+ verify(serverData, never()).clearVotePartyAccounting(vote.getVoteId());
+ }
+
+ @Test
+ void retirementFailureDoesNotStopLaterTimedVotes() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ clearInvocations(plugin.getBukkitScheduler());
+ VoteTimeQueue first = new VoteTimeQueue(UUID.randomUUID(), "Alex", "first.example", 123L);
+ VoteTimeQueue second = new VoteTimeQueue(UUID.randomUUID(), "Steve", "second.example", 124L);
+ handler.getTimeChangeQueue().add(first);
+ handler.getTimeChangeQueue().add(second);
+ doThrow(new IllegalStateException("disk unavailable"))
+ .doNothing()
+ .when(serverData).clearVotePartyAccounting(first.getVoteId());
+
+ handler.processQueue();
+
+ assertTrue(handler.getTimeChangeQueue().isEmpty());
+ verify(plugin.getServer().getPluginManager(), org.mockito.Mockito.times(2))
+ .callEvent(any(PlayerVoteEvent.class));
+ verify(serverData).clearVotePartyAccounting(second.getVoteId());
+ verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously(
+ org.mockito.ArgumentMatchers.eq(plugin), any(Runnable.class), anyLong());
+ }
+
+ @Test
+ void failedRetirementSurvivesRestartAndIsRemovedAfterRetry() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ UUID voteId = UUID.randomUUID();
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ clearInvocations(plugin.getBukkitScheduler(), serverData);
+ handler.getTimeChangeQueue().add(new VoteTimeQueue(voteId, "Alex", "example.org", 123L));
+ doThrow(new IllegalStateException("disk unavailable")).when(serverData).clearVotePartyAccounting(voteId);
+
+ handler.processQueue();
+
+ verify(serverData).replaceTimedVoteState(List.of(), List.of(voteId));
+ org.mockito.ArgumentCaptor firstRetry = org.mockito.ArgumentCaptor.forClass(Runnable.class);
+ verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously(
+ org.mockito.ArgumentMatchers.eq(plugin), firstRetry.capture(), anyLong());
+
+ reset(serverData);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ when(serverData.getTimedVoteRetirements()).thenReturn(List.of(voteId));
+ clearInvocations(plugin.getBukkitScheduler());
+ TimeQueueHandler recovered = new TimeQueueHandler(plugin);
+ org.mockito.ArgumentCaptor recoveredRetry = org.mockito.ArgumentCaptor.forClass(Runnable.class);
+ verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously(
+ org.mockito.ArgumentMatchers.eq(plugin), recoveredRetry.capture(), anyLong());
+
+ recoveredRetry.getValue().run();
+
+ verify(serverData).clearVotePartyAccounting(voteId);
+ verify(serverData).replaceTimedVoteState(List.of(), List.of());
+ assertTrue(recovered.getTimeChangeQueue().isEmpty());
+ }
+
+ @Test
+ void disabledPluginDoesNotRegisterABukkitRetry() {
+ when(plugin.isEnabled()).thenReturn(false);
+
+ new TimeQueueHandler(plugin);
+
+ verify(plugin.getBukkitScheduler(), never()).runTaskLaterAsynchronously(
+ org.mockito.ArgumentMatchers.eq(plugin), any(Runnable.class), anyLong());
+ }
+
+ @Test
+ void cancelledDurableTransitionFailsItsLeaseWithoutScheduling() {
+ when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
+ TimeQueueHandler handler = new TimeQueueHandler(plugin);
+ reset(voteTimer);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ TimeChangeTransition.Lease lease = mock(TimeChangeTransition.Lease.class);
+ when(transition.retain()).thenReturn(lease);
+ when(transition.isCancellationRequested()).thenReturn(true);
+
+ handler.postTimeChange(new DateChangedEvent(com.bencodez.advancedcore.api.time.TimeType.DAY, transition));
+
+ verify(voteTimer, never()).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
+ verify(lease).fail(any(java.util.concurrent.CancellationException.class));
+ verify(lease, never()).complete();
+ }
}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java
index 1991dfedd1..b415ef3de1 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java
@@ -8,8 +8,10 @@
import static org.mockito.Mockito.mock;
import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
@@ -41,4 +43,31 @@ void admittedTasksAreReported() {
assertTrue(VoteTaskAdmission.trySubmit(executor, () -> { }));
assertTrue(VoteTaskAdmission.trySchedule(executor, () -> { }, 5, TimeUnit.SECONDS));
}
+
+ @Test
+ void admittedTaskOwnsVoteLaneOnlyWhileItRuns() throws Exception {
+ ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
+ AtomicBoolean ownedInside = new AtomicBoolean();
+ try {
+ assertFalse(VoteTaskAdmission.isVoteTask());
+ assertTrue(VoteTaskAdmission.trySubmit(executor,
+ () -> ownedInside.set(VoteTaskAdmission.isVoteTask())));
+ executor.shutdown();
+ assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
+ assertTrue(ownedInside.get());
+ assertFalse(VoteTaskAdmission.isVoteTask());
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void externallyScheduledOwnedTaskUsesTheSameScopedMarker() {
+ AtomicBoolean ownedInside = new AtomicBoolean();
+
+ VoteTaskAdmission.ownedTask(() -> ownedInside.set(VoteTaskAdmission.isVoteTask())).run();
+
+ assertTrue(ownedInside.get());
+ assertFalse(VoteTaskAdmission.isVoteTask());
+ }
}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java
index 2fd872f13f..87c403f7af 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterLoaderTest.java
@@ -2,11 +2,25 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import java.time.YearMonth;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.UUID;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
+import com.bencodez.advancedcore.api.user.UserManager;
+import com.bencodez.simpleapi.sql.Column;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.config.Config;
+import com.bencodez.votingplugin.user.VotingPluginUser;
+
public class TopVoterLoaderTest {
@Test
@@ -22,4 +36,119 @@ public void rejectsMalformedMonthColumns() {
assertNull(TopVoterLoader.parseMonthColumn("Other-JANUARY-2025"));
assertNull(TopVoterLoader.parseMonthColumn(null));
}
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void boundaryRankingUsesCopiedTotalInsteadOfCurrentPeriodTotal() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ UserManager userManager = mock(UserManager.class);
+ com.bencodez.votingplugin.user.UserManager votingUserManager =
+ mock(com.bencodez.votingplugin.user.UserManager.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ Config config = mock(Config.class);
+ UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ TopVoterPlayer player = new TopVoterPlayer(uuid, "first", 1L);
+ when(plugin.getUserManager()).thenReturn(userManager);
+ when(plugin.getVotingPluginUserManager()).thenReturn(votingUserManager);
+ when(plugin.getConfigFile()).thenReturn(config);
+ when(votingUserManager.getVotingPluginUser(uuid, false)).thenReturn(user);
+ when(user.getLastDailyTotal()).thenReturn(20);
+ when(user.getTotal(TopVoter.Daily)).thenReturn(99);
+ when(user.getTopVoterPlayer()).thenReturn(player);
+ doAnswer(invocation -> {
+ BiConsumer> perUser = invocation.getArgument(0);
+ Consumer finished = invocation.getArgument(1);
+ perUser.accept(uuid, new ArrayList<>());
+ finished.accept(1);
+ return null;
+ }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any());
+
+ assertEquals(20, new TopVoterLoader(plugin).getBoundaryTopVoters(TopVoter.Daily).get(player));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void boundaryRankingKeepsOnlyTheConfiguredBestPlayers() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ UserManager userManager = mock(UserManager.class);
+ com.bencodez.votingplugin.user.UserManager votingUserManager =
+ mock(com.bencodez.votingplugin.user.UserManager.class);
+ Config config = mock(Config.class);
+ when(plugin.getUserManager()).thenReturn(userManager);
+ when(plugin.getVotingPluginUserManager()).thenReturn(votingUserManager);
+ when(plugin.getConfigFile()).thenReturn(config);
+ when(config.getMaxiumNumberOfTopVotersToLoad()).thenReturn(2);
+ UUID first = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ UUID second = UUID.fromString("00000000-0000-0000-0000-000000000002");
+ UUID third = UUID.fromString("00000000-0000-0000-0000-000000000003");
+ VotingPluginUser firstUser = boundaryUser(first, "first", 10, votingUserManager);
+ VotingPluginUser secondUser = boundaryUser(second, "second", 30, votingUserManager);
+ VotingPluginUser thirdUser = boundaryUser(third, "third", 20, votingUserManager);
+ doAnswer(invocation -> {
+ BiConsumer> perUser = invocation.getArgument(0);
+ Consumer finished = invocation.getArgument(1);
+ perUser.accept(first, new ArrayList<>());
+ perUser.accept(second, new ArrayList<>());
+ perUser.accept(third, new ArrayList<>());
+ finished.accept(3);
+ return null;
+ }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any());
+
+ TopVoterLoader.BoundaryRanking boundary =
+ new TopVoterLoader(plugin).getBoundaryRanking(TopVoter.Daily, null);
+ LinkedHashMap ranking = boundary.players();
+
+ assertEquals(java.util.List.of(secondUser.getTopVoterPlayer(), thirdUser.getTopVoterPlayer()),
+ new ArrayList<>(ranking.keySet()));
+ assertEquals(java.util.List.of(30, 20), new ArrayList<>(ranking.values()));
+ assertEquals(60, boundary.combinedTotal());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void boundaryRankingExcludesBannedBlacklistedAndIgnoredPlayers() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ UserManager userManager = mock(UserManager.class);
+ com.bencodez.votingplugin.user.UserManager votingUserManager =
+ mock(com.bencodez.votingplugin.user.UserManager.class);
+ Config config = mock(Config.class);
+ when(plugin.getUserManager()).thenReturn(userManager);
+ when(plugin.getVotingPluginUserManager()).thenReturn(votingUserManager);
+ when(plugin.getConfigFile()).thenReturn(config);
+ UUID banned = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ UUID blacklisted = UUID.fromString("00000000-0000-0000-0000-000000000002");
+ UUID ignored = UUID.fromString("00000000-0000-0000-0000-000000000003");
+ VotingPluginUser bannedUser = boundaryUser(banned, "banned", 30, votingUserManager);
+ VotingPluginUser blacklistedUser = boundaryUser(blacklisted, "blacklisted", 20, votingUserManager);
+ VotingPluginUser ignoredUser = boundaryUser(ignored, "ignored", 10, votingUserManager);
+ when(bannedUser.isBanned()).thenReturn(true);
+ when(blacklistedUser.getPlayerName()).thenReturn("blacklisted");
+ when(ignoredUser.isTopVoterIgnore()).thenReturn(true);
+ doAnswer(invocation -> {
+ BiConsumer> perUser = invocation.getArgument(0);
+ perUser.accept(banned, new ArrayList<>());
+ perUser.accept(blacklisted, new ArrayList<>());
+ perUser.accept(ignored, new ArrayList<>());
+ return null;
+ }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any());
+
+ TopVoterLoader.BoundaryRanking ranking = new TopVoterLoader(plugin).getBoundaryRanking(
+ TopVoter.Daily, null, true, java.util.List.of("blacklisted"));
+
+ assertEquals(0, ranking.players().size());
+ assertEquals(60, ranking.combinedTotal());
+ }
+
+ private VotingPluginUser boundaryUser(UUID uuid, String name, int total,
+ com.bencodez.votingplugin.user.UserManager users) {
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TopVoterPlayer player = new TopVoterPlayer(uuid, name, (long) total);
+ when(users.getVotingPluginUser(uuid, false)).thenReturn(user);
+ when(user.getLastDailyTotal()).thenReturn(total);
+ when(user.getTopVoterPlayer()).thenReturn(player);
+ return user;
+ }
}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java
new file mode 100644
index 0000000000..03d8ddcb8b
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/topvoter/TopVoterTimeChangeRecoveryTest.java
@@ -0,0 +1,640 @@
+package com.bencodez.votingplugin.topvoter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.File;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.ArrayList;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
+import com.bencodez.advancedcore.api.time.TimeType;
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
+import com.bencodez.simpleapi.sql.Column;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.data.ServerData;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardTarget;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeRewardState;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeTopPolicy;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeUserProgress;
+import com.bencodez.votingplugin.data.ServerData.TimeChangeUserPolicy;
+import com.bencodez.votingplugin.specialrewards.SpecialRewards;
+import com.bencodez.votingplugin.user.VotingPluginUser;
+import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService;
+
+class TopVoterTimeChangeRecoveryTest {
+ @Test
+ void retryDoesNotCreateADailyStreakBoundaryAfterTheCapturedDecisionDisabledIt() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true);
+ when(serverData.getTimeChangeUserPolicy(transition)).thenReturn(
+ new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false, false));
+ when(transition.getId()).thenReturn("DAY:2026-09-21");
+ try (MockedStatic reset = org.mockito.Mockito.mockStatic(TimeChangeTotalReset.class)) {
+ reset.when(() -> TimeChangeTotalReset.copyBoundary(plugin, "DailyTotal", "LastDailyTotal",
+ "time-copy:DAY:2026-09-21")).thenReturn(true);
+
+ new TopVoterHandler(plugin).copyTotalBoundary(TopVoter.Daily, transition);
+
+ reset.verify(() -> TimeChangeTotalReset.copyBoundary(plugin, "DailyTotal", "LastDailyTotal",
+ "time-copy:DAY:2026-09-21"));
+ reset.verifyNoMoreInteractions();
+ }
+ }
+
+ @Test
+ void retryUsesPersistedAbsoluteTargetAndDoesNotDuplicateCompletedStreakReward() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ SpecialRewards specialRewards = mock(SpecialRewards.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getSpecialRewards()).thenReturn(specialRewards);
+ when(serverData.prepareTimeChangeUserStreak(transition, uuid, 9, true))
+ .thenReturn(new TimeChangeUserProgress(uuid, 8, true, true));
+ when(user.getWeekVoteStreak()).thenReturn(8);
+
+ new TopVoterHandler(plugin).applyRecoverableStreak(user, transition, uuid, TopVoter.Weekly, 9, true);
+
+ verify(user, never()).setWeekVoteStreak(org.mockito.ArgumentMatchers.anyInt());
+ verify(specialRewards, never()).checkVoteStreak(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.eq(user), org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyBoolean());
+ verify(serverData, never()).completeTimeChangeUserStreakReward(transition, uuid);
+ }
+
+ @Test
+ void ambiguousStreakRewardClaimStopsAutomaticReplay() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ SpecialRewards specialRewards = mock(SpecialRewards.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getSpecialRewards()).thenReturn(specialRewards);
+ when(serverData.prepareTimeChangeUserStreak(transition, uuid, 9, true))
+ .thenReturn(new TimeChangeUserProgress(uuid, 8, true, false));
+ when(serverData.getTimeChangeUserStreakRewardState(transition, uuid))
+ .thenReturn(TimeChangeRewardState.CLAIMED);
+ when(user.getWeekVoteStreak()).thenReturn(8);
+
+ assertThrows(IllegalStateException.class,
+ () -> new TopVoterHandler(plugin).applyRecoverableStreak(
+ user, transition, uuid, TopVoter.Weekly, 9, true));
+
+ verify(specialRewards, never()).checkVoteStreak(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.eq(user), org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyBoolean());
+ }
+
+ @Test
+ void userRecoveryProcessesRowsDuringTheBoundedStorageWalk() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ com.bencodez.advancedcore.api.user.UserManager userManager =
+ mock(com.bencodez.advancedcore.api.user.UserManager.class);
+ com.bencodez.votingplugin.user.UserManager votingUsers =
+ mock(com.bencodez.votingplugin.user.UserManager.class);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ when(plugin.getUserManager()).thenReturn(userManager);
+ when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true);
+ when(serverData.getTimeChangeUserPolicy(transition)).thenReturn(
+ new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false, false));
+ when(serverData.getTimeChangeCursor(transition)).thenReturn("");
+ when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user);
+ doAnswer(invocation -> {
+ java.util.function.BiConsumer> perUser = invocation.getArgument(0);
+ Consumer finished = invocation.getArgument(1);
+ perUser.accept(uuid, new ArrayList<>());
+ verify(serverData).completeTimeChangeUser(transition, uuid.toString());
+ finished.accept(1);
+ return null;
+ }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any());
+
+ new TopVoterHandler(plugin).processRecoverableUsers(TopVoter.Daily, transition);
+ }
+
+ @Test
+ void userRecoveryFlushesUserChangesBeforeAdvancingTheCursor() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ com.bencodez.advancedcore.api.user.UserManager userManager =
+ mock(com.bencodez.advancedcore.api.user.UserManager.class);
+ com.bencodez.votingplugin.user.UserManager votingUsers =
+ mock(com.bencodez.votingplugin.user.UserManager.class);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ UserDataCache cache = mock(UserDataCache.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ UUID firstUuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ UUID secondUuid = UUID.fromString("00000000-0000-0000-0000-000000000002");
+ when(plugin.getUserManager()).thenReturn(userManager);
+ when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true);
+ when(serverData.getTimeChangeUserPolicy(transition)).thenReturn(
+ new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false, false));
+ when(serverData.getTimeChangeCursor(transition)).thenReturn("");
+ when(votingUsers.getVotingPluginUser(org.mockito.ArgumentMatchers.any(UUID.class),
+ org.mockito.ArgumentMatchers.eq(false))).thenReturn(user);
+ when(user.getCache()).thenReturn(cache);
+ doAnswer(invocation -> {
+ java.util.function.BiConsumer> perUser = invocation.getArgument(0);
+ Consumer finished = invocation.getArgument(1);
+ perUser.accept(firstUuid, new ArrayList<>());
+ perUser.accept(secondUuid, new ArrayList<>());
+ finished.accept(2);
+ return null;
+ }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any());
+
+ new TopVoterHandler(plugin).processRecoverableUsers(TopVoter.Daily, transition);
+
+ org.mockito.InOrder order = inOrder(cache, serverData);
+ order.verify(cache).flushChangesAndRun(org.mockito.ArgumentMatchers.any(Runnable.class));
+ order.verify(serverData).completeTimeChangeUser(transition, firstUuid.toString());
+ order.verify(cache).flushChangesAndRun(org.mockito.ArgumentMatchers.any(Runnable.class));
+ order.verify(serverData).completeTimeChangeUser(transition, secondUuid.toString());
+ }
+
+ @Test
+ void failedUserFlushDoesNotAdvanceTheRecoveryCursor() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ com.bencodez.advancedcore.api.user.UserManager userManager =
+ mock(com.bencodez.advancedcore.api.user.UserManager.class);
+ com.bencodez.votingplugin.user.UserManager votingUsers =
+ mock(com.bencodez.votingplugin.user.UserManager.class);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ UserDataCache cache = mock(UserDataCache.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ when(plugin.getUserManager()).thenReturn(userManager);
+ when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true);
+ when(serverData.getTimeChangeUserPolicy(transition)).thenReturn(
+ new TimeChangeUserPolicy(false, true, false, false, 0, 0, 0, false, false, false));
+ when(serverData.getTimeChangeCursor(transition)).thenReturn("");
+ when(votingUsers.getVotingPluginUser(uuid, false)).thenReturn(user);
+ when(user.getCache()).thenReturn(cache);
+ doAnswer(invocation -> {
+ java.util.function.BiConsumer> perUser = invocation.getArgument(0);
+ perUser.accept(uuid, new ArrayList<>());
+ return null;
+ }).when(userManager).forEachUserKeys(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any());
+ doAnswer(invocation -> { throw new IllegalStateException("write failed"); })
+ .when(cache).flushChangesAndRun(org.mockito.ArgumentMatchers.any(Runnable.class));
+
+ assertThrows(IllegalStateException.class,
+ () -> new TopVoterHandler(plugin).processRecoverableUsers(TopVoter.Daily, transition));
+
+ verify(serverData, never()).completeTimeChangeUser(transition, uuid.toString());
+ verify(user).clearTempCache();
+ }
+
+ @Test
+ void weeklyStreakUsesBoundaryTotalInsteadOfANewWeekVote() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true);
+ when(user.getLastWeeklyTotal()).thenReturn(0);
+ when(user.getTotal(TopVoter.Weekly)).thenReturn(1);
+ when(user.getWeekVoteStreak()).thenReturn(4);
+ when(serverData.prepareTimeChangeUserStreak(transition, uuid, 0, false))
+ .thenReturn(new TimeChangeUserProgress(uuid, 0, false, false));
+
+ new TopVoterHandler(plugin).processWeeklyUser(user, transition, uuid);
+
+ verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 0, false);
+ verify(user).setWeekVoteStreak(0);
+ verify(user, never()).hasPercentageTotal(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.anyInt());
+ }
+
+ @Test
+ void weeklyRecoveryUsesThePolicyCapturedBeforeConfigurationChanged() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ TimeChangeUserPolicy captured = new TimeChangeUserPolicy(true, true, false, false, 0, 0, 0, false, false, false);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(false);
+ when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(false);
+ when(user.getLastWeeklyTotal()).thenReturn(4);
+ when(user.getWeekVoteStreak()).thenReturn(2);
+ when(user.getHighestWeeklyTotal()).thenReturn(1);
+ when(serverData.prepareTimeChangeUserStreak(transition, uuid, 3, true))
+ .thenReturn(new TimeChangeUserProgress(uuid, 3, true, true));
+
+ new TopVoterHandler(plugin).processWeeklyUser(user, transition, uuid, captured);
+
+ verify(user).setWeekVoteStreak(3);
+ verify(user).setHighestWeeklyTotal(4);
+ }
+
+ @Test
+ void delayedDailyRecoveryPreservesAStreakStartedAfterTheBoundary() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ long oldUpdate = LocalDateTime.of(2026, 9, 19, 12, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ long newUpdate = LocalDateTime.of(2026, 9, 21, 12, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true);
+ when(transition.getPeriodKey()).thenReturn("2026-09-21");
+ when(user.getLastDayVoteStreak()).thenReturn(4);
+ when(user.getLastDayVoteStreakLastUpdate()).thenReturn(oldUpdate);
+ when(user.getDayVoteStreakLastUpdate()).thenReturn(newUpdate);
+ when(user.getDayVoteStreak()).thenReturn(5);
+ when(serverData.prepareTimeChangeUserStreak(transition, uuid, 1, false))
+ .thenReturn(new TimeChangeUserProgress(uuid, 1, false, false));
+
+ new TopVoterHandler(plugin).processDailyUser(user, transition, uuid);
+
+ verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 1, false);
+ verify(user).setDayVoteStreak(1);
+ }
+
+ @Test
+ void copiedDailyBoundaryStillResetsStreakAfterSettingIsDisabled() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ long oldUpdate = LocalDateTime.of(2026, 9, 19, 12, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(false);
+ when(transition.getPeriodKey()).thenReturn("2026-09-21");
+ when(user.getLastDayVoteStreak()).thenReturn(4);
+ when(user.getLastDayVoteStreakLastUpdate()).thenReturn(oldUpdate);
+ when(user.getDayVoteStreakLastUpdate()).thenReturn(oldUpdate);
+ when(user.getDayVoteStreak()).thenReturn(4);
+ when(serverData.prepareTimeChangeUserStreak(transition, uuid, 0, false))
+ .thenReturn(new TimeChangeUserProgress(uuid, 0, false, false));
+
+ new TopVoterHandler(plugin).processDailyUser(user, transition, uuid, true);
+
+ verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 0, false);
+ verify(user).setDayVoteStreak(0);
+ }
+
+ @Test
+ void dailyRecoveryClearsAnIneligibleBoundaryStreakWithoutANewVote() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ long oldUpdate = LocalDateTime.of(2026, 9, 19, 12, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true);
+ when(transition.getPeriodKey()).thenReturn("2026-09-21");
+ when(user.getLastDayVoteStreak()).thenReturn(4);
+ when(user.getLastDayVoteStreakLastUpdate()).thenReturn(oldUpdate);
+ when(user.getDayVoteStreakLastUpdate()).thenReturn(oldUpdate);
+ when(user.getDayVoteStreak()).thenReturn(4);
+ when(serverData.prepareTimeChangeUserStreak(transition, uuid, 0, false))
+ .thenReturn(new TimeChangeUserProgress(uuid, 0, false, false));
+
+ new TopVoterHandler(plugin).processDailyUser(user, transition, uuid);
+
+ verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 0, false);
+ verify(user).setDayVoteStreak(0);
+ }
+
+ @Test
+ void recoveredHighestTotalsUseCopiedDailyAndWeeklyBoundaries() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true);
+ when(user.getLastDailyTotal()).thenReturn(5);
+ when(user.getTotal(TopVoter.Daily)).thenReturn(8);
+ when(user.getHighestDailyTotal()).thenReturn(2);
+ when(user.getLastWeeklyTotal()).thenReturn(6);
+ when(user.getTotal(TopVoter.Weekly)).thenReturn(9);
+ when(user.getHighestWeeklyTotal()).thenReturn(3);
+
+ TopVoterHandler handler = new TopVoterHandler(plugin);
+ handler.processDailyUser(user, transition, "player");
+ handler.processWeeklyUser(user, transition, "player");
+
+ verify(user).setHighestDailyTotal(5);
+ verify(user).setHighestWeeklyTotal(6);
+ verify(user, never()).setHighestDailyTotal(8);
+ verify(user, never()).setHighestWeeklyTotal(9);
+ }
+
+ @Test
+ void monthlyRecoveryUsesCopiedBoundaryWhenUndatedTotalsArePrimary() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ LocalDateTime previousMonth = LocalDateTime.of(2026, 8, 15, 0, 0);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getConfigFile().isUseVoteStreaks()).thenReturn(true);
+ when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true);
+ when(plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()).thenReturn(false);
+ when(user.getLastMonthTotal()).thenReturn(0);
+ when(user.getTotal(TopVoter.Monthly, previousMonth)).thenReturn(7);
+ when(user.getMonthVoteStreak()).thenReturn(4);
+ when(serverData.prepareTimeChangeUserStreak(transition, uuid, 0, false))
+ .thenReturn(new TimeChangeUserProgress(uuid, 0, false, false));
+
+ new TopVoterHandler(plugin).processMonthlyUser(user, previousMonth, transition, uuid);
+
+ verify(serverData).prepareTimeChangeUserStreak(transition, uuid, 0, false);
+ verify(user).setMonthVoteStreak(0);
+ verify(user, never()).setHighestMonthlyTotal(7);
+ verify(user, never()).hasPercentageTotal(org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.anyInt());
+ }
+
+ @Test
+ void monthlyRecoveryUsesDatedBoundaryWhenDateTotalsArePrimary() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ LocalDateTime previousMonth = LocalDateTime.of(2026, 8, 15, 0, 0);
+ when(plugin.getConfigFile().isUseHighestTotals()).thenReturn(true);
+ when(plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()).thenReturn(true);
+ when(user.getTotal(TopVoter.Monthly, previousMonth)).thenReturn(5);
+ when(user.getLastMonthTotal()).thenReturn(9);
+ when(user.getHighestMonthlyTotal()).thenReturn(2);
+
+ new TopVoterHandler(plugin).processMonthlyUser(user, previousMonth, transition, "player");
+
+ verify(user).setHighestMonthlyTotal(5);
+ verify(user, never()).setHighestMonthlyTotal(9);
+ }
+
+ @Test
+ void dailyRecoveryUsesTheDayBeforeTheStableTransitionDate() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ when(transition.getPeriodKey()).thenReturn("2026-09-21");
+
+ assertEquals(LocalDateTime.of(2026, 9, 20, 0, 0),
+ new TopVoterHandler(plugin).previousDayTime(transition));
+ }
+
+ @Test
+ void rewardSnapshotFixesRecipientsPlacesAndVoteTotalsAtThePeriodBoundary() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ TopVoterPlayer first = new TopVoterPlayer(
+ UUID.fromString("00000000-0000-0000-0000-000000000001"), "first", 1L);
+ TopVoterPlayer second = new TopVoterPlayer(
+ UUID.fromString("00000000-0000-0000-0000-000000000002"), "second", 2L);
+ LinkedHashMap ranking = new LinkedHashMap<>();
+ ranking.put(first, 20);
+ ranking.put(second, 20);
+ when(plugin.getServerData().getTimeChangeTopPolicy(transition)).thenReturn(
+ new TimeChangeTopPolicy(true, true, false, false, List.of("1"), List.of()));
+ TopVoterHandler handler = spy(new TopVoterHandler(plugin));
+ doReturn(ranking).when(handler).boundaryTopVotersFor(TopVoter.Daily, transition);
+
+ List targets = handler.buildTopRewardSnapshot(TopVoter.Daily, transition);
+
+ assertEquals(List.of(
+ new TimeChangeRewardTarget(first.getUuid().toString(), "first", 1, "1", 20),
+ new TimeChangeRewardTarget(second.getUuid().toString(), "second", 1, "1", 20)), targets);
+ }
+
+ @Test
+ void ambiguousTopRewardClaimStopsAutomaticReplay() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ TimeChangeRewardTarget target = new TimeChangeRewardTarget(uuid, "first", 1, "1", 20);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(serverData.getTimeChangeRewardTargets(transition)).thenReturn(List.of(target));
+ when(serverData.getTimeChangeRewardState(transition, uuid)).thenReturn(TimeChangeRewardState.CLAIMED);
+
+ assertThrows(IllegalStateException.class,
+ () -> new TopVoterHandler(plugin).processRecoverableTopRewards(TopVoter.Daily, transition));
+
+ verify(plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(
+ org.mockito.ArgumentMatchers.any(UUID.class), org.mockito.ArgumentMatchers.anyString());
+ }
+
+ @Test
+ void persistedTopRewardTargetIsNotReevaluatedAgainstCurrentIgnoreState() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ TimeChangeRewardTarget target = new TimeChangeRewardTarget(uuid, "first", 1, "1", 20);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(serverData.getTimeChangeRewardTargets(transition)).thenReturn(List.of(target));
+ when(serverData.getTimeChangeRewardState(transition, uuid)).thenReturn(TimeChangeRewardState.UNCLAIMED);
+ when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuid), "first"))
+ .thenReturn(user);
+ when(user.isTopVoterIgnore()).thenReturn(true);
+
+ new TopVoterHandler(plugin).processRecoverableTopRewards(TopVoter.Daily, transition);
+
+ verify(serverData).claimTimeChangeReward(transition, uuid);
+ verify(user).giveDailyTopVoterAward(1, "1", 20);
+ verify(serverData).completeTimeChangeReward(transition, uuid);
+ }
+
+ @Test
+ void archiveRetryUsesOneStableTransitionFile() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ when(transition.getPeriodKey()).thenReturn("2026-W38");
+ TopVoterHandler handler = new TopVoterHandler(plugin);
+
+ String first = handler.timeChangeArchiveFileName(TopVoter.Weekly, transition);
+ String retry = handler.timeChangeArchiveFileName(TopVoter.Weekly, transition);
+
+ assertEquals("TopVoter" + File.separator + "Weekly" + File.separator + "Weekly_2026-W38.yml", first);
+ assertEquals(first, retry);
+ }
+
+ @Test
+ void sqliteTotalResetRetryPreservesVotesAcceptedAfterTheFirstReset() throws Exception {
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:");
+ Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, DailyTotal INTEGER, LastDailyTotal INTEGER)");
+ statement.executeUpdate("INSERT INTO users VALUES ('player', 20, 0)");
+
+ TimeChangeTotalReset.copyBoundarySqlite(connection, "users", "DailyTotal", "LastDailyTotal",
+ "time-copy:DAY:2026-09-21");
+ statement.executeUpdate("UPDATE users SET DailyTotal = DailyTotal + 3 WHERE uuid = 'player'");
+ TimeChangeTotalReset.copyBoundarySqlite(connection, "users", "DailyTotal", "LastDailyTotal",
+ "time-copy:DAY:2026-09-21");
+ TimeChangeTotalReset.resetSqlite(connection, "users", "DailyTotal", "LastDailyTotal",
+ "time-total:DAY:2026-09-21");
+ statement.executeUpdate("UPDATE users SET DailyTotal = DailyTotal + 2 WHERE uuid = 'player'");
+ TimeChangeTotalReset.resetSqlite(connection, "users", "DailyTotal", "LastDailyTotal",
+ "time-total:DAY:2026-09-21");
+
+ try (ResultSet result = statement.executeQuery("SELECT DailyTotal FROM users WHERE uuid = 'player'")) {
+ assertEquals(5, result.getInt(1));
+ }
+ }
+ }
+
+ @Test
+ void sqliteVotePartyBoundaryResetPreservesVotesAcceptedAfterTheBoundary() throws Exception {
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:");
+ Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, VotePartyVotes INTEGER, "
+ + "LastVotePartyVotes INTEGER)");
+ statement.executeUpdate("INSERT INTO users VALUES ('player', 4, 0)");
+ TimeChangeTotalReset.copyBoundarySqlite(connection, "users", "VotePartyVotes", "LastVotePartyVotes",
+ "vote-party-copy:DAY");
+ statement.executeUpdate("UPDATE users SET VotePartyVotes = VotePartyVotes + 3 WHERE uuid = 'player'");
+ TimeChangeTotalReset.resetSqlite(connection, "users", "VotePartyVotes", "LastVotePartyVotes",
+ "vote-party-reset:DAY");
+ statement.executeUpdate("UPDATE users SET VotePartyVotes = VotePartyVotes + 2 WHERE uuid = 'player'");
+ TimeChangeTotalReset.resetSqlite(connection, "users", "VotePartyVotes", "LastVotePartyVotes",
+ "vote-party-reset:DAY");
+ try (ResultSet result = statement.executeQuery("SELECT VotePartyVotes FROM users WHERE uuid = 'player'")) {
+ assertEquals(5, result.getInt(1));
+ }
+ }
+ }
+
+ @Test
+ void sqliteDailyStreakBoundaryKeepsValueAndTimestampTogether() throws Exception {
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:");
+ Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, DayVoteStreak INTEGER, "
+ + "LastDayVoteStreak INTEGER, DayVoteStreakLastUpdate TEXT, "
+ + "LastDayVoteStreakLastUpdate TEXT)");
+ statement.executeUpdate("INSERT INTO users VALUES ('player', 4, 0, '100', '')");
+
+ TimeChangeTotalReset.copyDailyStreakBoundarySqlite(connection, "users", "time-streak-copy:DAY");
+ statement.executeUpdate("UPDATE users SET DayVoteStreak = 5, DayVoteStreakLastUpdate = '200'");
+ TimeChangeTotalReset.copyDailyStreakBoundarySqlite(connection, "users", "time-streak-copy:DAY");
+
+ try (ResultSet result = statement.executeQuery(
+ "SELECT LastDayVoteStreak, LastDayVoteStreakLastUpdate FROM users WHERE uuid = 'player'")) {
+ assertEquals(4, result.getInt(1));
+ assertEquals("100", result.getString(2));
+ }
+ }
+ }
+
+ @Test
+ void sqliteVoteShopResetsRemainIndependentAndDoNotRepeat() throws Exception {
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:");
+ Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, VoteShopLimitdaily INTEGER, "
+ + "VoteShopLimitweekly INTEGER)");
+ statement.executeUpdate("INSERT INTO users VALUES ('player', 4, 6)");
+ TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitdaily",
+ "time-shop:DAY:2026-09-21:VoteShopLimitdaily");
+ TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitweekly",
+ "time-shop:DAY:2026-09-21:VoteShopLimitweekly");
+ statement.executeUpdate("UPDATE users SET VoteShopLimitdaily = 1, VoteShopLimitweekly = 2");
+ TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitdaily",
+ "time-shop:DAY:2026-09-21:VoteShopLimitdaily");
+ TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitweekly",
+ "time-shop:DAY:2026-09-21:VoteShopLimitweekly");
+ try (ResultSet result = statement.executeQuery(
+ "SELECT VoteShopLimitdaily, VoteShopLimitweekly FROM users WHERE uuid = 'player'")) {
+ assertEquals(1, result.getInt(1));
+ assertEquals(2, result.getInt(2));
+ }
+ }
+ }
+
+ @Test
+ void sqliteVoteShopResetQuotesConfiguredIdentifierCharacters() throws Exception {
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:");
+ Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE users (uuid TEXT PRIMARY KEY, `VoteShopLimitdaily-reward` INTEGER)");
+ statement.executeUpdate("INSERT INTO users VALUES ('player', 4)");
+
+ TimeChangeTotalReset.resetSqliteToZero(connection, "users", "VoteShopLimitdaily-reward",
+ "time-shop:DAY:2026-09-21:VoteShopLimitdaily-reward");
+
+ try (ResultSet result = statement.executeQuery(
+ "SELECT `VoteShopLimitdaily-reward` FROM users WHERE uuid = 'player'")) {
+ assertEquals(0, result.getInt(1));
+ }
+ }
+ }
+
+ @Test
+ void failedMysqlVoteShopResetLeavesPhasePending() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ServerData serverData = mock(ServerData.class);
+ TimeChangeTransition transition = mock(TimeChangeTransition.class);
+ when(plugin.getServerData()).thenReturn(serverData);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getShopFile().getShopIdentifiers()).thenReturn(Set.of("daily"));
+ when(plugin.getShopFile().getVoteShopResetDaily("daily")).thenReturn(true);
+ when(transition.getType()).thenReturn(TimeType.DAY);
+ when(transition.getPeriodKey()).thenReturn("2026-09-21");
+ when(serverData.getTimeChangeVoteShopTargets(transition)).thenReturn(List.of("daily"));
+ try (MockedStatic purchaseService = mockStatic(VoteShopPurchaseService.class)) {
+ purchaseService.when(() -> VoteShopPurchaseService.limitGenerationIdForTransition(transition))
+ .thenReturn("time-shop:DAY:2026-09-21");
+ purchaseService.when(() -> VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin,
+ "VoteShopLimitdaily", "time-shop:DAY:2026-09-21")).thenReturn(false);
+
+ assertThrows(IllegalStateException.class,
+ () -> new TopVoterHandler(plugin).processRecoverableVoteShop(TopVoter.Daily, transition));
+ purchaseService.verify(() -> VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin,
+ "VoteShopLimitdaily", "time-shop:DAY:2026-09-21"));
+ }
+
+ verify(serverData, never()).completeTimeChangePhase(transition, "VOTE_SHOP");
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java
index 86156a8584..eab7809e9b 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java
@@ -117,6 +117,25 @@ void firstPerServerColumnCreditTreatsNullAsZero() throws Exception {
.contains("SET `hub_Points` = COALESCE(`hub_Points`, 0) + ?"));
}
+ @Test
+ void cappedVoteCreditAppliesTheAdditionAndLimitInOneUpdate() throws Exception {
+ Fixture fixture = fixture();
+ Connection missing = missingLookup();
+ Attempt credit = successfulAttempt(10);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(missing, credit.connection());
+
+ assertEquals(10, new SharedPointAdditionJournal(fixture.table, false)
+ .addCapped("vote-points", "player", "hub_Points", 5, 10, 100L).total());
+
+ org.mockito.ArgumentCaptor statements = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(credit.connection(), times(4)).prepareStatement(statements.capture());
+ assertTrue(statements.getAllValues().get(1)
+ .contains("SET `hub_Points` = LEAST(COALESCE(`hub_Points`, 0) + ?, ?)"));
+ verify(credit.credit()).setInt(1, 5);
+ verify(credit.credit()).setInt(2, 10);
+ verify(credit.credit()).setString(3, "player");
+ }
+
@Test
void completedOperationRejectsAConflictingRetryInsteadOfChangingPoints() throws Exception {
Fixture fixture = fixture();
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java
new file mode 100644
index 0000000000..ca992277d8
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserProxyVoteTest.java
@@ -0,0 +1,50 @@
+package com.bencodez.votingplugin.user;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.UUID;
+
+import org.bukkit.Server;
+import org.bukkit.plugin.PluginManager;
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
+import com.bencodez.advancedcore.api.user.UserData;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.config.BungeeSettings;
+import com.bencodez.votingplugin.events.PlayerVoteEvent;
+import com.bencodez.votingplugin.votesites.VoteSiteManager;
+
+class VotingPluginUserProxyVoteTest {
+ @Test
+ void backendDeliveryIsNotAcknowledgedWhenPostAdmissionProcessingFails() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ BungeeSettings bungee = mock(BungeeSettings.class);
+ VoteSiteManager sites = mock(VoteSiteManager.class);
+ Server server = mock(Server.class);
+ PluginManager pluginManager = mock(PluginManager.class);
+ AdvancedCoreUser base = mock(AdvancedCoreUser.class);
+ when(plugin.getBungeeSettings()).thenReturn(bungee);
+ when(bungee.isUseBungeecoord()).thenReturn(true);
+ when(plugin.getVoteSiteManager()).thenReturn(sites);
+ when(plugin.getServer()).thenReturn(server);
+ when(server.getPluginManager()).thenReturn(pluginManager);
+ when(base.getPlayerName()).thenReturn("Player");
+ when(base.getUserData()).thenReturn(mock(UserData.class));
+ doAnswer(invocation -> {
+ PlayerVoteEvent event = invocation.getArgument(0);
+ event.setProcessingFailed(true);
+ event.setReplayUnsafe(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerVoteEvent.class));
+
+ VotingPluginUser user = new VotingPluginUser(plugin, base);
+
+ assertThrows(IllegalStateException.class, () -> user.bungeeVotePluginMessagingAccepted(
+ "service", 100L, null, true, true, true, 1, UUID.randomUUID()));
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java
new file mode 100644
index 0000000000..2ca6e38fbc
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVotePointsTest.java
@@ -0,0 +1,86 @@
+package com.bencodez.votingplugin.user;
+
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.plugin.PluginManager;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
+import com.bencodez.advancedcore.api.user.UserData;
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.events.PlayerReceivePointsEvent;
+
+class VotingPluginUserVotePointsTest {
+ @Test
+ void repeatedVoteIdentityCommitsPointsAndDispatchesTheReceiveHookOnlyOnce() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false);
+ when(plugin.getConfigFile().getPointsOnVote()).thenReturn(5);
+ when(plugin.getConfigFile().getLimitVotePoints()).thenReturn(10);
+ MySQL table = mock(MySQL.class);
+ when(plugin.getMysql()).thenReturn(table);
+ AdvancedCoreUser base = mock(AdvancedCoreUser.class);
+ String uuid = "00000000-0000-0000-0000-000000000001";
+ when(base.getUserData()).thenReturn(mock(UserData.class));
+ when(base.getUUID()).thenReturn(uuid);
+ VotingPluginUser user = org.mockito.Mockito.spy(new VotingPluginUser(plugin, base));
+ doReturn(false).when(user).isCached();
+ SharedPointAdditionJournal journal = mock(SharedPointAdditionJournal.class);
+ UUID voteId = UUID.randomUUID();
+ String operationId = "vote-points:" + voteId;
+ when(journal.findCompleted(operationId, uuid, "Points"))
+ .thenReturn(null, new SharedPointAdditionJournal.AdditionResult(5));
+ when(journal.addCapped(org.mockito.ArgumentMatchers.eq(operationId), org.mockito.ArgumentMatchers.eq(uuid),
+ org.mockito.ArgumentMatchers.eq("Points"), org.mockito.ArgumentMatchers.eq(5),
+ org.mockito.ArgumentMatchers.eq(10), anyLong()))
+ .thenReturn(new SharedPointAdditionJournal.AdditionResult(5));
+ PluginManager pluginManager = mock(PluginManager.class);
+
+ try (MockedStatic journals = mockStatic(SharedPointAdditionJournal.class);
+ MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ journals.when(() -> SharedPointAdditionJournal.forTable(table)).thenReturn(journal);
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+
+ user.addVotePoints(voteId);
+ user.addVotePoints(voteId);
+ }
+
+ verify(journal).addCapped(org.mockito.ArgumentMatchers.eq(operationId),
+ org.mockito.ArgumentMatchers.eq(uuid), org.mockito.ArgumentMatchers.eq("Points"),
+ org.mockito.ArgumentMatchers.eq(5), org.mockito.ArgumentMatchers.eq(10), anyLong());
+ verify(journal, org.mockito.Mockito.never()).acknowledge(
+ org.mockito.ArgumentMatchers.eq(operationId), anyLong());
+ verify(pluginManager).callEvent(isA(PlayerReceivePointsEvent.class));
+ }
+
+ @Test
+ void deliveryCompletionAcknowledgesVotePointReceipt() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ MySQL table = mock(MySQL.class);
+ when(plugin.getMysql()).thenReturn(table);
+ SharedPointAdditionJournal journal = mock(SharedPointAdditionJournal.class);
+ UUID voteId = UUID.randomUUID();
+
+ try (MockedStatic journals = mockStatic(SharedPointAdditionJournal.class)) {
+ journals.when(() -> SharedPointAdditionJournal.forTable(table)).thenReturn(journal);
+ VotingPluginUser.acknowledgeVotePointDelivery(plugin, voteId);
+ }
+
+ verify(journal).acknowledge(org.mockito.ArgumentMatchers.eq("vote-points:" + voteId), anyLong());
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java
index ec43050a0a..c5519cb2ce 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java
@@ -1,7 +1,9 @@
package com.bencodez.votingplugin.user;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
@@ -9,14 +11,168 @@
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.util.UUID;
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
import com.bencodez.advancedcore.api.user.UserData;
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.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.specialrewards.SpecialRewards;
+import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService;
class VotingPluginUserVoteShopLimitTest {
+ @Test
+ void recoveredBestDailyStreakIsFlushedBeforeItsRewardRuns() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ SpecialRewards specialRewards = mock(SpecialRewards.class);
+ when(plugin.getSpecialRewards()).thenReturn(specialRewards);
+ AdvancedCoreUser base = mock(AdvancedCoreUser.class);
+ when(base.getUserData()).thenReturn(mock(UserData.class));
+ when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ VotingPluginUser user = spy(new VotingPluginUser(plugin, base));
+ UserDataCache cache = mock(UserDataCache.class);
+ doReturn(3).when(user).getBestDayVoteStreak();
+ doNothing().when(user).setBestDayVoteStreak(7);
+ doReturn(cache).when(user).getCache();
+
+ user.completeRecoveredDailyStreak(7, true);
+
+ org.mockito.InOrder order = org.mockito.Mockito.inOrder(user, cache, specialRewards);
+ order.verify(user).setBestDayVoteStreak(7);
+ order.verify(cache).flushChangesAndRun(org.mockito.ArgumentMatchers.any(Runnable.class));
+ order.verify(specialRewards).checkVoteStreakAt(null, user, "Day", 7, true);
+ }
+
+ @Test
+ void sharedMysqlDailyVotesUseTheCrossBackendBoundaryMutation() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ AdvancedCoreUser base = mock(AdvancedCoreUser.class);
+ when(base.getUserData()).thenReturn(mock(UserData.class));
+ when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ VotingPluginUser user = spy(new VotingPluginUser(plugin, base));
+ UserDataCache cache = mock(UserDataCache.class);
+ doReturn(cache).when(user).getCache();
+ doReturn(0).when(user).getDailyTotal();
+ UUID voteId = UUID.randomUUID();
+ try (MockedStatic service = org.mockito.Mockito
+ .mockStatic(VoteShopPurchaseService.class);
+ MockedStatic reconciler = org.mockito.Mockito
+ .mockStatic(SharedMysqlCacheReconciler.class)) {
+ service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(),
+ "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)).thenReturn(true);
+
+ user.addTotalDaily(voteId);
+
+ service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(),
+ "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null));
+ reconciler.verify(() -> SharedMysqlCacheReconciler.invalidate(plugin, base.getUUID(), "DailyTotal"));
+ verify(cache, never()).clearChanges();
+ }
+ }
+
+ @Test
+ void sharedMysqlVotePartyCountsUseTheCrossBackendBoundaryMutation() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ AdvancedCoreUser base = mock(AdvancedCoreUser.class);
+ when(base.getUserData()).thenReturn(mock(UserData.class));
+ when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ VotingPluginUser user = spy(new VotingPluginUser(plugin, base));
+ UserDataCache cache = mock(UserDataCache.class);
+ doReturn(cache).when(user).getCache();
+ doReturn(0).when(user).getVotePartyVotes();
+ UUID voteId = UUID.randomUUID();
+ try (MockedStatic service = org.mockito.Mockito
+ .mockStatic(VoteShopPurchaseService.class);
+ MockedStatic reconciler = org.mockito.Mockito
+ .mockStatic(SharedMysqlCacheReconciler.class)) {
+ service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(),
+ "VotePartyVotes", "LastVotePartyVotes", java.util.List.of("VotePartyVotes"), null))
+ .thenReturn(true);
+
+ user.addVotePartyVote(voteId);
+
+ service.verify(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(),
+ "VotePartyVotes", "LastVotePartyVotes", java.util.List.of("VotePartyVotes"), null));
+ reconciler.verify(() -> SharedMysqlCacheReconciler.invalidate(plugin, base.getUUID(), "VotePartyVotes"));
+ verify(cache, never()).clearChanges();
+ }
+ }
+
+ @Test
+ void unretainedSharedMysqlIncrementDoesNotWriteAnUnfencedAbsoluteValue() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ AdvancedCoreUser base = mock(AdvancedCoreUser.class);
+ when(base.getUserData()).thenReturn(mock(UserData.class));
+ when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ VotingPluginUser user = spy(new VotingPluginUser(plugin, base));
+ doReturn(null).when(user).getCache();
+ doReturn(4).when(user).getDailyTotal();
+ doNothing().when(user).setDailyTotal(org.mockito.ArgumentMatchers.anyInt());
+ UUID voteId = UUID.randomUUID();
+ try (MockedStatic service = org.mockito.Mockito
+ .mockStatic(VoteShopPurchaseService.class)) {
+ service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(),
+ "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null)).thenReturn(false);
+
+ assertThrows(IllegalStateException.class, () -> user.addTotalDaily(voteId));
+
+ verify(user, never()).setDailyTotal(5);
+ }
+ }
+
+ @Test
+ void unretainedSharedMysqlMonthlyIncrementDoesNotWriteAnUnfencedCappedValue() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getConfigFile().isLimitMonthlyVotes()).thenReturn(true);
+ when(plugin.getTimeChecker().getTime().getDayOfMonth()).thenReturn(2);
+ when(plugin.getVoteSiteManager().getVoteSitesEnabled().size()).thenReturn(3);
+ AdvancedCoreUser base = mock(AdvancedCoreUser.class);
+ when(base.getUserData()).thenReturn(mock(UserData.class));
+ when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ VotingPluginUser user = spy(new VotingPluginUser(plugin, base));
+ doReturn(null).when(user).getCache();
+ doReturn(6).when(user).getMonthTotal();
+ doNothing().when(user).setMonthTotal(org.mockito.ArgumentMatchers.anyInt());
+ UUID voteId = UUID.randomUUID();
+ try (MockedStatic service = org.mockito.Mockito
+ .mockStatic(VoteShopPurchaseService.class)) {
+ service.when(() -> VoteShopPurchaseService.incrementMysqlPeriodTotals(plugin, voteId, base.getUUID(),
+ "MonthTotal", "LastMonthTotal", java.util.List.of("MonthTotal"), Integer.valueOf(6)))
+ .thenReturn(false);
+
+ assertThrows(IllegalStateException.class, () -> user.addMonthTotal(voteId));
+
+ verify(user, never()).setMonthTotal(6);
+ }
+ }
+
+ @Test
+ void sqliteMonthlyIncrementAppliesTheCapInsideTheMutationFence() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
+ when(plugin.getConfigFile().isLimitMonthlyVotes()).thenReturn(true);
+ when(plugin.getTimeChecker().getTime().getDayOfMonth()).thenReturn(2);
+ when(plugin.getVoteSiteManager().getVoteSitesEnabled().size()).thenReturn(3);
+ AdvancedCoreUser base = mock(AdvancedCoreUser.class);
+ when(base.getUserData()).thenReturn(mock(UserData.class));
+ when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ VotingPluginUser user = spy(new VotingPluginUser(plugin, base));
+ doReturn(6).when(user).getMonthTotal();
+ doNothing().when(user).setMonthTotal(org.mockito.ArgumentMatchers.anyInt());
+
+ user.addMonthTotal(UUID.randomUUID());
+
+ verify(user).setMonthTotal(6);
+ }
+
@Test
void sharedMysqlLimitsUseNonBlockingUserCacheWhenAvailable() {
VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java
index 7f49f61e8d..affa541d0b 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java
@@ -3,6 +3,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
@@ -14,6 +15,8 @@
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
+import java.util.List;
+import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
@@ -21,6 +24,298 @@
import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
class SharedMysqlPurchaseJournalTest {
+ @Test
+ void retiredVoteAccountingKeepsABoundedTombstoneAndCleansExpiredRows() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement retire = mock(PreparedStatement.class);
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement delete = mock(PreparedStatement.class);
+ ResultSet expired = ids("expired-vote-id");
+ when(select.executeQuery()).thenReturn(expired);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(retire, select, delete);
+ UUID voteId = UUID.randomUUID();
+ long retiredAt = SharedMysqlPurchaseJournal.TERMINAL_RETENTION_MILLIS + 5000L;
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).retireVoteAccounting(voteId, retiredAt);
+
+ verify(retire).setLong(1, retiredAt);
+ verify(retire).setString(2, voteId.toString());
+ verify(select).setLong(1, 5000L);
+ verify(delete).setString(1, "expired-vote-id");
+ verify(delete).setLong(2, 5000L);
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work, org.mockito.Mockito.times(3)).prepareStatement(sql.capture());
+ assertTrue(sql.getAllValues().get(1).contains("`requested` = `completed`"));
+ assertTrue(sql.getAllValues().get(2).contains("`requested` = `completed`"));
+ verify(fixture.work).commit();
+ }
+ @Test
+ void dailyStreakResetCompletionFollowsThePersistedCopyMarker() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement copyInsert = mock(PreparedStatement.class);
+ PreparedStatement copySelect = mock(PreparedStatement.class);
+ PreparedStatement resetInsert = mock(PreparedStatement.class);
+ PreparedStatement resetSelect = mock(PreparedStatement.class);
+ PreparedStatement update = mock(PreparedStatement.class);
+ ResultSet copyMarker = mock(ResultSet.class);
+ ResultSet resetMarker = mock(ResultSet.class);
+ when(copyMarker.next()).thenReturn(true);
+ when(copyMarker.getString(2)).thenReturn("time-streak-copy:DAY:2026-09-22");
+ when(resetMarker.next()).thenReturn(true);
+ when(resetMarker.getLong(1)).thenReturn(3L);
+ when(copySelect.executeQuery()).thenReturn(copyMarker);
+ when(resetSelect.executeQuery()).thenReturn(resetMarker);
+ when(update.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect,
+ update);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false)
+ .completeDailyStreakReset("time-streak-reset:DAY:2026-09-22");
+
+ verify(update).setLong(1, 4L);
+ verify(update).setString(2, "time-streak-reset:DAY:2026-09-22");
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void voteAdmissionDurablyIncludesEligibleDailyStreakAndReward() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement copyInsert = mock(PreparedStatement.class);
+ PreparedStatement copySelect = mock(PreparedStatement.class);
+ PreparedStatement resetInsert = mock(PreparedStatement.class);
+ PreparedStatement resetSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingInsert = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement candidateSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ ResultSet copyEpoch = mock(ResultSet.class);
+ ResultSet resetEpoch = mock(ResultSet.class);
+ ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0);
+ ResultSet candidate = mock(ResultSet.class);
+ when(copyEpoch.next()).thenReturn(true);
+ when(resetEpoch.next()).thenReturn(true);
+ when(copySelect.executeQuery()).thenReturn(copyEpoch);
+ when(resetSelect.executeQuery()).thenReturn(resetEpoch);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(candidate.next()).thenReturn(true);
+ when(candidate.getInt(1)).thenReturn(0);
+ when(candidate.getInt(2)).thenReturn(4);
+ when(candidate.getString(3)).thenReturn("");
+ when(candidateSelect.executeQuery()).thenReturn(candidate);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect,
+ accountingInsert, accountingSelect, candidateSelect, accountingUpdate);
+
+ int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting(
+ java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, true, false,
+ null, null, false, 0.0, 1, true, 1234L);
+
+ assertEquals(304, requested);
+ verify(accountingUpdate).setInt(2, 368);
+ verify(accountingUpdate).setInt(3, 320);
+ verify(accountingUpdate).setInt(6, 5);
+ verify(accountingUpdate).setLong(7, 1234L);
+ verify(accountingUpdate).setInt(8, 1);
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void repeatedVoteAdmissionReturnsThePersistedPointsDecision() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement copyInsert = mock(PreparedStatement.class);
+ PreparedStatement copySelect = mock(PreparedStatement.class);
+ PreparedStatement resetInsert = mock(PreparedStatement.class);
+ PreparedStatement resetSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingInsert = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ ResultSet copyEpoch = mock(ResultSet.class);
+ ResultSet resetEpoch = mock(ResultSet.class);
+ ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 320, 64, 9, 40, true);
+ when(copyEpoch.next()).thenReturn(true);
+ when(resetEpoch.next()).thenReturn(true);
+ when(copySelect.executeQuery()).thenReturn(copyEpoch);
+ when(resetSelect.executeQuery()).thenReturn(resetEpoch);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect,
+ accountingInsert, accountingSelect, accountingUpdate);
+
+ SharedMysqlPurchaseJournal.VoteAccountingDecision decision = new SharedMysqlPurchaseJournal(fixture.table, false)
+ .prepareVoteAccounting(
+ UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, false,
+ null, null, false, 0.0, 1, false, 1234L, 2, 10, "current_Points");
+
+ assertEquals(256, decision.bits());
+ assertEquals(9, decision.pointAmount());
+ assertEquals(40, decision.pointCap());
+ assertEquals("persisted_Points", decision.pointColumn());
+ assertEquals(true, decision.replayUnsafe());
+ verify(accountingUpdate).setInt(2, 320);
+ verify(accountingUpdate).setInt(3, 320);
+ verify(accountingUpdate).setString(11, "persisted_Points");
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void pendingDailyTotalsContributeToPercentageStreakAdmission() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement dailyCopyInsert = mock(PreparedStatement.class);
+ PreparedStatement dailyCopySelect = mock(PreparedStatement.class);
+ PreparedStatement copyInsert = mock(PreparedStatement.class);
+ PreparedStatement copySelect = mock(PreparedStatement.class);
+ PreparedStatement resetInsert = mock(PreparedStatement.class);
+ PreparedStatement resetSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingInsert = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement dailyResetInsert = mock(PreparedStatement.class);
+ PreparedStatement dailyResetSelect = mock(PreparedStatement.class);
+ PreparedStatement candidateSelect = mock(PreparedStatement.class);
+ PreparedStatement pendingTotalSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ ResultSet dailyCopyEpoch = mock(ResultSet.class);
+ ResultSet copyEpoch = mock(ResultSet.class);
+ ResultSet resetEpoch = mock(ResultSet.class);
+ ResultSet dailyResetEpoch = mock(ResultSet.class);
+ ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0);
+ ResultSet candidate = mock(ResultSet.class);
+ ResultSet pendingTotals = mock(ResultSet.class);
+ when(dailyCopyEpoch.next()).thenReturn(true);
+ when(dailyCopyEpoch.getString(2)).thenReturn("time-copy:DAY:2026-09-22");
+ when(copyEpoch.next()).thenReturn(true);
+ when(resetEpoch.next()).thenReturn(true);
+ when(dailyResetEpoch.next()).thenReturn(true);
+ when(dailyResetEpoch.getString(2)).thenReturn("time-total:DAY:2026-09-21");
+ when(dailyCopySelect.executeQuery()).thenReturn(dailyCopyEpoch);
+ when(copySelect.executeQuery()).thenReturn(copyEpoch);
+ when(resetSelect.executeQuery()).thenReturn(resetEpoch);
+ when(dailyResetSelect.executeQuery()).thenReturn(dailyResetEpoch);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(candidate.next()).thenReturn(true);
+ when(candidate.getInt(1)).thenReturn(0);
+ when(candidate.getInt(2)).thenReturn(4);
+ when(candidate.getString(3)).thenReturn("");
+ when(candidateSelect.executeQuery()).thenReturn(candidate);
+ when(pendingTotals.next()).thenReturn(true);
+ when(pendingTotals.getInt(1)).thenReturn(1);
+ when(pendingTotalSelect.executeQuery()).thenReturn(pendingTotals);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(dailyCopyInsert, dailyCopySelect,
+ copyInsert, copySelect, resetInsert, resetSelect, accountingInsert, accountingSelect,
+ dailyResetInsert, dailyResetSelect, candidateSelect, pendingTotalSelect, accountingUpdate);
+
+ int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting(
+ UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, false,
+ null, null, true, 50.0, 1, false, 1234L);
+
+ assertEquals(48, requested);
+ verify(accountingUpdate).setInt(2, 112);
+ verify(pendingTotalSelect).setInt(5, 64);
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work, org.mockito.Mockito.times(13)).prepareStatement(sql.capture());
+ assertTrue(sql.getAllValues().get(10).contains("LastDailyTotal"),
+ "active daily reset must exclude the copied boundary total");
+ }
+
+ @Test
+ void firstVoteCanBeAdmittedBeforeTheUserRowIsCreated() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement copyInsert = mock(PreparedStatement.class);
+ PreparedStatement copySelect = mock(PreparedStatement.class);
+ PreparedStatement resetInsert = mock(PreparedStatement.class);
+ PreparedStatement resetSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingInsert = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement candidateSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ ResultSet copyEpoch = mock(ResultSet.class);
+ ResultSet resetEpoch = mock(ResultSet.class);
+ ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0);
+ ResultSet missingCandidate = mock(ResultSet.class);
+ when(copyEpoch.next()).thenReturn(true);
+ when(resetEpoch.next()).thenReturn(true);
+ when(copySelect.executeQuery()).thenReturn(copyEpoch);
+ when(resetSelect.executeQuery()).thenReturn(resetEpoch);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(missingCandidate.next()).thenReturn(false);
+ when(candidateSelect.executeQuery()).thenReturn(missingCandidate);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(copyInsert, copySelect, resetInsert, resetSelect,
+ accountingInsert, accountingSelect, candidateSelect, accountingUpdate);
+
+ int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting(
+ java.util.UUID.randomUUID(), "00000000-0000-0000-0000-000000000001", false, false, false,
+ null, null, false, 0.0, 1, false, 1234L);
+
+ assertEquals(48, requested);
+ verify(accountingUpdate).setInt(6, 1);
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void completedDailyStreakCanClaimItsDeferredRewardOnce() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 48, 16);
+ when(accounting.getObject(8)).thenReturn(Integer.valueOf(1));
+ when(accounting.getObject(9)).thenReturn(Integer.valueOf(7));
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(accountingSelect, accountingUpdate);
+ java.util.UUID voteId = java.util.UUID.randomUUID();
+
+ SharedMysqlPurchaseJournal.RecoveredDailyStreak reward =
+ new SharedMysqlPurchaseJournal(fixture.table, false).claimDailyStreakReward(voteId);
+
+ assertEquals(voteId, reward.voteId());
+ assertEquals(7, reward.streak());
+ assertTrue(reward.forceProxyRouting());
+ verify(accountingUpdate).setInt(1, 144);
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void claimedDailyStreakRewardCompletesOnlyAfterDeliveryReturns() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 48, 144);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(accountingSelect, accountingUpdate);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false)
+ .completeDailyStreakReward(java.util.UUID.randomUUID());
+
+ verify(accountingUpdate).setInt(1, 48);
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void ambiguousDailyStreakRewardIsNotClaimedAgain() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 48, 144);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(accountingSelect);
+
+ assertThrows(java.sql.SQLException.class, () -> new SharedMysqlPurchaseJournal(fixture.table, false)
+ .claimDailyStreakReward(java.util.UUID.randomUUID()));
+
+ verify(fixture.work).rollback();
+ }
+
+ @Test
+ void dailyStreakDateFenceRecognizesTheSameLocalDay() {
+ java.time.ZoneId zone = java.time.ZoneId.systemDefault();
+ long morning = java.time.LocalDate.of(2026, 9, 22).atTime(1, 0).atZone(zone).toInstant().toEpochMilli();
+ long evening = java.time.LocalDate.of(2026, 9, 22).atTime(23, 0).atZone(zone).toInstant().toEpochMilli();
+ long nextDay = java.time.LocalDate.of(2026, 9, 23).atStartOfDay(zone).toInstant().toEpochMilli();
+
+ assertTrue(SharedMysqlPurchaseJournal.sameLocalDay(Long.toString(morning), evening));
+ assertFalse(SharedMysqlPurchaseJournal.sameLocalDay(Long.toString(morning), nextDay));
+ }
@Test
void journalTableNameIsPortableAndCollisionResistantForLongSourceNames() {
String source = "u".repeat(80);
@@ -264,6 +559,509 @@ void repeatedResetGenerationDoesNotWipeNewPeriodPurchases() throws Exception {
verify(fixture.work, org.mockito.Mockito.never()).commit();
}
+ @Test
+ void periodResetSubtractsTheBoundaryCopyAndAdvancesItsGeneration() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement reset = mock(PreparedStatement.class);
+ PreparedStatement advance = mock(PreparedStatement.class);
+ ResultSet epoch = mock(ResultSet.class);
+ when(epoch.next()).thenReturn(true);
+ when(epoch.getLong(1)).thenReturn(4L);
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(advance.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, reset, advance);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).resetPeriodTotal(
+ "DailyTotal", "LastDailyTotal", "time-total:DAY:2026-09-21");
+
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work, org.mockito.Mockito.times(4)).prepareStatement(sql.capture());
+ assertTrue(sql.getAllValues().get(2).contains(
+ "`DailyTotal` = GREATEST(0, COALESCE(`DailyTotal`, 0) - COALESCE(`LastDailyTotal`, 0))"));
+ verify(advance).setLong(1, 5L);
+ verify(advance).setString(2, "time-total:DAY:2026-09-21");
+ verify(advance).setString(3, "period-reset:DailyTotal");
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void periodBoundaryCopyAndGenerationAdvanceShareOneTransaction() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement pending = mock(PreparedStatement.class);
+ PreparedStatement copy = mock(PreparedStatement.class);
+ PreparedStatement advance = mock(PreparedStatement.class);
+ ResultSet epoch = mock(ResultSet.class);
+ ResultSet noPending = ids();
+ when(epoch.next()).thenReturn(true);
+ when(epoch.getLong(1)).thenReturn(8L);
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(pending.executeQuery()).thenReturn(noPending);
+ when(advance.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, pending, copy, advance);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).copyPeriodBoundary(
+ "DailyTotal", "LastDailyTotal", "time-copy:DAY:2026-09-21");
+
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work, org.mockito.Mockito.times(5)).prepareStatement(sql.capture());
+ assertTrue(sql.getAllValues().get(3).contains(
+ "`LastDailyTotal` = COALESCE(`DailyTotal`, 0)"));
+ verify(advance).setLong(1, 9L);
+ verify(advance).setString(3, "period-copy:DailyTotal");
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void reservedMonthIncrementDrainsWithoutReapplyingItsOldCap() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement pending = mock(PreparedStatement.class);
+ PreparedStatement increment = mock(PreparedStatement.class);
+ PreparedStatement complete = mock(PreparedStatement.class);
+ PreparedStatement copy = mock(PreparedStatement.class);
+ PreparedStatement advance = mock(PreparedStatement.class);
+ ResultSet epoch = mock(ResultSet.class);
+ ResultSet accounting = mock(ResultSet.class);
+ when(epoch.next()).thenReturn(true);
+ when(epoch.getLong(1)).thenReturn(8L);
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(accounting.next()).thenReturn(true, false);
+ when(accounting.getString(1)).thenReturn(UUID.randomUUID().toString());
+ when(accounting.getString(2)).thenReturn("player");
+ when(accounting.getInt(3)).thenReturn(4 | 1024);
+ when(accounting.getString(5)).thenReturn("September2026");
+ when(accounting.getObject(6)).thenReturn(5);
+ when(pending.executeQuery()).thenReturn(accounting);
+ when(increment.executeUpdate()).thenReturn(1);
+ when(complete.executeUpdate()).thenReturn(1);
+ when(advance.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, pending,
+ increment, complete, copy, advance);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).copyPeriodBoundary(
+ "MonthTotal", "LastMonthTotal", "time-copy:MONTH:2026-09");
+
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work, org.mockito.Mockito.times(7)).prepareStatement(sql.capture());
+ String incrementSql = sql.getAllValues().get(3);
+ assertTrue(incrementSql.contains("`MonthTotal` = COALESCE(`MonthTotal`, 0) + 1"));
+ assertTrue(incrementSql.contains("`September2026` = COALESCE(`September2026`, 0) + 1"));
+ assertFalse(incrementSql.contains("LEAST"));
+ verify(increment).setString(1, "player");
+ }
+
+ @Test
+ void pendingAccountingAppliesCapsOnlyToUnreservedMonthIncrements() {
+ assertEquals(5, SharedMysqlPurchaseJournal.pendingAccountingMaximum(4, 4, 5));
+ assertEquals(null, SharedMysqlPurchaseJournal.pendingAccountingMaximum(4, 4 | 1024, 5));
+ assertEquals(null, SharedMysqlPurchaseJournal.pendingAccountingMaximum(2, 2, 5));
+ }
+
+ @Test
+ void periodIncrementLocksTheSameBoundaryRowBeforeUpdatingTheUser() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement requestInsert = mock(PreparedStatement.class);
+ PreparedStatement requestSelect = mock(PreparedStatement.class);
+ PreparedStatement requestUpdate = mock(PreparedStatement.class);
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement resetMarkerInsert = mock(PreparedStatement.class);
+ PreparedStatement resetMarkerSelect = mock(PreparedStatement.class);
+ PreparedStatement increment = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ ResultSet epoch = mock(ResultSet.class);
+ ResultSet requested = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0);
+ ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 4, 0);
+ ResultSet resetEpoch = mock(ResultSet.class);
+ when(epoch.next()).thenReturn(true);
+ when(epoch.getLong(1)).thenReturn(9L);
+ when(epoch.getString(2)).thenReturn("time-copy:MONTH:2026-09");
+ when(resetEpoch.next()).thenReturn(true);
+ when(resetEpoch.getString(2)).thenReturn("time-total:MONTH:2026-08");
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(requestSelect.executeQuery()).thenReturn(requested);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(resetMarkerSelect.executeQuery()).thenReturn(resetEpoch);
+ when(increment.executeUpdate()).thenReturn(1);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(requestUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate,
+ markerInsert, markerSelect,
+ accountingSelect, resetMarkerInsert, resetMarkerSelect, increment, accountingUpdate);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(java.util.UUID.randomUUID(),
+ "00000000-0000-0000-0000-000000000001", "MonthTotal", "LastMonthTotal",
+ java.util.List.of("MonthTotal", "MonthTotal-SEPTEMBER-2026"), Integer.valueOf(42));
+
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work, org.mockito.Mockito.times(10)).prepareStatement(sql.capture());
+ assertTrue(sql.getAllValues().get(4).contains("FOR UPDATE"));
+ assertTrue(sql.getAllValues().get(8).contains("`MonthTotal` = GREATEST(COALESCE(`MonthTotal`, 0), "
+ + "LEAST(COALESCE(`LastMonthTotal`, 0) + ?, COALESCE(`MonthTotal`, 0) + 1))"));
+ assertTrue(sql.getAllValues().get(8).contains("`MonthTotal-SEPTEMBER-2026` = "
+ + "GREATEST(COALESCE(`MonthTotal-SEPTEMBER-2026`, 0), "
+ + "LEAST(?, COALESCE(`MonthTotal-SEPTEMBER-2026`, 0) + 1))"));
+ verify(markerSelect).setString(1, "period-copy:MonthTotal");
+ verify(increment).setInt(1, 42);
+ verify(increment).setInt(2, 42);
+ verify(increment).setString(3, "00000000-0000-0000-0000-000000000001");
+ verify(fixture.work, org.mockito.Mockito.times(2)).commit();
+ }
+
+ @Test
+ void repeatedVoteOperationDoesNotIncrementThePeriodTwice() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement requestInsert = mock(PreparedStatement.class);
+ PreparedStatement requestSelect = mock(PreparedStatement.class);
+ PreparedStatement requestUpdate = mock(PreparedStatement.class);
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ ResultSet epoch = mock(ResultSet.class);
+ ResultSet requested = accountingRow("player", 1, 1);
+ ResultSet accounting = accountingRow("player", 1, 1);
+ when(epoch.next()).thenReturn(true);
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(requestSelect.executeQuery()).thenReturn(requested);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(requestUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate,
+ markerInsert, markerSelect,
+ accountingSelect);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(java.util.UUID.randomUUID(),
+ "player", "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null);
+
+ verify(fixture.work).rollback();
+ verify(fixture.work).commit();
+ verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(anyString());
+ }
+
+ @Test
+ void repeatedVoteOperationDoesNotIncrementAllTimeTotalTwice() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement requestInsert = mock(PreparedStatement.class);
+ PreparedStatement requestSelect = mock(PreparedStatement.class);
+ PreparedStatement requestUpdate = mock(PreparedStatement.class);
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ ResultSet epoch = mock(ResultSet.class);
+ ResultSet requested = accountingRow("player", 512, 512);
+ ResultSet accounting = accountingRow("player", 512, 512);
+ when(epoch.next()).thenReturn(true);
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(requestSelect.executeQuery()).thenReturn(requested);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(requestUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate,
+ markerInsert, markerSelect, accountingSelect);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(UUID.randomUUID(),
+ "player", "AllTimeTotal", "AllTimeTotal", List.of("AllTimeTotal"), null);
+
+ verify(fixture.work).rollback();
+ verify(fixture.work).commit();
+ verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(anyString());
+ }
+
+ @Test
+ void cappedMonthlyAdmissionReservesAfterPreviouslyAdmittedVotes() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement[] statements = new PreparedStatement[16];
+ for (int index = 0; index < statements.length; index++) statements[index] = mock(PreparedStatement.class);
+ ResultSet marker = mock(ResultSet.class);
+ ResultSet accounting = accountingRow("player", 0, 0);
+ when(marker.next()).thenReturn(true);
+ when(statements[1].executeQuery()).thenReturn(marker);
+ when(statements[3].executeQuery()).thenReturn(marker);
+ when(statements[5].executeQuery()).thenReturn(marker);
+ when(statements[7].executeQuery()).thenReturn(marker);
+ when(statements[9].executeQuery()).thenReturn(marker);
+ when(statements[11].executeQuery()).thenReturn(accounting);
+ ResultSet currentTotal = mock(ResultSet.class);
+ when(currentTotal.next()).thenReturn(true);
+ when(currentTotal.getInt(1)).thenReturn(1);
+ when(statements[12].executeQuery()).thenReturn(currentTotal);
+ ResultSet pendingTotal = mock(ResultSet.class);
+ when(pendingTotal.next()).thenReturn(true);
+ when(pendingTotal.getInt(1)).thenReturn(1);
+ when(statements[13].executeQuery()).thenReturn(pendingTotal);
+ ResultSet streak = mock(ResultSet.class);
+ when(streak.next()).thenReturn(true);
+ when(statements[14].executeQuery()).thenReturn(streak);
+ when(statements[15].executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(statements[0],
+ java.util.Arrays.copyOfRange(statements, 1, statements.length));
+
+ int requested = new SharedMysqlPurchaseJournal(fixture.table, false).prepareVoteAccounting(
+ UUID.randomUUID(), "player", true, false, false, "MonthTotal-SEPTEMBER-2026",
+ Integer.valueOf(3), false, 0.0, 1, false, 1234L);
+
+ assertEquals(567, requested, "the vote must retain its available monthly slot");
+ verify(statements[13]).setInt(3, 4);
+ verify(statements[15]).setInt(2, 1655);
+ verify(statements[15]).setInt(3, 1088);
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void admittedMonthlyIncrementUsesItsJournaledColumnAfterConfigurationChanges() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement admittedSelect = mock(PreparedStatement.class);
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement increment = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ ResultSet admitted = accountingRow("player", 1028, 1024);
+ when(admitted.getString(4)).thenReturn("MonthTotal-SEPTEMBER-2026");
+ when(admitted.getObject(5)).thenReturn(Integer.valueOf(2));
+ ResultSet epoch = mock(ResultSet.class);
+ when(epoch.next()).thenReturn(true);
+ ResultSet accounting = accountingRow("player", 1028, 1024);
+ when(admittedSelect.executeQuery()).thenReturn(admitted);
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(increment.executeUpdate()).thenReturn(1);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(admittedSelect, markerInsert, markerSelect,
+ accountingSelect, increment, accountingUpdate);
+
+ SharedMysqlPurchaseJournal.PeriodTotalResult result = new SharedMysqlPurchaseJournal(fixture.table, false)
+ .incrementPeriodTotalsResolved(UUID.randomUUID(), "player", "MonthTotal", "LastMonthTotal",
+ List.of("MonthTotal", "MonthTotal-OCTOBER-2026"), Integer.valueOf(99), true);
+
+ assertEquals(List.of("MonthTotal", "MonthTotal-SEPTEMBER-2026"), result.columns());
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work, org.mockito.Mockito.times(6)).prepareStatement(sql.capture());
+ assertTrue(sql.getAllValues().get(4).contains("`MonthTotal-SEPTEMBER-2026`"));
+ assertFalse(sql.getAllValues().get(4).contains("`MonthTotal-OCTOBER-2026`"));
+ assertFalse(sql.getAllValues().get(4).contains("LEAST("),
+ "an increment reserved under an older cap must still advance after newer votes");
+ }
+
+ @Test
+ void ambiguousPeriodIncrementCommitIsConfirmedByTheVoteMarker() throws Exception {
+ Fixture fixture = fixture();
+ Connection confirmation = mock(Connection.class);
+ PreparedStatement requestInsert = mock(PreparedStatement.class);
+ PreparedStatement requestSelect = mock(PreparedStatement.class);
+ PreparedStatement requestUpdate = mock(PreparedStatement.class);
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement increment = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ PreparedStatement confirm = mock(PreparedStatement.class);
+ ResultSet epoch = mock(ResultSet.class);
+ ResultSet requested = accountingRow("player", 0, 0);
+ ResultSet accounting = accountingRow("player", 1, 0);
+ ResultSet confirmed = accountingRow("player", 1, 1);
+ when(epoch.next()).thenReturn(true);
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(requestSelect.executeQuery()).thenReturn(requested);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(increment.executeUpdate()).thenReturn(1);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(requestUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(requestInsert, requestSelect, requestUpdate,
+ markerInsert, markerSelect,
+ accountingSelect, increment, accountingUpdate);
+ org.mockito.Mockito.doNothing().doThrow(new java.sql.SQLException("commit acknowledgement lost"))
+ .when(fixture.work).commit();
+ when(confirm.executeQuery()).thenReturn(confirmed);
+ when(confirmation.prepareStatement(anyString())).thenReturn(confirm);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.work, fixture.work, confirmation);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(java.util.UUID.randomUUID(),
+ "player", "DailyTotal", "LastDailyTotal", java.util.List.of("DailyTotal"), null);
+
+ verify(increment).executeUpdate();
+ verify(confirm).executeQuery();
+ }
+
+ @Test
+ void dailyStreakValueAndTimestampCopyShareOneTransaction() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement pending = mock(PreparedStatement.class);
+ PreparedStatement copy = mock(PreparedStatement.class);
+ PreparedStatement advance = mock(PreparedStatement.class);
+ ResultSet epoch = mock(ResultSet.class);
+ ResultSet noPending = ids();
+ when(epoch.next()).thenReturn(true);
+ when(epoch.getLong(1)).thenReturn(2L);
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(pending.executeQuery()).thenReturn(noPending);
+ when(advance.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, pending, copy, advance);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).copyDailyStreakBoundary("DayVoteStreak",
+ "LastDayVoteStreak", "DayVoteStreakLastUpdate", "LastDayVoteStreakLastUpdate",
+ "time-streak-copy:DAY:2026-09-21");
+
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work, org.mockito.Mockito.times(5)).prepareStatement(sql.capture());
+ assertTrue(sql.getAllValues().get(3).contains("`LastDayVoteStreak` = COALESCE(`DayVoteStreak`, 0), "
+ + "`LastDayVoteStreakLastUpdate` = COALESCE(`DayVoteStreakLastUpdate`, '')"));
+ verify(advance).setString(3, "streak-copy:DayVoteStreak");
+ verify(fixture.work).commit();
+ }
+
+ @Test
+ void dailyStreakUpdateLocksTheSameBoundaryRow() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement requestMarkerInsert = mock(PreparedStatement.class);
+ PreparedStatement requestMarkerSelect = mock(PreparedStatement.class);
+ PreparedStatement requestInsert = mock(PreparedStatement.class);
+ PreparedStatement requestSelect = mock(PreparedStatement.class);
+ PreparedStatement requestUpdate = mock(PreparedStatement.class);
+ PreparedStatement copyMarkerInsert = mock(PreparedStatement.class);
+ PreparedStatement copyMarkerSelect = mock(PreparedStatement.class);
+ PreparedStatement resetMarkerInsert = mock(PreparedStatement.class);
+ PreparedStatement resetMarkerSelect = mock(PreparedStatement.class);
+ PreparedStatement accountingSelect = mock(PreparedStatement.class);
+ PreparedStatement earlierStreakSelect = mock(PreparedStatement.class);
+ PreparedStatement persistedUpdateSelect = mock(PreparedStatement.class);
+ PreparedStatement update = mock(PreparedStatement.class);
+ PreparedStatement accountingUpdate = mock(PreparedStatement.class);
+ PreparedStatement streakValueSelect = mock(PreparedStatement.class);
+ ResultSet requestEpoch = mock(ResultSet.class);
+ ResultSet copyEpoch = mock(ResultSet.class);
+ ResultSet resetEpoch = mock(ResultSet.class);
+ ResultSet requested = accountingRow("00000000-0000-0000-0000-000000000001", 0, 0);
+ ResultSet accounting = accountingRow("00000000-0000-0000-0000-000000000001", 16, 0);
+ ResultSet noEarlierStreak = ids();
+ ResultSet persistedUpdate = mock(ResultSet.class);
+ ResultSet streakValue = mock(ResultSet.class);
+ when(requestEpoch.next()).thenReturn(true);
+ when(copyEpoch.next()).thenReturn(true);
+ when(resetEpoch.next()).thenReturn(true);
+ when(requestMarkerSelect.executeQuery()).thenReturn(requestEpoch);
+ when(copyMarkerSelect.executeQuery()).thenReturn(copyEpoch);
+ when(resetMarkerSelect.executeQuery()).thenReturn(resetEpoch);
+ when(requestSelect.executeQuery()).thenReturn(requested);
+ when(accountingSelect.executeQuery()).thenReturn(accounting);
+ when(earlierStreakSelect.executeQuery()).thenReturn(noEarlierStreak);
+ when(persistedUpdate.next()).thenReturn(true);
+ when(persistedUpdate.getString(1)).thenReturn("");
+ when(persistedUpdateSelect.executeQuery()).thenReturn(persistedUpdate);
+ when(streakValue.next()).thenReturn(true);
+ when(streakValue.getInt(1)).thenReturn(8);
+ when(streakValueSelect.executeQuery()).thenReturn(streakValue);
+ when(update.executeUpdate()).thenReturn(1);
+ when(accountingUpdate.executeUpdate()).thenReturn(1);
+ when(requestUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(requestMarkerInsert, requestMarkerSelect,
+ requestInsert, requestSelect, requestUpdate, copyMarkerInsert, copyMarkerSelect,
+ resetMarkerInsert, resetMarkerSelect, accountingSelect, earlierStreakSelect, persistedUpdateSelect, update,
+ streakValueSelect, accountingUpdate);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).updateDailyStreak(java.util.UUID.randomUUID(),
+ "00000000-0000-0000-0000-000000000001", 7, 1234L, false);
+
+ verify(requestMarkerSelect).setString(1, "streak-copy:DayVoteStreak");
+ verify(copyMarkerSelect).setString(1, "streak-copy:DayVoteStreak");
+ verify(earlierStreakSelect).setInt(4, 16);
+ verify(earlierStreakSelect).setInt(5, 16);
+ verify(update).setString(1, "1234");
+ verify(update).setString(2, "00000000-0000-0000-0000-000000000001");
+ verify(accountingUpdate).setInt(2, 8);
+ verify(fixture.work, org.mockito.Mockito.times(2)).commit();
+ }
+
+ @Test
+ void dailyStreakBoundaryResetUsesTheSharedDatabaseFence() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement markerInsert = mock(PreparedStatement.class);
+ PreparedStatement markerSelect = mock(PreparedStatement.class);
+ PreparedStatement reset = mock(PreparedStatement.class);
+ ResultSet epoch = mock(ResultSet.class);
+ when(epoch.next()).thenReturn(true);
+ when(markerSelect.executeQuery()).thenReturn(epoch);
+ when(reset.executeUpdate()).thenReturn(1);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, reset);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false).resetDailyStreakAtBoundary("player", 1234L);
+
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work, org.mockito.Mockito.times(3)).prepareStatement(sql.capture());
+ assertTrue(sql.getAllValues().get(2).contains("CASE WHEN COALESCE(`DayVoteStreakLastUpdate`, '') = ?"));
+ verify(markerSelect).setString(1, "streak-copy:DayVoteStreak");
+ verify(reset).setString(1, "1234");
+ verify(reset).setString(2, "player");
+ }
+
+ @Test
+ void failedApplyLeavesTheCommittedAccountingRequestForRecovery() throws Exception {
+ Fixture fixture = fixture();
+ Connection request = mock(Connection.class);
+ Connection apply = mock(Connection.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement update = mock(PreparedStatement.class);
+ ResultSet row = accountingRow("player", 0, 0);
+ when(select.executeQuery()).thenReturn(row);
+ when(update.executeUpdate()).thenReturn(1);
+ when(request.prepareStatement(anyString())).thenReturn(insert, select, update);
+ when(apply.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("database unavailable"));
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(request, apply);
+
+ assertFalse(new SharedMysqlPurchaseJournal(fixture.table, false).incrementPeriodTotals(
+ java.util.UUID.randomUUID(), "player", "DailyTotal", "LastDailyTotal",
+ java.util.List.of("DailyTotal"), null));
+
+ verify(request).commit();
+ verify(request, org.mockito.Mockito.never()).rollback();
+ }
+
+ @Test
+ void completedAccountingRowsRemainForTheUnboundedReplayWindow() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement pending = mock(PreparedStatement.class);
+ ResultSet none = ids();
+ when(pending.executeQuery()).thenReturn(none);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(pending);
+
+ new SharedMysqlPurchaseJournal(fixture.table, false)
+ .recoverAccounting(java.util.concurrent.TimeUnit.DAYS.toMillis(90));
+
+ verify(fixture.work).prepareStatement(org.mockito.ArgumentMatchers.contains("requested"));
+ }
+
+ @Test
+ void claimedRewardIsSkippedWithoutStarvingLaterRecoverableReward() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement pending = mock(PreparedStatement.class);
+ ResultSet rows = mock(ResultSet.class);
+ UUID ambiguous = UUID.randomUUID();
+ UUID recoverable = UUID.randomUUID();
+ when(rows.next()).thenReturn(true, true, false);
+ when(rows.getString(1)).thenReturn(ambiguous.toString(), recoverable.toString());
+ when(rows.getString(2)).thenReturn("player-a", "player-b");
+ when(rows.getInt(3)).thenReturn(48, 48);
+ when(rows.getInt(4)).thenReturn(144, 16);
+ when(pending.executeQuery()).thenReturn(rows);
+ when(fixture.work.prepareStatement(anyString())).thenReturn(pending);
+
+ SharedMysqlPurchaseJournal.AccountingRecoveryBatch batch =
+ new SharedMysqlPurchaseJournal(fixture.table, false).recoverAccounting(1L);
+
+ assertEquals(java.util.List.of(recoverable), batch.pendingRewards());
+ org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.work).prepareStatement(sql.capture());
+ assertTrue(sql.getValue().contains("& 128"),
+ "claimed reward rows must be excluded from the bounded recovery page");
+ }
+
@Test
void ambiguousResetCommitIsConfirmedAfterTheConnectionIsReleased() throws Exception {
Fixture fixture = fixture();
@@ -570,6 +1368,29 @@ private static ResultSet pendingRow() throws Exception {
return pendingRow("PENDING");
}
+ private static ResultSet accountingRow(String uuid, int requested, int completed) throws Exception {
+ return accountingRow(uuid, requested, completed, null, null, null, false);
+ }
+
+ private static ResultSet accountingRow(String uuid, int requested, int completed, Integer points, Integer cap,
+ boolean replayUnsafe) throws Exception {
+ return accountingRow(uuid, requested, completed, points, cap, "persisted_Points", replayUnsafe);
+ }
+
+ private static ResultSet accountingRow(String uuid, int requested, int completed, Integer points, Integer cap,
+ String pointColumn, boolean replayUnsafe) throws Exception {
+ ResultSet row = mock(ResultSet.class);
+ when(row.next()).thenReturn(true);
+ when(row.getString(1)).thenReturn(uuid);
+ when(row.getInt(2)).thenReturn(requested);
+ when(row.getInt(3)).thenReturn(completed);
+ when(row.getObject(10)).thenReturn(points);
+ when(row.getObject(11)).thenReturn(cap);
+ when(row.getString(12)).thenReturn(pointColumn);
+ when(row.getInt(13)).thenReturn(replayUnsafe ? 1 : 0);
+ return row;
+ }
+
private static ResultSet pendingRow(String state) throws Exception {
ResultSet row = mock(ResultSet.class);
when(row.next()).thenReturn(true);
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java
index b167151eb9..ba359cc434 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java
@@ -54,6 +54,8 @@
import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
import com.bencodez.advancedcore.api.rewards.RewardHandler;
import com.bencodez.advancedcore.api.rewards.RewardOptions;
+import com.bencodez.advancedcore.api.time.TimeChangeTransition;
+import com.bencodez.advancedcore.api.time.TimeType;
import com.bencodez.simpleapi.folialib.enums.EntityTaskResult;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.user.SharedMysqlCacheReconciler;
@@ -62,6 +64,23 @@
import com.bencodez.votingplugin.voteshop.shop.VoteShopItem;
class VoteShopPurchaseServiceTest {
+ @Test
+ void deliveryCompletionAcknowledgesVotePointReceipt() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
+ com.bencodez.votingplugin.data.ServerData serverData =
+ mock(com.bencodez.votingplugin.data.ServerData.class);
+ when(plugin.getServerData()).thenReturn(serverData);
+ UUID voteId = UUID.randomUUID();
+
+ try (org.mockito.MockedStatic users = org.mockito.Mockito.mockStatic(VotingPluginUser.class)) {
+ VoteShopPurchaseService.completeVoteDelivery(plugin, voteId);
+ users.verify(() -> VotingPluginUser.acknowledgeVotePointDelivery(plugin, voteId));
+ }
+
+ verify(serverData).clearVoteReplayUnsafe(voteId);
+ }
+
@Test
void purchaseRecoveryEligibilityIgnoresCurrentPerServerPointsSetting() {
VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
@@ -174,6 +193,24 @@ void weeklyGenerationAndBoundaryIgnoreJvmDefaultLocale() {
}
}
+ @Test
+ void transitionResetGenerationUsesThePeriodKeyWithoutLocaleInterpretation() {
+ TimeChangeTransition week = mock(TimeChangeTransition.class);
+ when(week.getType()).thenReturn(TimeType.WEEK);
+ when(week.getPeriodKey()).thenReturn("2026-W38");
+ Locale previous = Locale.getDefault();
+ try {
+ Locale.setDefault(Locale.US);
+ String usGeneration = VoteShopPurchaseService.limitGenerationIdForTransition(week);
+ Locale.setDefault(Locale.GERMANY);
+ String germanGeneration = VoteShopPurchaseService.limitGenerationIdForTransition(week);
+ assertEquals("time-shop:WEEK:2026-W38", usGeneration);
+ assertEquals(usGeneration, germanGeneration);
+ } finally {
+ Locale.setDefault(previous);
+ }
+ }
+
@Test
void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception {
MySQL table = mock(MySQL.class);
@@ -1195,6 +1232,8 @@ void failedCompensationMarkerIsRetriedByPeriodicRecovery(@TempDir Path temporary
.thenThrow(new java.sql.SQLException("down"))
.thenThrow(new java.sql.SQLException("still down"))
.thenReturn(true);
+ when(journal.recoverAccounting(anyLong())).thenReturn(
+ new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(false, java.util.List.of()));
when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of());
VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit(
VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null);
@@ -1218,6 +1257,65 @@ void failedCompensationMarkerIsRetriedByPeriodicRecovery(@TempDir Path temporary
verify(scheduler, never()).runTaskAsynchronously(eq(plugin), any(Runnable.class));
}
+ @Test
+ void recoveredDailyStreakRunsRewardOnlyAfterThePersistedIncrement(@TempDir Path temporaryDirectory) throws Exception {
+ VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory);
+ when(plugin.getSpecialRewards()).thenReturn(mock(com.bencodez.votingplugin.specialrewards.SpecialRewards.class));
+ SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ java.util.UUID voteId = java.util.UUID.randomUUID();
+ java.util.UUID playerId = java.util.UUID.randomUUID();
+ when(journal.recoverAccounting(anyLong())).thenReturn(
+ new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(true, java.util.List.of(voteId)),
+ new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(false, java.util.List.of()));
+ when(journal.claimDailyStreakReward(voteId)).thenReturn(
+ new SharedMysqlPurchaseJournal.RecoveredDailyStreak(voteId, playerId.toString(), 7, true));
+ when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of());
+ when(plugin.getVotingPluginUserManager().getVotingPluginUser(playerId, false)).thenReturn(user);
+
+ VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal);
+
+ org.mockito.InOrder order = org.mockito.Mockito.inOrder(journal, user);
+ order.verify(journal).claimDailyStreakReward(voteId);
+ order.verify(user).completeRecoveredDailyStreak(7, true);
+ order.verify(journal).completeDailyStreakReward(voteId);
+ }
+
+ @Test
+ void deferredDailyStreakRecoveryYieldsUntilTheNextRecoveryPass(@TempDir Path temporaryDirectory) throws Exception {
+ VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory);
+ when(plugin.getSpecialRewards()).thenReturn(mock(com.bencodez.votingplugin.specialrewards.SpecialRewards.class));
+ SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class);
+ java.util.UUID voteId = java.util.UUID.randomUUID();
+ when(journal.recoverAccounting(anyLong())).thenReturn(
+ new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(true, java.util.List.of(voteId)));
+ when(journal.claimDailyStreakReward(voteId)).thenReturn(null);
+ when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of());
+
+ VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal);
+
+ verify(journal).recoverAccounting(anyLong());
+ verify(journal).claimDailyStreakReward(voteId);
+ verify(journal).recoverAndCleanup(anyLong());
+ }
+
+ @Test
+ void dailyStreakRecoveryDoesNotClaimRewardBeforeRewardServiceIsReady(@TempDir Path temporaryDirectory)
+ throws Exception {
+ VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory);
+ when(plugin.getSpecialRewards()).thenReturn(null);
+ SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class);
+ java.util.UUID voteId = java.util.UUID.randomUUID();
+ when(journal.recoverAccounting(anyLong())).thenReturn(
+ new SharedMysqlPurchaseJournal.AccountingRecoveryBatch(true, java.util.List.of(voteId)));
+ when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of());
+
+ VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal);
+
+ verify(journal, never()).claimDailyStreakReward(any());
+ verify(journal).recoverAndCleanup(anyLong());
+ }
+
private static VotingPluginMain mockPluginForCompensation(Path dataDirectory) {
VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile());