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());