diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index eb218bfdaf..54c6de4256 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -865,7 +865,7 @@ public void executeAll(CommandSender sender, String[] args) { @Override public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); - user.cache(); + if (!UserStorage.MYSQL.equals(plugin.getStorageType())) user.cache(); user.removePoints(Integer.parseInt(args[3]), removed -> { if (!removed) { runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize( diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index 87a136604a..6e8e092ea2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -67,6 +67,8 @@ private record PlaceholderClassification(List> all private record PlatformUpdateKey(UUID uuid, PlaceHolder placeholder) { } private final ConcurrentHashMap platformUpdateGenerations = new ConcurrentHashMap<>(); private boolean userDataChangeListenerRegistered; + private final PointPlaceholderFence pointFence = new PointPlaceholderFence(); + private final ConcurrentHashMap pointRecoveryWorkers = new ConcurrentHashMap<>(); @Getter private PlaceholderCacheLevel cacheLevel; @@ -185,6 +187,69 @@ public String getPlaceHolder(Player p, String identifier) { * @return the placeholder value */ public String getPlaceholderValue(OfflinePlayer p, String identifier, boolean javascript, boolean forceProcess, + boolean useCache) { + if (p == null || !isPointIdentifier(identifier)) { + return getPlaceholderValueUnfenced(p, identifier, javascript, forceProcess, useCache); + } + VotingPluginUser pointUser = resolvePlaceholderUser(p); + UUID uuid = pointUser.getJavaUUID(); + var generation = pointFence.generation(uuid); + try { + if (!pointFence.readable(uuid, generation)) { + if (pointFence.recoverable(uuid)) schedulePointRecovery(pointUser); + return "..."; + } + if (com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType())) { + var snapshot = publishedPointSnapshot(pointUser); + if (snapshot == null) { + schedulePointRecovery(pointUser); + return "..."; + } + // A complete row with invalid data is unavailable, not a missing snapshot to reload. + if (pointBalance(snapshot, pointUser.getPointsPath()) == null) return "..."; + } + String value = getPlaceholderValueUnfenced(p, identifier, javascript, forceProcess, useCache); + return pointFence.readable(uuid, generation) ? value : "..."; + } finally { + pointFence.release(uuid); + } + } + + private java.util.Map publishedPointSnapshot(VotingPluginUser user) { + var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(user.getJavaUUID()); + return cache == null ? null : cache.snapshotIfPublished(); + } + + private Integer pointBalance(VotingPluginUser user) { + if (!com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType())) return user.getPoints(); + return pointBalance(publishedPointSnapshot(user), user.getPointsPath()); + } + + private Integer pointBalance(java.util.Map snapshot, String column) { + if (snapshot == null) return null; + // AC publishes a complete row; absence has the historical integer default. + if (!snapshot.containsKey(column)) return 0; + var value = snapshot.get(column); + if (value == null) return null; + try { + if (value.isInt()) return value.getInt(); + if (value.isString()) return Integer.valueOf(value.getString()); + } catch (NumberFormatException malformed) { + return null; + } + return null; + } + + private void schedulePointRecovery(VotingPluginUser user) { + schedulePlaceholderCheck(user); + } + + private static boolean isPointIdentifier(String identifier) { + String key = identifier.toLowerCase(Locale.ROOT); + return key.equals("points") || key.equals("points_format") || key.startsWith("enoughpoints_"); + } + + private String getPlaceholderValueUnfenced(OfflinePlayer p, String identifier, boolean javascript, boolean forceProcess, boolean useCache) { if (plugin.getConfigFile().isUseJavascriptPlaceholders() && javascript && p != null) { identifier = PlaceholderUtils.replaceJavascript(p, identifier); @@ -498,7 +563,8 @@ public String placeholderRequest(VotingPluginUser user, String identifier) { @Override public String placeholderRequest(VotingPluginUser user, String identifier) { - return Integer.toString(user.getPoints()); + Integer points = pointBalance(user); + return points == null ? "..." : Integer.toString(points); } }.withDescription("User points").updateDataKey("Points")); @@ -507,7 +573,8 @@ public String placeholderRequest(VotingPluginUser user, String identifier) { @Override public String placeholderRequest(VotingPluginUser user, String identifier) { NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.US); - return numberFormat.format(user.getPoints()); + Integer points = pointBalance(user); + return points == null ? "..." : numberFormat.format(points); } }.withDescription("User points").updateDataKey("Points")); @@ -633,7 +700,9 @@ public String placeholderRequest(VotingPluginUser user, String identifier) { return "invalid"; } int number = Integer.parseInt(identifier.split("_")[1]); - if (user.getPoints() >= number) { + Integer points = pointBalance(user); + if (points == null) return "..."; + if (points >= number) { return "true"; } return "false"; @@ -1415,6 +1484,13 @@ private boolean shouldRefresh(PlaceHolder placeholder, UUID uu @SuppressWarnings("unchecked") private void updateCachedPlaceholder(PlaceHolder placeholder, VotingPluginUser user, UUID uuid, String... keys) { + var pointGeneration = pointFence.generation(uuid); + try { + if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey()) + && !pointFence.readable(uuid, pointGeneration)) { + if (pointFence.recoverable(uuid)) schedulePointRecovery(user); + return; + } for (String ident : Set.copyOf(placeholder.getCache().keySet())) { ConcurrentHashMap values = placeholder.getCache().get(ident); if (values == null) continue; @@ -1435,7 +1511,11 @@ private void updateCachedPlaceholder(PlaceHolder placeholder, } } else { String value = placeholder.placeholderRequest(user, ident); - values.put(uuid, value); + if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey())) { + pointFence.publish(uuid, pointGeneration, () -> { + if (plugin.isEnabled()) values.put(uuid, value); + }); + } else values.put(uuid, value); if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) { values.remove(uuid); continue; @@ -1444,6 +1524,9 @@ private void updateCachedPlaceholder(PlaceHolder placeholder, + matchingKey(placeholder, keys) + " with " + value); } } + } finally { + pointFence.release(uuid); + } } private void publishCachedValue(PlaceHolder placeholder, VotingPluginUser user, UUID uuid, @@ -1468,6 +1551,52 @@ private String matchingKey(PlaceHolder placeholder, String... return placeholder.getUpdateDataKey(); } + /** Blocks stale point results before a shared debit enters its worker. */ + public void beginSharedPointDebit(UUID uuid) { + pointFence.begin(uuid, () -> clearPointResults(uuid)); + } + + /** Owns a queued batch without replacing its worker's mutation outcome. */ + public java.util.function.Consumer sharedPointAdmissionCompletion(UUID uuid) { + beginSharedPointDebit(uuid); + var completed = new java.util.concurrent.atomic.AtomicBoolean(); + return settled -> { + if (completed.compareAndSet(false, true)) pointFence.finishAdmission(uuid, + settled && plugin.isEnabled() && plugin.getPlaceholders() == this); + }; + } + + private void clearPointResults(UUID uuid) { + for (PlaceHolder placeholder : userDataChangeClassification.all()) { + if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey()) && placeholder.isUsesCache()) { + placeholder.clearCachePlayer(uuid); + } + } + } + + /** Completes the fence only after the storage worker has reconciled its snapshot. */ + public void finishSharedPointDebit(UUID uuid, boolean trustworthySnapshot) { + finishSharedPointDebit(uuid, trustworthySnapshot, trustworthySnapshot); + } + + public void finishSharedPointDebit(UUID uuid, boolean knownOutcome, boolean trustworthySnapshot) { + pointFence.finish(uuid, knownOutcome, trustworthySnapshot); + } + + /** Captures a worker refill generation without granting permission to retry a mutation. */ + public java.util.function.Consumer sharedPointRefillCompletion(UUID uuid) { + var stamp = pointFence.beginRefill(uuid, () -> clearPointResults(uuid)); + var completed = new java.util.concurrent.atomic.AtomicBoolean(); + return verified -> { + if (!completed.compareAndSet(false, true)) return; + try { + pointFence.finishRefill(uuid, stamp, verified && plugin.isEnabled() && plugin.getPlaceholders() == this); + } finally { + pointFence.release(uuid); + } + }; + } + /** * Handle player logout. * @param user the voting plugin user @@ -1518,6 +1647,7 @@ public void onUpdate() { public void onUpdate(VotingPluginUser user, boolean login) { if (user == null) return; UUID uuid = user.getJavaUUID(); + if (pointFence.recoverable(uuid)) schedulePointRecovery(user); Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); PlaceholderClassification classification = userDataChangeClassification; List> platformUpdates = new ArrayList<>(); @@ -1559,6 +1689,8 @@ public void onVotePartyUpdate() { * Reload placeholders. */ public void reload() { + pointFence.invalidateLifecycle(); + pointRecoveryWorkers.clear(); platformUpdateGenerations.clear(); plugin.refreshPlaceholderPlayerPresence(); cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel(); @@ -1592,16 +1724,47 @@ VotingPluginUser resolvePlaceholderUser(OfflinePlayer player) { * Schedule placeholder check for user. * @param user the voting plugin user */ - public void schedulePlaceholderCheck(VotingPluginUser user) { - plugin.getTimer().execute(new Runnable() { - - @Override - public void run() { - checkNonCachedPlaceholders(); - // Admit cold offline entries only from this worker callback. - onUpdate(user, !getCacheLevel().onlineOnly() - || plugin.getPlaceholderPlayerPresence().isOnline(user.getJavaUUID())); - } - }); - } + public void schedulePlaceholderCheck(VotingPluginUser user) { + if (!plugin.isEnabled() || plugin.getPlaceholders() != this) return; + UUID uuid = user.getJavaUUID(); + Object token = new Object(); + long lifecycle = pointFence.lifecycle(); + // Common warming and direct point recovery share queued and executing admission. + if (pointRecoveryWorkers.putIfAbsent(uuid, token) != null) return; + try { + plugin.getTimer().execute(() -> { + boolean warmed = false; + try { + if (!plugin.isEnabled() || plugin.getPlaceholders() != this || lifecycle != pointFence.lifecycle()) return; + var observed = pointFence.generation(uuid); + try { + // Invalid data in a complete published row is not a missing snapshot. + // Pending/unknown writes cannot authorize a competing point refill. + if (pointFence.recoverable(uuid) + || (pointFence.readable(uuid, observed) + && com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType()) + && publishedPointSnapshot(user) == null)) { + com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker( + plugin, user.getUUID(), user.getPointsPath()); + } + } finally { pointFence.release(uuid); } + if (!plugin.isEnabled() || plugin.getPlaceholders() != this || lifecycle != pointFence.lifecycle()) return; + checkNonCachedPlaceholders(); + // Preserve each cache level's existing offline-entry admission policy. + onUpdate(user, !getCacheLevel().onlineOnly() || plugin.getPlaceholderPlayerPresence().isOnline(uuid)); + warmed = true; + } finally { + boolean retired = pointRecoveryWorkers.remove(uuid, token); + // A new identifier may have arrived after this worker's discovery pass. + if (retired && warmed && !placeholdersToSetCacheOn.isEmpty() && plugin.isEnabled() + && plugin.getPlaceholders() == this && lifecycle == pointFence.lifecycle()) { + schedulePlaceholderCheck(user); + } + } + }); + } catch (RuntimeException rejected) { + pointRecoveryWorkers.remove(uuid, token); + plugin.debug(rejected); + } + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PointPlaceholderFence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PointPlaceholderFence.java new file mode 100644 index 0000000000..2a15afa668 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PointPlaceholderFence.java @@ -0,0 +1,107 @@ +package com.bencodez.votingplugin.placeholders; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** Coordinates point rendering with in-flight shared-storage mutations. */ +final class PointPlaceholderFence { + private static final class State { + long generation; + int pending; + int readers; + int refills; + boolean uncertain; + boolean snapshotUnavailable; + } + + private final Map states = new HashMap<>(); + private long generation; + private long lifecycle; + record Stamp(long lifecycle, long generation) { } + + synchronized Stamp generation(UUID uuid) { + State state = states.computeIfAbsent(uuid, ignored -> { + State created = new State(); + created.generation = ++generation; + return created; + }); + state.readers++; + return new Stamp(lifecycle, state.generation); + } + + synchronized boolean readable(UUID uuid, Stamp observed) { + if (lifecycle != observed.lifecycle()) return false; + State state = states.get(uuid); + return state != null && state.generation == observed.generation() + && state.pending == 0 && state.refills == 0 && !state.uncertain && !state.snapshotUnavailable; + } + + synchronized void release(UUID uuid) { + State state = states.get(uuid); + if (state == null || state.readers == 0) throw new IllegalStateException("Unbalanced point reader"); + state.readers--; + retireIfSafe(uuid, state); + } + + private void retireIfSafe(UUID uuid, State state) { + if (state.readers == 0 && state.pending == 0 && state.refills == 0 && !state.uncertain && !state.snapshotUnavailable) states.remove(uuid); + } + + synchronized void invalidateLifecycle() { lifecycle++; } + synchronized long lifecycle() { return lifecycle; } + synchronized boolean recoverable(UUID uuid) { + State state = states.get(uuid); + return state != null && state.snapshotUnavailable && !state.uncertain && state.pending == 0 && state.refills == 0; + } + + synchronized Stamp beginRefill(UUID uuid, Runnable invalidate) { + State state = states.computeIfAbsent(uuid, ignored -> new State()); + state.generation = ++generation; + state.snapshotUnavailable = true; + state.refills++; + state.readers++; + invalidate.run(); + return new Stamp(lifecycle, state.generation); + } + + synchronized void begin(UUID uuid, Runnable invalidate) { + State state = states.computeIfAbsent(uuid, ignored -> new State()); + state.generation = ++generation; + state.pending++; + invalidate.run(); + } + + synchronized void finish(UUID uuid, boolean knownOutcome, boolean trustworthy) { + State state = states.get(uuid); + if (state == null || state.pending == 0) throw new IllegalStateException("Unbalanced point mutation"); + state.uncertain |= !knownOutcome; + state.snapshotUnavailable = !trustworthy || state.refills != 0; + state.pending--; + state.generation = ++generation; + retireIfSafe(uuid, state); + } + + synchronized void finishAdmission(UUID uuid, boolean workerSettled) { + State state = states.get(uuid); + if (state == null || state.pending == 0) throw new IllegalStateException("Unbalanced point admission"); + state.pending--; + state.snapshotUnavailable |= !workerSettled; + state.generation = ++generation; + retireIfSafe(uuid, state); + } + + synchronized void finishRefill(UUID uuid, Stamp observed, boolean verified) { + State state = states.get(uuid); + if (state == null || state.refills == 0) throw new IllegalStateException("Unbalanced point refill"); + boolean current = lifecycle == observed.lifecycle() && state.generation == observed.generation(); + state.refills--; + state.snapshotUnavailable = !(verified && current && state.pending == 0 && state.refills == 0 && !state.uncertain); + // Even an obsolete read completion can replace storage data: retire every older render. + state.generation = ++generation; + } + + synchronized void publish(UUID uuid, Stamp observed, Runnable publication) { + if (readable(uuid, observed)) publication.run(); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java index f6d9dc09c8..d6d18da20c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -93,10 +93,20 @@ public static void invalidate(VotingPluginMain plugin, String uuid, String... co public static void invalidateAndRefresh(VotingPluginMain plugin, String uuid, String... columns) { invalidate(plugin, uuid, columns); if (plugin == null || uuid == null) return; + String pointColumn = plugin.getBungeeSettings().isPerServerPoints() + ? plugin.getBungeeSettings().getServerNameStorage() + "_Points" : "Points"; + boolean affectsPoints = columns != null && java.util.Arrays.asList(columns).contains(pointColumn); + var placeholders = plugin.getPlaceholders(); + java.util.function.Consumer completion = affectsPoints && placeholders != null + ? placeholders.sharedPointRefillCompletion(UUID.fromString(uuid)) : null; try { - plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuid), false).cacheAsync(); + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + () -> tryInvalidateAndRefreshOnWorker(plugin, uuid, columns)); } catch (RuntimeException refreshFailure) { plugin.debug(refreshFailure); + } finally { + // This admission is not storage evidence. Only the actual worker can recover rendering. + if (completion != null) completion.accept(false); } } @@ -106,13 +116,37 @@ public static void invalidateAndRefresh(VotingPluginMain plugin, String uuid, St * A failed refill leaves the snapshot explicitly unavailable, not false zero. */ public static void invalidateAndRefreshOnWorker(VotingPluginMain plugin, String uuid, String... columns) { - invalidate(plugin, uuid, columns); - if (plugin == null || uuid == null) return; + tryInvalidateAndRefreshOnWorker(plugin, uuid, columns); + } + + static boolean tryInvalidateAndRefreshOnWorker(VotingPluginMain plugin, String uuid, String... columns) { + if (plugin == null || uuid == null) return false; + UUID playerUuid = UUID.fromString(uuid); + String pointColumn = plugin.getBungeeSettings().isPerServerPoints() + ? plugin.getBungeeSettings().getServerNameStorage() + "_Points" : "Points"; + boolean affectsPoints = columns != null && java.util.Arrays.asList(columns).contains(pointColumn); + var placeholders = plugin.getPlaceholders(); + java.util.function.Consumer completion = affectsPoints && placeholders != null + ? placeholders.sharedPointRefillCompletion(playerUuid) : null; + boolean verified = false; try { - plugin.getUserManager().getDataManager().cacheUser(UUID.fromString(uuid), null); + invalidate(plugin, uuid, columns); + var manager = plugin.getUserManager().getDataManager(); + manager.cacheUser(playerUuid, null); + var cache = manager.getUserDataCache().get(playerUuid); + if (cache != null) synchronized (cache) { + // A complete published row may legitimately omit the selected points key. + // Rendering separately rejects present malformed/unsupported values. + verified = manager.getUserDataCache().get(playerUuid) == cache + && cache.hasPublishedStorageSnapshot(); + } + return true; } catch (RuntimeException refreshFailure) { plugin.getLogger().warning("Shared MySQL cache refresh is unavailable after mutation; stale data was not published."); plugin.debug(refreshFailure); + return false; + } finally { + if (completion != null) completion.accept(verified); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 375bd943e9..ca55ae8140 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -185,16 +185,29 @@ AddResult addCommitted(VotingPluginUser user, int amount, String operationId) { AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) { if (operationId == null || operationId.isEmpty()) return addCommittedToColumn(user, amount, pointsColumn); - drainCache(user, pointsColumn); + var placeholders = plugin.getPlaceholders(); + UUID uuid = UUID.fromString(user.getUUID()); + if (placeholders != null) placeholders.beginSharedPointDebit(uuid); + boolean confirmed = false; try { + drainCache(user, pointsColumn); SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) .add(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis()); + confirmed = true; return new AddResult(true, result.total()); } catch (SQLException failure) { logFailure(failure); return new AddResult(MutationOutcome.INDETERMINATE, 0); } finally { - discardPointsCache(user, pointsColumn); + boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), pointsColumn); + if (placeholders != null) { + var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(uuid); + boolean available = false; + if (cache != null) synchronized (cache) { + available = refreshed && confirmed && cache.hasPublishedStorageSnapshot(); + } + placeholders.finishSharedPointDebit(uuid, confirmed, available); + } } } @@ -209,18 +222,36 @@ AddResult removeCommitted(VotingPluginUser user, int amount, String operationId) AddResult removeCommittedFromColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) { if (operationId == null || operationId.isEmpty()) return new AddResult(false, 0); - drainCache(user, pointsColumn); + var placeholders = plugin.getPlaceholders(); + UUID uuid = UUID.fromString(user.getUUID()); + if (placeholders != null) placeholders.beginSharedPointDebit(uuid); + AddResult outcome = new AddResult(MutationOutcome.INDETERMINATE, 0); + boolean refreshed; try { + drainCache(user, pointsColumn); SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) .subtract(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis()); - return new AddResult(true, result.total()); + outcome = new AddResult(true, result.total()); } catch (SQLException failure) { - logFailure(failure); - return new AddResult(failure instanceof SharedPointAdditionJournal.DebitRejectedException - ? MutationOutcome.REJECTED : MutationOutcome.INDETERMINATE, 0); + if (failure instanceof SharedPointAdditionJournal.DebitRejectedException) { + outcome = new AddResult(MutationOutcome.REJECTED, 0); + } else { + logFailure(failure); + outcome = new AddResult(MutationOutcome.INDETERMINATE, 0); + } } finally { - discardPointsCache(user, pointsColumn); + refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), pointsColumn); + if (placeholders != null) { + var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(uuid); + boolean available = false; + if (cache != null) synchronized (cache) { + available = refreshed && outcome.outcome() != MutationOutcome.INDETERMINATE + && cache.hasPublishedStorageSnapshot(); + } + placeholders.finishSharedPointDebit(uuid, outcome.outcome() != MutationOutcome.INDETERMINATE, available); + } } + return new AddResult(outcome.outcome(), outcome.total(), refreshed); } /** Looks up a completed retry before its Bukkit receive event is dispatched. */ @@ -326,7 +357,20 @@ void acknowledgePointAdditionNow(String operationId) { boolean set(VotingPluginUser user, int value, boolean async) { String pointsColumn = user.getPointsPath(); - return run(() -> setAbsolute(user, value, pointsColumn), async); + var placeholders = plugin.getPlaceholders(); + Consumer admission = placeholders == null ? null + : placeholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID())); + boolean accepted = run(() -> { + boolean settled = false; + try { + setAbsolute(user, value, pointsColumn); + settled = true; + } finally { + if (admission != null) admission.accept(settled); + } + }, async); + if (!accepted && admission != null) admission.accept(false); + return accepted; } boolean setCommitted(VotingPluginUser user, int value) { @@ -498,41 +542,40 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : sourcePlayer; String sourcePoints = source.getPointsPath(); String targetPoints = target.getPointsPath(); + var reservationPublication = beginPointPublication(source); try { plugin.getTimer().execute(() -> { - try { - drainCache(source); - drainCache(target); - } catch (RuntimeException cacheFailure) { - plugin.debug(cacheFailure); - completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); - return; - } MySQL table = plugin.getMysql(); String transferId = UUID.randomUUID().toString(); String owner = UUID.randomUUID().toString(); - SharedPointTransferJournal journal; + SharedPointTransferJournal prepared = null; + boolean known = true; + boolean reserved = false; + PointTransferResult failed = PointTransferResult.UNAVAILABLE; try { - journal = SharedPointTransferJournal.forTable(table); - recoverTransfers(plugin, journal); - if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), - debitAmount, System.currentTimeMillis())) { - completeOnBukkit(sourcePlayer, completion, PointTransferResult.INSUFFICIENT_POINTS); - return; - } - // The source cache may have been recreated while the reservation was being - // committed. Invalidate its points after the durable debit, before any later - // dump can restore the pre-debit balance. - discardPointsCache(source, sourcePoints); + drainCache(source); + drainCache(target); + prepared = SharedPointTransferJournal.forTable(table); + recoverTransfers(plugin, prepared); + known = false; + reserved = prepared.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, + target.getUUID(), debitAmount, System.currentTimeMillis()); + known = true; + failed = PointTransferResult.INSUFFICIENT_POINTS; } catch (SQLException failure) { - // An acknowledgement/confirmation failure can follow a committed - // reservation debit. Never allow a cache recreated during the unknown - // outcome to flush the pre-debit source balance over it. - discardPointsCache(source, sourcePoints); + known |= failure instanceof SharedPointTransferJournal.PreparationFailure; logFailure(failure); - completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + } catch (RuntimeException cacheFailure) { + plugin.debug(cacheFailure); + } finally { + boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints); + finishPointPublication(reservationPublication, source, sourcePoints, known, refreshed); + } + if (!reserved) { + completeOnBukkit(sourcePlayer, completion, failed); return; } + SharedPointTransferJournal journal = prepared; /* * Do not claim the reservation until the Bukkit approval task has actually @@ -562,6 +605,7 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target // No reservation exists when the initial persistence task is rejected. // Still complete the command contract on the source entity lane. plugin.debug(schedulingFailure); + finishPointPublication(reservationPublication, source, sourcePoints, true, false); completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); } } @@ -574,8 +618,7 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser System.currentTimeMillis()); if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) { try { - journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); - discardPointsCache(source, sourcePoints); + refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount); } catch (SQLException failure) { logFailure(failure); } @@ -713,10 +756,7 @@ private void compensateRejectedTransfer(VotingPluginUser source, org.bukkit.enti } catch (SQLException markerFailure) { boolean refunded = false; try { - refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); - if (refunded) { - discardPointsCache(source, sourcePoints); - } + refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount); } catch (SQLException refundFailure) { logFailure(refundFailure); } @@ -736,17 +776,24 @@ private void settleTransfer(VotingPluginUser source, VotingPluginUser target, or int debitAmount, Consumer completion, SharedPointTransferJournal journal, String transferId, String owner, String sourcePoints, String targetPoints, Integer approvedAmount) { + var sourcePublication = beginPointPublication(source); + var targetPublication = beginPointPublication(target); + boolean known = false; PointTransferResult result; try { // The hook may have recreated either cache while it ran on Bukkit. - drainCache(target); + SharedPointTransferJournal.SettlementOutcome outcome; try { + drainCache(target); outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, target.getUUID(), targetPoints, debitAmount, approvedAmount); + known = outcome != SharedPointTransferJournal.SettlementOutcome.INDETERMINATE; } finally { - discardPointsCache(source, sourcePoints); - discardPointsCache(target, targetPoints); + boolean sourceRefreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints); + boolean targetRefreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, target.getUUID(), targetPoints); + finishPointPublication(sourcePublication, source, sourcePoints, known, sourceRefreshed); + finishPointPublication(targetPublication, target, targetPoints, known, targetRefreshed); } result = outcome == SharedPointTransferJournal.SettlementOutcome.COMPLETED ? PointTransferResult.SUCCESS : outcome == SharedPointTransferJournal.SettlementOutcome.REFUNDED ? PointTransferResult.CANCELLED @@ -760,14 +807,29 @@ private void settleTransfer(VotingPluginUser source, VotingPluginUser target, or completeOnBukkit(sourcePlayer, completion, result); } + private boolean refundReservedWithPublication(VotingPluginUser source, SharedPointTransferJournal journal, + String transferId, String sourcePoints, int debitAmount) throws SQLException { + var publication = beginPointPublication(source); + boolean known = false; + try { + boolean refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + known = true; + return refunded; + } catch (SQLException failure) { + known = failure instanceof SharedPointTransferJournal.PreparationFailure; + throw failure; + } finally { + boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints); + finishPointPublication(publication, source, sourcePoints, known, refreshed); + } + } + private void refundReservedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { try { - if (journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount)) { - discardPointsCache(source, sourcePoints); - } + refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount); } catch (SQLException refundFailure) { logFailure(refundFailure); } @@ -783,24 +845,23 @@ private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, org // HOOK_STARTED is safe to compensate because the approval task has not // been submitted yet. A RESERVED row is handled by its normal refund. if (journal.markCompensating(transferId)) { - refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount); } else { - refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount); } } catch (SQLException markerFailure) { // A lost marker acknowledgement may still have committed. Both refund // operations are idempotent and cover either durable pre-hook state. try { - refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount); if (!refunded) { - refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount); } } catch (SQLException refundFailure) { logFailure(refundFailure); } logFailure(markerFailure); } - if (refunded) discardPointsCache(source, sourcePoints); if (!refunded) { rememberPendingCompensationMarker(plugin, transferId); logIndeterminateClaim(transferId); @@ -815,19 +876,32 @@ void completeRejectedPersistenceSubmission(org.bukkit.entity.Player sourcePlayer completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); } + private boolean refundClaimedWithPublication(VotingPluginUser source, SharedPointTransferJournal journal, + String transferId, String sourcePoints, int debitAmount) throws SQLException { + var publication = beginPointPublication(source); + boolean known = false; + try { + boolean refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + known = true; + return refunded; + } catch (SQLException refundFailure) { + known = refundFailure instanceof SharedPointTransferJournal.PreparationFailure; + throw refundFailure; + } finally { + // A later guarded fallback or refill cannot erase an earlier ambiguous write. + boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints); + finishPointPublication(publication, source, sourcePoints, known, refreshed); + } + } + void refundClaimedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { try { - journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount); } catch (SQLException refundFailure) { logFailure(refundFailure); - } finally { - // The refund may have committed even when its acknowledgement and - // confirmation read both failed. Invalidate a cache recreated after the - // original debit so it cannot later overwrite either durable outcome. - discardPointsCache(source, sourcePoints); } plugin.debug(failure); completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); @@ -990,9 +1064,12 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount, S enum MutationOutcome { CONFIRMED, REJECTED, INDETERMINATE } - record AddResult(MutationOutcome outcome, int total) { + record AddResult(MutationOutcome outcome, int total, boolean sourceRefreshed) { + AddResult(MutationOutcome outcome, int total) { + this(outcome, total, false); + } AddResult(boolean success, int total) { - this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total); + this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total, false); } boolean success() { return outcome == MutationOutcome.CONFIRMED; } } @@ -1002,21 +1079,29 @@ private boolean setAbsolute(VotingPluginUser user, int value) { } private boolean setAbsolute(VotingPluginUser user, int value, String points) { - drainCache(user, points); - MySQL table = plugin.getMysql(); - String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) - + " = ? WHERE " + table.qi("uuid") - + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); - try (Connection connection = requireConnection(table); - PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setInt(1, value); - statement.setString(2, user.getUUID()); - return statement.executeUpdate() == 1; - } catch (SQLException failure) { - logFailure(failure); - return false; + var publication = beginPointPublication(user); + boolean known = true; + try { + drainCache(user, points); + MySQL table = plugin.getMysql(); + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + + " = ? WHERE " + table.qi("uuid") + + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + try (Connection connection = requireConnection(table); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, value); + statement.setString(2, user.getUUID()); + known = false; + int affected = statement.executeUpdate(); + known = true; + return affected == 1; + } catch (SQLException failure) { + logFailure(failure); + return false; + } } finally { - discardPointsCache(user, points); + boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points); + finishPointPublication(publication, user, points, known, refreshed); } } @@ -1101,6 +1186,27 @@ private static Connection requireConnection(MySQL table) throws SQLException { * deliberately not discarded here because it is a distinct, later write and * the generic absolute-value API cannot provide cross-server atomic semantics. */ + private com.bencodez.votingplugin.placeholders.PlaceHolders beginPointPublication(VotingPluginUser user) { + var placeholders = plugin.getPlaceholders(); + if (placeholders != null) placeholders.beginSharedPointDebit(UUID.fromString(user.getUUID())); + return placeholders; + } + + private void finishPointPublication(com.bencodez.votingplugin.placeholders.PlaceHolders placeholders, + VotingPluginUser user, String column, boolean known, boolean refreshed) { + if (placeholders == null) return; + UUID uuid = UUID.fromString(user.getUUID()); + var caches = plugin.getUserManager().getDataManager().getUserDataCache(); + var cache = caches.get(uuid); + boolean available = false; + if (cache != null) synchronized (cache) { + available = known && refreshed && caches.get(uuid) == cache && cache.hasPublishedStorageSnapshot() + && column.equals(user.getPointsPath()) + && plugin.isEnabled() && plugin.getPlaceholders() == placeholders; + } + placeholders.finishSharedPointDebit(uuid, known, available); + } + private void discardPointsCache(VotingPluginUser user) { discardPointsCache(user, user.getPointsPath()); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java index c2f7281ee8..42cfa062b4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java @@ -28,6 +28,14 @@ * settlement is a second short, idempotent transaction.

*/ final class SharedPointTransferJournal { + /** Positive proof that this invocation did not attempt a points write. */ + static final class PreparationFailure extends SQLException { + private static final long serialVersionUID = 1L; + PreparationFailure(SQLException cause) { + super(cause.getMessage(), cause.getSQLState(), cause.getErrorCode(), cause); + } + } + private static final String RESERVED = "RESERVED"; private static final String HOOK_STARTED = "HOOK_STARTED"; /* @@ -144,51 +152,59 @@ public boolean equals(Object other) { */ boolean reserve(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints, String targetUuid, int requestedCreditPoints, long now) throws SQLException { - TransferRow existing = find(transferId); - if (existing != null) { - return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints) - && !REFUNDED.equals(existing.state); - } + boolean pointWriteAttempted = false; + try { + TransferRow existing = find(transferId); + if (existing != null) { + return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints) + && !REFUNDED.equals(existing.state); + } - String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid") - + ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points") - + ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at") - + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; - String points = qi(sourcePointsColumn); - String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points - + " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?"; - try (Connection connection = connection()) { - connection.setAutoCommit(false); - try (PreparedStatement insertStatement = connection.prepareStatement(insert); - PreparedStatement debitStatement = connection.prepareStatement(debit)) { - insertStatement.setString(1, transferId); - insertStatement.setString(2, sourceUuid); - insertStatement.setString(3, sourcePointsColumn); - insertStatement.setString(4, targetUuid); - insertStatement.setInt(5, debitPoints); - insertStatement.setInt(6, requestedCreditPoints); - insertStatement.setString(7, RESERVED); - insertStatement.setLong(8, now); - insertStatement.executeUpdate(); - - debitStatement.setInt(1, debitPoints); - debitStatement.setString(2, sourceUuid); - debitStatement.setInt(3, debitPoints); - if (debitStatement.executeUpdate() != 1) { - connection.rollback(); - return false; - } - return commitAndConfirm(connection, transferId, RESERVED); - } catch (SQLException failure) { - rollback(connection); - if (isDuplicate(failure)) { - closeQuietly(connection); - TransferRow duplicate = find(transferId); - return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints, - requestedCreditPoints) && !REFUNDED.equals(duplicate.state); + String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid") + + ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points") + + ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at") + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + String points = qi(sourcePointsColumn); + String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement insertStatement = connection.prepareStatement(insert); + PreparedStatement debitStatement = connection.prepareStatement(debit)) { + insertStatement.setString(1, transferId); + insertStatement.setString(2, sourceUuid); + insertStatement.setString(3, sourcePointsColumn); + insertStatement.setString(4, targetUuid); + insertStatement.setInt(5, debitPoints); + insertStatement.setInt(6, requestedCreditPoints); + insertStatement.setString(7, RESERVED); + insertStatement.setLong(8, now); + insertStatement.executeUpdate(); + + debitStatement.setInt(1, debitPoints); + debitStatement.setString(2, sourceUuid); + debitStatement.setInt(3, debitPoints); + pointWriteAttempted = true; + if (debitStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + return commitAndConfirm(connection, transferId, RESERVED); + } catch (SQLException failure) { + rollback(connection); + if (isDuplicate(failure)) { + closeQuietly(connection); + TransferRow duplicate = find(transferId); + return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints, + requestedCreditPoints) && !REFUNDED.equals(duplicate.state); + } + throw failure; } - throw failure; } + } catch (SQLException failure) { + if (failure instanceof PreparationFailure) throw failure; + if (!pointWriteAttempted) throw new PreparationFailure(failure); + throw failure; } } @@ -286,42 +302,50 @@ boolean markCompensating(String transferId) throws SQLException { */ boolean refundReserved(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints) throws SQLException { - if (!isSafeColumn(sourcePointsColumn)) return false; - String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") - + " = ? FOR UPDATE"; - String points = qi(sourcePointsColumn); - String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points - + " + ? WHERE " + qi("uuid") + uuidCast(); - String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id") - + " = ?"; - try (Connection connection = connection()) { - connection.setAutoCommit(false); - try (PreparedStatement selectStatement = connection.prepareStatement(select)) { - selectStatement.setString(1, transferId); - try (ResultSet result = selectStatement.executeQuery()) { - if (!result.next() || !RESERVED.equals(result.getString(1))) { + boolean pointWriteAttempted = false; + try { + if (!isSafeColumn(sourcePointsColumn)) return false; + String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") + + " = ? FOR UPDATE"; + String points = qi(sourcePointsColumn); + String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id") + + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next() || !RESERVED.equals(result.getString(1))) { + connection.rollback(); + return false; + } + } + } + try (PreparedStatement refundStatement = connection.prepareStatement(refund)) { + refundStatement.setInt(1, debitPoints); + refundStatement.setString(2, sourceUuid); + pointWriteAttempted = true; + if (refundStatement.executeUpdate() != 1) { connection.rollback(); return false; } } - } - try (PreparedStatement refundStatement = connection.prepareStatement(refund)) { - refundStatement.setInt(1, debitPoints); - refundStatement.setString(2, sourceUuid); - if (refundStatement.executeUpdate() != 1) { - connection.rollback(); - return false; - } - } - try (PreparedStatement updateStatement = connection.prepareStatement(update)) { - updateStatement.setString(1, REFUNDED); - updateStatement.setString(2, transferId); - if (updateStatement.executeUpdate() != 1) { - connection.rollback(); - return false; + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, REFUNDED); + updateStatement.setString(2, transferId); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } } + return commitAndConfirm(connection, transferId, REFUNDED); } - return commitAndConfirm(connection, transferId, REFUNDED); + } catch (SQLException failure) { + if (failure instanceof PreparationFailure) throw failure; + if (!pointWriteAttempted) throw new PreparationFailure(failure); + throw failure; } } @@ -340,6 +364,7 @@ boolean refundHookStarted(String transferId, String sourceUuid, String sourcePoi + " + ? WHERE " + qi("uuid") + uuidCast(); String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id") + " = ?"; + boolean pointWriteAttempted = false; try (Connection connection = connection()) { connection.setAutoCommit(false); try (PreparedStatement selectStatement = connection.prepareStatement(select)) { @@ -363,6 +388,7 @@ boolean refundHookStarted(String transferId, String sourceUuid, String sourcePoi try (PreparedStatement refundStatement = connection.prepareStatement(refund)) { refundStatement.setInt(1, debitPoints); refundStatement.setString(2, sourceUuid); + pointWriteAttempted = true; if (refundStatement.executeUpdate() != 1) { connection.rollback(); return false; @@ -378,6 +404,7 @@ boolean refundHookStarted(String transferId, String sourceUuid, String sourcePoi } return commitAndConfirm(connection, transferId, REFUNDED); } catch (SQLException failure) { + if (!pointWriteAttempted) throw new PreparationFailure(failure); throw failure; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index d985aa23e8..3dccd557d6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1,19 +1,19 @@ -package com.bencodez.votingplugin.user; - +package com.bencodez.votingplugin.user; + import java.text.SimpleDateFormat; import java.sql.SQLException; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Map.Entry; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -22,41 +22,41 @@ import java.util.concurrent.ConcurrentMap; import java.util.function.BiConsumer; import java.util.function.Consumer; -import java.util.stream.Collectors; - -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; - -import com.bencodez.advancedcore.api.messages.PlaceholderUtils; -import com.bencodez.advancedcore.api.misc.MiscUtils; -import com.bencodez.advancedcore.api.rewards.RewardBuilder; -import com.bencodez.advancedcore.api.rewards.RewardOptions; +import java.util.stream.Collectors; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.misc.MiscUtils; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; +import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; -import com.bencodez.simpleapi.messages.MessageAPI; -import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.messages.MessageAPI; +import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueInt; -import com.bencodez.simpleapi.sql.data.DataValueString; -import com.bencodez.simpleapi.time.ParsedDuration; -import com.bencodez.votingplugin.VotingPluginMain; -import com.bencodez.votingplugin.events.PlayerReceivePointsEvent; -import com.bencodez.votingplugin.events.PlayerSpecialRewardEvent; -import com.bencodez.votingplugin.events.PlayerVoteEvent; -import com.bencodez.votingplugin.events.SpecialRewardType; -import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; -import com.bencodez.votingplugin.topvoter.TopVoter; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.simpleapi.time.ParsedDuration; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerReceivePointsEvent; +import com.bencodez.votingplugin.events.PlayerSpecialRewardEvent; +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.events.SpecialRewardType; +import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; +import com.bencodez.votingplugin.topvoter.TopVoter; import com.bencodez.votingplugin.topvoter.TopVoterPlayer; import com.bencodez.votingplugin.util.BukkitCompletionScheduler; -import com.bencodez.votingplugin.votesites.NextSite; -import com.bencodez.votingplugin.votesites.VoteSite; - -/** - * The Class VotingPluginUser. This class represents a user in the VotingPlugin - * system. It extends the AdvancedCoreUser class and provides additional - * functionality specific to the VotingPlugin. - */ +import com.bencodez.votingplugin.votesites.NextSite; +import com.bencodez.votingplugin.votesites.VoteSite; + +/** + * The Class VotingPluginUser. This class represents a user in the VotingPlugin + * system. It extends the AdvancedCoreUser class and provides additional + * functionality specific to the VotingPlugin. + */ public class VotingPluginUser extends com.bencodez.advancedcore.api.user.AdvancedCoreUser { private static final int BULK_POINT_BATCH_SIZE = 64; /** Durable record of an offline reward batch not yet fully confirmed. */ @@ -84,137 +84,137 @@ static void endOfflineVoteBulkClear() { private static final ConcurrentMap> IN_FLIGHT_POINT_REPLAYS = new ConcurrentHashMap<>(); - - /** The plugin instance. */ - private VotingPluginMain plugin; - - /** - * Instantiates a new VotingPluginUser. - * - * @param plugin the plugin instance - * @param user the AdvancedCoreUser instance - */ - public VotingPluginUser(VotingPluginMain plugin, AdvancedCoreUser user) { - super(plugin, user); - this.plugin = plugin; - } - - /** - * Instantiates a new VotingPluginUser. - * - * @param plugin the plugin instance - * @param player the player instance - * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} - * instead. - */ - @Deprecated - public VotingPluginUser(VotingPluginMain plugin, Player player) { - super(plugin, player); - this.plugin = plugin; - } - - /** - * Instantiates a new VotingPluginUser. - * - * @param plugin the plugin instance - * @param playerName the player name - * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} - * instead. - */ - @Deprecated - public VotingPluginUser(VotingPluginMain plugin, String playerName) { - super(plugin, playerName); - this.plugin = plugin; - } - - /** - * Instantiates a new VotingPluginUser. - * - * @param plugin the plugin instance - * @param uuid the UUID of the player - * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} - * instead. - */ - @Deprecated - public VotingPluginUser(VotingPluginMain plugin, UUID uuid) { - super(plugin, uuid); - this.plugin = plugin; - } - - /** - * Instantiates a new VotingPluginUser. - * - * @param plugin the plugin instance - * @param uuid the UUID of the player - * @param loadName whether to load the player name - * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} - * instead. - */ - @Deprecated - public VotingPluginUser(VotingPluginMain plugin, UUID uuid, boolean loadName) { - super(plugin, uuid, loadName); - this.plugin = plugin; - } - - /** - * Instantiates a new VotingPluginUser. - * - * @param plugin the plugin instance - * @param uuid the UUID of the player - * @param playerName the player name - * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} - * instead. - */ - @Deprecated - public VotingPluginUser(VotingPluginMain plugin, UUID uuid, String playerName) { - super(plugin, uuid, playerName); - this.plugin = plugin; - } - - /** - * Adds one to the all-time total votes. - */ - public void addAllTimeTotal() { - setAllTimeTotal(getAllTimeTotal() + 1); - } - - /** - * Adds one to the daily vote streak. - */ - @Deprecated - public void addDayVoteStreak() { - setDayVoteStreak(getDayVoteStreak() + 1); - } - - /** - * Adds one to the monthly total votes. - */ - public void addMonthTotal() { - setMonthTotal(getMonthTotal() + 1); - } - - /** - * Adds one to the monthly vote streak. - */ - @Deprecated - public void addMonthVoteStreak() { - setMonthVoteStreak(getMonthVoteStreak() + 1); - } - - /** - * Adds an offline vote for the specified vote site. - * - * @param voteSiteName the name of the vote site - */ - public void addOfflineVote(String voteSiteName) { - ArrayList offlineVotes = getOfflineVotes(); - offlineVotes.add(voteSiteName); - setOfflineVotes(offlineVotes); - } - - /** - * Adds points to the user based on the configuration. - */ + + /** The plugin instance. */ + private VotingPluginMain plugin; + + /** + * Instantiates a new VotingPluginUser. + * + * @param plugin the plugin instance + * @param user the AdvancedCoreUser instance + */ + public VotingPluginUser(VotingPluginMain plugin, AdvancedCoreUser user) { + super(plugin, user); + this.plugin = plugin; + } + + /** + * Instantiates a new VotingPluginUser. + * + * @param plugin the plugin instance + * @param player the player instance + * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} + * instead. + */ + @Deprecated + public VotingPluginUser(VotingPluginMain plugin, Player player) { + super(plugin, player); + this.plugin = plugin; + } + + /** + * Instantiates a new VotingPluginUser. + * + * @param plugin the plugin instance + * @param playerName the player name + * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} + * instead. + */ + @Deprecated + public VotingPluginUser(VotingPluginMain plugin, String playerName) { + super(plugin, playerName); + this.plugin = plugin; + } + + /** + * Instantiates a new VotingPluginUser. + * + * @param plugin the plugin instance + * @param uuid the UUID of the player + * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} + * instead. + */ + @Deprecated + public VotingPluginUser(VotingPluginMain plugin, UUID uuid) { + super(plugin, uuid); + this.plugin = plugin; + } + + /** + * Instantiates a new VotingPluginUser. + * + * @param plugin the plugin instance + * @param uuid the UUID of the player + * @param loadName whether to load the player name + * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} + * instead. + */ + @Deprecated + public VotingPluginUser(VotingPluginMain plugin, UUID uuid, boolean loadName) { + super(plugin, uuid, loadName); + this.plugin = plugin; + } + + /** + * Instantiates a new VotingPluginUser. + * + * @param plugin the plugin instance + * @param uuid the UUID of the player + * @param playerName the player name + * @deprecated Use {@link #VotingPluginUser(VotingPluginMain, AdvancedCoreUser)} + * instead. + */ + @Deprecated + public VotingPluginUser(VotingPluginMain plugin, UUID uuid, String playerName) { + super(plugin, uuid, playerName); + this.plugin = plugin; + } + + /** + * Adds one to the all-time total votes. + */ + public void addAllTimeTotal() { + setAllTimeTotal(getAllTimeTotal() + 1); + } + + /** + * Adds one to the daily vote streak. + */ + @Deprecated + public void addDayVoteStreak() { + setDayVoteStreak(getDayVoteStreak() + 1); + } + + /** + * Adds one to the monthly total votes. + */ + public void addMonthTotal() { + setMonthTotal(getMonthTotal() + 1); + } + + /** + * Adds one to the monthly vote streak. + */ + @Deprecated + public void addMonthVoteStreak() { + setMonthVoteStreak(getMonthVoteStreak() + 1); + } + + /** + * Adds an offline vote for the specified vote site. + * + * @param voteSiteName the name of the vote site + */ + public void addOfflineVote(String voteSiteName) { + ArrayList offlineVotes = getOfflineVotes(); + offlineVotes.add(voteSiteName); + setOfflineVotes(offlineVotes); + } + + /** + * Adds points to the user based on the configuration. + */ public void addPoints() { int points = plugin.getConfigFile().getPointsOnVote(); SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); @@ -244,37 +244,37 @@ public void addPoints() { } else if (getPoints() > limit) { setPoints(limit); } - } - } - - /** - * Adds the specified number of points to the user. - * - * @param value the number of points to add - * @return the current total points - */ - public int addPoints(int value) { - return addPoints(value, false); - } - - /** - * Adds the specified number of points to the user, optionally asynchronously. + } + } + + /** + * Adds the specified number of points to the user. + * + * @param value the number of points to add + * @return the current total points + */ + public int addPoints(int value) { + return addPoints(value, false); + } + + /** + * Adds the specified number of points to the user, optionally asynchronously. * MySQL mutations from platform-owned threads are always deferred; the return * value is predicted in that case. Use the storage-aware completion API for * a confirmed committed balance. Worker-thread synchronous calls still wait * for the mutation and authoritative cache refill. - * - * @param value the number of points to add - * @param async whether to add the points asynchronously - * @return the current total points - */ + * + * @param value the number of points to add + * @param async whether to add the points asynchronously + * @return the current total points + */ public synchronized int addPoints(int value, boolean async) { - PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); - Bukkit.getPluginManager().callEvent(event); - - if (event.isCancelled()) { - return getPoints(); - } + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + + if (event.isCancelled()) { + return getPoints(); + } SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (sharedPoints.usesMysqlPointMutations()) { // Platform-owned callers use the existing asynchronous contract; JDBC and @@ -317,6 +317,38 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu * @return committed point total, or an exceptional stage when persistence fails */ public synchronized CompletionStage addPointsStorageAwareAsync(int value, String operationId) { + if (!new SharedMysqlPointMutator(plugin).usesMysqlPointMutations()) return addPointsStorageAwareAsyncUnfenced(value, operationId); + var placeholders = plugin.getPlaceholders(); + if (placeholders == null) return addPointsStorageAwareAsyncUnfenced(value, operationId); + UUID uuid = UUID.fromString(getUUID()); + String column = getPointsPath(); + placeholders.beginSharedPointDebit(uuid); + var finished = new java.util.concurrent.atomic.AtomicBoolean(); + java.util.function.BiConsumer finish = (total, failure) -> { + if (!finished.compareAndSet(false, true)) return; + boolean known = failure == null || failure instanceof java.util.concurrent.RejectedExecutionException + || failure instanceof PointAdditionNotStartedException; + var caches = plugin.getUserManager().getDataManager().getUserDataCache(); + var cache = caches.get(uuid); + boolean available = false; + if (cache != null) synchronized (cache) { + available = failure == null && caches.get(uuid) == cache && cache.hasPublishedStorageSnapshot() + && column.equals(getPointsPath()) && plugin.isEnabled() + && plugin.getPlaceholders() == placeholders; + } + placeholders.finishSharedPointDebit(uuid, known, available); + }; + try { + CompletionStage completion = addPointsStorageAwareAsyncUnfenced(value, operationId); + completion.whenComplete(finish); + return completion; + } catch (RuntimeException | Error failure) { + finish.accept(null, failure); + throw failure; + } + } + + private CompletionStage addPointsStorageAwareAsyncUnfenced(int value, String operationId) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (!sharedPoints.applies() && operationId != null && !operationId.isEmpty() && SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)) { @@ -381,6 +413,7 @@ private CompletionStage addPerServerPointsWithReplayClaim(SharedMysqlPo SharedPointAdditionJournal.HookClaim claim = sharedPoints.claimPointAdditionHook(operationId, uuid, journalPointsPath, value, claimOwner); if (claim.completed()) { + SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(plugin, getUUID(), getPointsPath()); completion.complete(claim.total()); return; } @@ -499,6 +532,7 @@ private CompletionStage addSharedPointsWithReplayLookup(SharedMysqlPoin SharedPointAdditionJournal.HookClaim claim = sharedPoints.claimPointAdditionHook(operationId, uuid, pointsPath, value, claimOwner); if (claim.completed()) { + SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(plugin, getUUID(), getPointsPath()); completion.complete(claim.total()); return; } @@ -579,13 +613,18 @@ private void reportIndeterminateSharedPointAddition(SharedMysqlPointMutator shar * deliberately retained for reconciliation instead of making a retry unsafe. * Started hooks still use the indeterminate path above. */ + private static final class PointAdditionNotStartedException extends IllegalStateException { + private static final long serialVersionUID = 1L; + PointAdditionNotStartedException() { super("Shared MySQL point addition was not started; retry is safe"); } + } + private void releaseUnstartedSharedPointAddition(SharedMysqlPointMutator sharedPoints, String operationId, String uuid, String pointsPath, int requestedAmount, String claimOwner, CompletableFuture completion) { Runnable release = () -> { try { sharedPoints.releaseUnstartedPointAdditionHook(operationId, uuid, pointsPath, requestedAmount, claimOwner); completion.completeExceptionally( - new IllegalStateException("Shared MySQL point addition was not started; retry is safe")); + new PointAdditionNotStartedException()); } catch (SQLException failure) { plugin.getLogger().severe("Unable to release unstarted shared MySQL point addition " + operationId + "; retaining its claim for manual reconciliation"); @@ -751,12 +790,23 @@ public void setPointsStorageAware(int value, Consumer completion) { } Player player = getPlayer(); String pointsColumn = getPointsPath(); + var placeholders = plugin.getPlaceholders(); + Consumer admission = placeholders == null ? null + : placeholders.sharedPointAdmissionCompletion(UUID.fromString(getUUID())); try { plugin.getTimer().execute(() -> { - boolean updated = sharedPoints.setCommittedInColumn(this, value, pointsColumn); + boolean updated; + boolean settled = false; + try { + updated = sharedPoints.setCommittedInColumn(this, value, pointsColumn); + settled = true; + } finally { + if (admission != null) admission.accept(settled); + } BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(updated)); }); } catch (RuntimeException rejected) { + if (admission != null) admission.accept(false); plugin.debug(rejected); BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(false)); } @@ -847,12 +897,39 @@ private static void bulkSharedMysqlMutation(VotingPluginMain plugin, List players = new ArrayList<>(users.size()); for (VotingPluginUser user : users) players.add(capturedPlayers.get(user)); - submitSharedMysqlChunk(plugin, users, players, 0, completion, sharedMutation); + java.util.IdentityHashMap>> admissions = new java.util.IdentityHashMap<>(); + for (VotingPluginUser user : users) { + var placeholders = plugin.getPlaceholders(); + if (placeholders != null) { + Consumer admission = placeholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID())); + if (admission != null) admissions.computeIfAbsent(user, ignored -> new java.util.ArrayDeque<>()).addLast(admission); + } + } + Consumer abandon = user -> { + var queue = admissions.get(user); + Consumer admission = null; + if (queue != null) synchronized (queue) { admission = queue.pollFirst(); } + if (admission != null) admission.accept(false); + }; + SharedPointMutation guarded = (mutator, user) -> { + var queue = admissions.get(user); + Consumer admission = null; + if (queue != null) synchronized (queue) { admission = queue.pollFirst(); } + boolean settled = false; + try { + boolean result = sharedMutation.apply(mutator, user); + settled = true; + return result; + } finally { + if (admission != null) admission.accept(settled); + } + }; + submitSharedMysqlChunk(plugin, users, players, 0, completion, guarded, abandon); } private static void submitSharedMysqlChunk(VotingPluginMain plugin, List users, List players, int start, - BiConsumer completion, SharedPointMutation sharedMutation) { + BiConsumer completion, SharedPointMutation sharedMutation, Consumer abandon) { int end = Math.min(start + BULK_POINT_BATCH_SIZE, users.size()); Runnable persistenceWork = () -> { boolean[] results = new boolean[end - start]; @@ -866,13 +943,14 @@ private static void submitSharedMysqlChunk(VotingPluginMain plugin, List completion.accept(false, 0)); } } - - /** - * Adds one to the total votes. - */ - public void addTotal() { - addMonthTotal(); - addAllTimeTotal(); - } - - /** - * Adds one to the daily total votes. - */ - public void addTotalDaily() { - setDailyTotal(getDailyTotal() + 1); - } - - /** - * Adds one to the weekly total votes. - */ - public void addTotalWeekly() { - setWeeklyTotal(getWeeklyTotal() + 1); - } - - /** - * Adds one to the weekly vote streak. - */ - @Deprecated - public void addWeekVoteStreak() { - setWeekVoteStreak(getWeekVoteStreak() + 1); - } - - /** - * Handles a plugin messaging bungee vote. - * - * @param service the service name - * @param time the vote time - * @param text the bungee message data - * @param setTotals whether to set the totals - * @param wasOnline whether the player was online - * @param broadcast whether to broadcast the vote - * @param num the vote number - */ + + /** + * Adds one to the total votes. + */ + public void addTotal() { + addMonthTotal(); + addAllTimeTotal(); + } + + /** + * Adds one to the daily total votes. + */ + public void addTotalDaily() { + setDailyTotal(getDailyTotal() + 1); + } + + /** + * Adds one to the weekly total votes. + */ + public void addTotalWeekly() { + setWeeklyTotal(getWeeklyTotal() + 1); + } + + /** + * Adds one to the weekly vote streak. + */ + @Deprecated + public void addWeekVoteStreak() { + setWeekVoteStreak(getWeekVoteStreak() + 1); + } + + /** + * Handles a plugin messaging bungee vote. + * + * @param service the service name + * @param time the vote time + * @param text the bungee message data + * @param setTotals whether to set the totals + * @param wasOnline whether the player was online + * @param broadcast whether to broadcast the vote + * @param num the vote number + */ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals, boolean wasOnline, boolean broadcast, int num) { bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, false, false, null); @@ -1013,19 +1091,19 @@ public PlayerVoteEvent bungeeVotePluginMessagingEvent(String service, long time, boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId, boolean targetedProxyVote) { if (plugin.getBungeeSettings().isUseBungeecoord()) { - plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service); - - PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true), - getPlayerName(), service, true); - voteEvent.setBungee(true); - voteEvent.setVotingPluginUser(this); - voteEvent.setForceBungee(true); - voteEvent.setTime(time); - voteEvent.setAddTotals(setTotals); + plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service); + + PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true), + getPlayerName(), service, true); + voteEvent.setBungee(true); + voteEvent.setVotingPluginUser(this); + voteEvent.setForceBungee(true); + voteEvent.setTime(time); + voteEvent.setAddTotals(setTotals); voteEvent.setBungeeTextTotals(text); voteEvent.setProxyVoteId(proxyVoteId); - voteEvent.setWasOnline(wasOnline); - voteEvent.setBroadcast(broadcast); + voteEvent.setWasOnline(wasOnline); + voteEvent.setBroadcast(broadcast); voteEvent.setVoteNumber(num); voteEvent.setQueuedProxyVote(queuedProxyVote); voteEvent.setProxyQueueClassificationKnown(proxyQueueClassificationKnown); @@ -1033,1168 +1111,1168 @@ public PlayerVoteEvent bungeeVotePluginMessagingEvent(String service, long time, voteEvent.setTargetedProxyVote(targetedProxyVote); plugin.getServer().getPluginManager().callEvent(voteEvent); return voteEvent; - } + } return null; - } - - /** - * Checks if the user can vote on all sites. - * - * @return true, if the user can vote on all sites - */ - public boolean canVoteAll() { - for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (!voteSite.isHidden()) { - boolean canVote = canVoteSite(voteSite); - if (!canVote) { - return false; - } - } - } - return true; - } - - /** - * Checks if the user can vote on any site. - * - * @return true, if the user can vote on any site - */ - public boolean canVoteAny() { - for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (!voteSite.isIgnoreCanVote() && !voteSite.isHidden()) { - boolean canVote = canVoteSite(voteSite); - if (canVote) { - return true; - } - } - } - return false; - } - - /** - * Checks if the user can vote on the specified site. - * - * @param voteSite the vote site - * @return true, if the user can vote on the site - */ - public boolean canVoteSite(VoteSite voteSite) { - long time = getTime(voteSite); - if (time == 0) { - return true; - } - try { - LocalDateTime now = plugin.getTimeChecker().getTime(); - LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault()) - .plusHours(plugin.getOptions().getTimeHourOffSet()); - - if (!voteSite.isVoteDelayDaily()) { - ParsedDuration voteDelay = voteSite.getVoteDelay(); - - // Preserve old behavior: if delay is 0, you can never vote again (unless daily - // reset mode) - if (voteDelay == null || voteDelay.isEmpty() || voteDelay.getMillis() == 0L) { - return false; - } - - LocalDateTime nextVote = lastVote.plus(Duration.ofMillis(voteDelay.getMillis())); - return now.isAfter(nextVote); - } - - // Daily reset logic unchanged - LocalDateTime resetTime = lastVote.withHour(voteSite.getVoteDelayDailyHour()).withMinute(0).withSecond(0); - LocalDateTime resetTimeTomorrow = resetTime.plusHours(24); - - if (lastVote.isBefore(resetTime)) { - return now.isAfter(resetTime); - } else { - return now.isAfter(resetTimeTomorrow); - } - } catch (Exception e) { - e.printStackTrace(); - } - return false; - } - - /** - * Checks if the user has voted on all sites. - * - * @return true, if the user has voted on all sites - */ - public boolean checkAllVotes() { - VotingPluginUser user = this; - - ArrayList months = new ArrayList<>(); - ArrayList days = new ArrayList<>(); - - for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (voteSite.isEnabled() && !voteSite.isHidden()) { - long time = user.getTime(voteSite); - if (time == 0) { - return false; - } - months.add(MiscUtils.getInstance().getMonthFromMili(time)); - days.add(MiscUtils.getInstance().getDayFromMili(time)); - } - } - - // check months - for (Integer month : months) { - if (!months.get(0).equals(month)) { - return false; - } - } - - // check days - for (Integer day : days) { - if (!days.get(0).equals(day)) { - return false; - } - } - - return true; - } - - /** - * Checks if the user has voted on almost all sites. - * - * @return true, if the user has voted on almost all sites - */ - public boolean checkAlmostAllVotes() { - if (getSitesNotVotedOn() <= 1) { - return true; - } - return false; - } - - /** - * Checks the day vote streak and updates it if necessary. - * - * @param forceBungee whether to force bungee - */ - @Deprecated - public void checkDayVoteStreak(boolean forceBungee) { - if (!voteStreakUpdatedToday(LocalDateTime.now())) { - if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || hasPercentageTotal( - TopVoter.Daily, plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)) { - plugin.extraDebug("Adding day vote streak to " + getUUID() + " " - + plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + " " - + hasPercentageTotal(TopVoter.Daily, - plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)); - addDayVoteStreak(); - plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); - setDayVoteStreakLastUpdate(System.currentTimeMillis()); - } - } - } - - /** - * Clears the offline votes. - */ - public void clearOfflineVotes() { - setOfflineVotes(new ArrayList<>()); - setOfflineRewards(new ArrayList<>()); - } - - /** - * Clears the total votes for all top voter categories. - */ - public void clearTotals() { - for (TopVoter top : TopVoter.values()) { - resetTotals(top); - } - } - - /** - * Gets the all-time total votes. - * - * @return the all-time total votes - * @deprecated Use getTotal(TopVoter.AllTime) when able instead - */ - @Deprecated - public int getAllTimeTotal() { - return getTotal(TopVoter.AllTime); - } - - /** - * Gets the best day vote streak. - * - * @return the best day vote streak - */ - @Deprecated - public int getBestDayVoteStreak() { - return getData().getInt("BestDayVoteStreak"); - } - - /** - * Gets the best month vote streak. - * - * @return the best month vote streak - */ - @Deprecated - public int getBestMonthVoteStreak() { - return getData().getInt("BestMonthVoteStreak"); - } - - /** - * Gets the best week vote streak. - * - * @return the best week vote streak - */ - @Deprecated - public int getBestWeekVoteStreak() { - return getData().getInt("BestWeekVoteStreak"); - } - - /** - * Checks if the cooldown check is enabled. - * - * @return true, if the cooldown check is enabled - */ - public boolean getCoolDownCheck() { - return getData().getBoolean(getCoolDownCheckPath()); - } - - /** - * Gets the path for the cooldown check. - * - * @return the cooldown check path - */ - public String getCoolDownCheckPath() { - if (plugin.getBungeeSettings().isUseBungeecoord()) { - return "CoolDownCheck_" + plugin.getBungeeSettings().getServerNameStorage(); - } - return "CoolDownCheck"; - } - - /** - * Checks if the cooldown check is enabled for a specific vote site. - * - * @param site the vote site - * @return true, if the cooldown check is enabled for the site - */ - public boolean getCoolDownCheckSite(VoteSite site) { - HashMap coolDownChecks = getCoolDownCheckSiteList(); - if (coolDownChecks.containsKey(site.getKey())) { - return coolDownChecks.get(site.getKey()).booleanValue(); - } - return false; - } - - /** - * Gets the list of cooldown checks for all vote sites. - * - * @return the list of cooldown checks for all vote sites - */ - public HashMap getCoolDownCheckSiteList() { - HashMap coolDownChecks = new HashMap<>(); - ArrayList coolDownCheck = getData().getStringList(getCoolDownCheckSitePath()); - for (String str : coolDownCheck) { - String[] data = str.split("//"); - if (data.length > 1 && plugin.getVoteSiteManager().hasVoteSite(data[0])) { - VoteSite site = plugin.getVoteSiteManager().getVoteSite(data[0], true); - if (site != null) { - Boolean b = Boolean.valueOf(data[1]); - coolDownChecks.put(site.getKey(), b); - } - } - } - return coolDownChecks; - } - - /** - * Gets the path for the cooldown check site list. - * - * @return the cooldown check site list path - */ - public String getCoolDownCheckSitePath() { - if (plugin.getBungeeSettings().isUseBungeecoord()) { - return "CoolDownCheck_" + plugin.getBungeeSettings().getServerNameStorage() + "_Sites"; - } - return "CoolDownCheck" + "_Sites"; - } - - /** - * Gets the daily total votes. - * - * @return the daily total votes - * @deprecated Use getTotal(TopVoter.Daily) instead - */ - @Deprecated - public int getDailyTotal() { - return getTotal(TopVoter.Daily); - } - - /** - * Gets the day vote streak. - * - * @return the day vote streak - */ - @Deprecated - public int getDayVoteStreak() { - return getData().getInt("DayVoteStreak"); - } - - /** - * Gets the last update time for the day vote streak. - * - * @return the last update time for the day vote streak - */ - @Deprecated - public long getDayVoteStreakLastUpdate() { - String str = getData().getString("DayVoteStreakLastUpdate"); - if (str == null || str.isEmpty() || str.equals("null")) { - return 0; - } - try { - return Long.parseLong(str); - } catch (NumberFormatException e) { - return 0; - } - } - - /** - * Checks if the broadcast is disabled. - * - * @return true if the broadcast is disabled, false otherwise - */ - public boolean getDisableBroadcast() { - return getUserData().getBoolean("DisableBroadcast"); - } - - /** - * Gets the day when the user has gotten all sites. - * - * @return the day when the user has gotten all sites - */ - public int getGottenAllSitesDay() { - return getData().getInt(plugin.getVotingPluginUserManager().getGottenAllSitesDayPath(), 0); - } - - /** - * Gets the day when the user has gotten almost all sites. - * - * @return the day when the user has gotten almost all sites - */ - public int getGottenAlmostAllSitesDay() { - return getData().getInt(plugin.getVotingPluginUserManager().getGottenAlmostAllSitesDayPath(), 0); - } - - /** - * Gets the highest daily total votes. - * - * @return the highest daily total votes - */ - public int getHighestDailyTotal() { - return getData().getInt("HighestDailyTotal"); - } - - /** - * Returns whether this user has already claimed the NameMC like reward. - * - * @return true if claimed - */ - public boolean hasClaimedNameMCLikeReward() { - return getUserData().getBoolean("NameMCLikeRewardClaimed"); - } - - /** - * Sets whether this user has already claimed the NameMC like reward. - * - * @param claimed true if claimed - */ - public void setClaimedNameMCLikeReward(boolean claimed) { - // A completed NameMC reward must be durably persisted before clearing - // its recovery marker; never leave this as an ordinary queued write. - getUserData().setBoolean("NameMCLikeRewardClaimed", claimed, false); } - /** Whether the NameMC grant may have started but is not yet confirmed. */ - public boolean isNameMCLikeRewardPending() { - return getUserData().getBoolean("NameMCLikeRewardPending"); + /** + * Checks if the user can vote on all sites. + * + * @return true, if the user can vote on all sites + */ + public boolean canVoteAll() { + for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!voteSite.isHidden()) { + boolean canVote = canVoteSite(voteSite); + if (!canVote) { + return false; + } + } + } + return true; } - /** Persist/clear the NameMC replay fence on the user-data worker. */ - public void setNameMCLikeRewardPending(boolean pending) { - getUserData().setBoolean("NameMCLikeRewardPending", pending, false); - } - - /** - * Gets the highest monthly total votes. - * - * @return the highest monthly total votes - */ - public int getHighestMonthlyTotal() { - return getData().getInt("HighestMonthlyTotal"); - } - - /** - * Gets the highest weekly total votes. - * - * @return the highest weekly total votes - */ - public int getHighestWeeklyTotal() { - return getData().getInt("HighestWeeklyTotal"); - } - - /** - * Gets the total votes for the last month. - * - * @return the total votes for the last month - */ - public int getLastMonthTotal() { - return getData().getInt("LastMonthTotal"); - } - - /** - * Gets the last votes for each vote site. - * - * @return a map of vote sites and the last vote time - */ - public HashMap getLastVotes() { - HashMap lastVotes = new HashMap<>(); - ArrayList lastVotesList = getUserData().getStringList("LastVotes"); - - for (String str : lastVotesList) { - String[] data = str.split("//"); - if (data.length <= 1) { - continue; - } - - String rawSiteKey = data[0]; - String rawTime = data[1]; - - if (!plugin.getVoteSiteManager().hasVoteSite(rawSiteKey)) { - continue; - } - - VoteSite site = plugin.getVoteSiteManager().getVoteSite(rawSiteKey, true); - if (site == null) { - continue; - } - - long time = 0; - try { - time = Long.parseLong(rawTime); - } catch (NumberFormatException ignored) { - time = 0; - } - - lastVotes.put(site, time); - } - - return lastVotes; - } - - /** - * Gets the time of the last vote. - * - * @return the time of the last vote - */ - public Long getLastVoteTime() { - Long time = Long.valueOf(0); - for (Long value : getLastVotes().values()) { - if (value.longValue() > time) { - time = value; - } - } - return time; - } - - /** - * Gets the last vote time for a specific vote site. - * - * @param voteSite the vote site - * @return the last vote time for the vote site - */ - public long getLastVoteTimer(VoteSite voteSite) { - HashMap times = getLastVotes(); - if (times.containsKey(voteSite)) { - return times.get(voteSite).longValue(); - } - return 0; - } - - /** - * Gets the last vote times sorted in descending order. - * - * @return a map of vote sites and the last vote times sorted in descending - * order - */ - public HashMap getLastVoteTimesSorted() { - LinkedHashMap times = new LinkedHashMap<>(); - - for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - times.put(voteSite, getTime(voteSite)); - } - LinkedHashMap sorted = new LinkedHashMap<>( - times.entrySet().stream().sorted(Collections.reverseOrder(Map.Entry.comparingByValue())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); - return sorted; - } - - /** - * Gets the total votes for the month. - * - * @return the total votes for the month - * @deprecated Use getTotal(TopVoter.Monthly) instead - */ - @Deprecated - public int getMonthTotal() { - return getTotal(TopVoter.Monthly); - } - - /** - * Gets the month vote streak. - * - * @return the month vote streak - */ - @Deprecated - public int getMonthVoteStreak() { - return getData().getInt("MonthVoteStreak"); - } - - /** - * Gets the next time all sites are available for voting. - * - * @return the next time all sites are available for voting - */ - public long getNextTimeAllSitesAvailable() { - long longest = 0; - for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - long seconds = voteNextDurationTime(site); - if (seconds > longest) { - longest = seconds; - } - } - - return longest; - } - - /** - * Gets the next time the first site is available for voting. - * - * @return seconds until first site is available, or 0 if none - */ - public long getNextTimeFirstSiteAvailable() { - NextSite next = getNextSiteAvailable(); - return next == null ? 0 : next.getSecondsUntilAvailable(); - } - - /** - * Returns the next VoteSite that will become available, and how many seconds - * until it is available. - * - * - Only considers enabled sites. - Skips hidden sites (matching - * canVoteAll/canVoteAny intent for player-facing voting). - Only considers - * sites the player CANNOT currently vote on (seconds > 0). - * - * @return NextSite or null if there is no upcoming site (i.e. can vote all, or - * no delays) - */ - public NextSite getNextSiteAvailable() { - List sites = plugin.getVoteSiteManager().getVoteSitesEnabled(); - if (sites == null || sites.isEmpty()) { - return null; - } - - VoteSite bestSite = null; - long bestSeconds = 0; - - for (int i = 0; i < sites.size(); i++) { - VoteSite site = sites.get(i); - if (site == null) { - continue; - } - - // Match the same "don't care" sites as your other checks generally do - if (!site.isEnabled() || site.isHidden()) { - continue; - } - - // If you can already vote, it isn't "next" - if (canVoteSite(site)) { - continue; - } - - long seconds = voteNextDurationTime(site); // uses getTime(site) internally - if (seconds <= 0) { - continue; - } - - if (bestSite == null || seconds < bestSeconds) { - bestSite = site; - bestSeconds = seconds; - } - } - - return bestSite == null ? null : new NextSite(bestSite, bestSeconds); - } - - /** - * Gets the number of offline votes for the specified vote site. - * - * @param site the vote site - * @return the number of offline votes for the specified vote site - */ - public int getNumberOfOfflineVotes(VoteSite site) { - ArrayList offlineVotes = getOfflineVotes(); - int num = 0; - for (String str : offlineVotes) { - if (str.equals(site.getKey())) { - num++; - } - } - return num; - } - - /** - * Gets the list of offline votes. - * - * @return the list of offline votes - */ - public ArrayList getOfflineVotes() { - return getUserData().getStringList("OfflineVotes"); - } - - /** - * Gets the points of the user. - * - * @return the points of the user - */ - public int getPoints() { - return getUserData().getInt(getPointsPath()); - } - - /** - * Gets the path for the points. - * - * @return the points path - */ - public String getPointsPath() { - if (plugin.getBungeeSettings().isPerServerPoints()) { - return plugin.getBungeeSettings().getServerNameStorage() + "_Points"; - } - return "Points"; - } - - /** - * Gets the number of sites not voted on. - * - * @return the number of sites not voted on - */ - public int getSitesNotVotedOn() { - int amount = 0; - for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (!site.isHidden()) { - if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) { - if (canVoteSite(site)) { - amount++; - } - } - } - } - return amount; + /** + * Checks if the user can vote on any site. + * + * @return true, if the user can vote on any site + */ + public boolean canVoteAny() { + for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!voteSite.isIgnoreCanVote() && !voteSite.isHidden()) { + boolean canVote = canVoteSite(voteSite); + if (canVote) { + return true; + } + } + } + return false; } /** - * Match the existing offline result without resolving a live Bukkit player. - * Permission-gated sites are excluded because the normal call uses - * {@code hasPermission(permission, false)}, which returns false offline. + * Checks if the user can vote on the specified site. + * + * @param voteSite the vote site + * @return true, if the user can vote on the site */ - public int getSitesNotVotedOnWithoutOnlinePermissions() { - int amount = 0; - for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (!site.isHidden() && site.getPermissionToView().isEmpty() && canVoteSite(site)) amount++; + public boolean canVoteSite(VoteSite voteSite) { + long time = getTime(voteSite); + if (time == 0) { + return true; } - return amount; - } - - public int getTotalNumberOfSites() { - int amount = 0; - for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (!site.isHidden()) { - if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) { - amount++; - } - } - } - return amount; + try { + LocalDateTime now = plugin.getTimeChecker().getTime(); + LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault()) + .plusHours(plugin.getOptions().getTimeHourOffSet()); + + if (!voteSite.isVoteDelayDaily()) { + ParsedDuration voteDelay = voteSite.getVoteDelay(); + + // Preserve old behavior: if delay is 0, you can never vote again (unless daily + // reset mode) + if (voteDelay == null || voteDelay.isEmpty() || voteDelay.getMillis() == 0L) { + return false; + } + + LocalDateTime nextVote = lastVote.plus(Duration.ofMillis(voteDelay.getMillis())); + return now.isAfter(nextVote); + } + + // Daily reset logic unchanged + LocalDateTime resetTime = lastVote.withHour(voteSite.getVoteDelayDailyHour()).withMinute(0).withSecond(0); + LocalDateTime resetTimeTomorrow = resetTime.plusHours(24); + + if (lastVote.isBefore(resetTime)) { + return now.isAfter(resetTime); + } else { + return now.isAfter(resetTimeTomorrow); + } + } catch (Exception e) { + e.printStackTrace(); + } + return false; } /** - * Match the existing offline total without resolving a live Bukkit player. - * Permission-gated sites are excluded because the normal call uses - * {@code hasPermission(permission, false)}, which returns false offline. + * Checks if the user has voted on all sites. + * + * @return true, if the user has voted on all sites */ - public int getTotalNumberOfSitesWithoutOnlinePermissions() { - int amount = 0; - for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (!site.isHidden() && site.getPermissionToView().isEmpty()) amount++; + public boolean checkAllVotes() { + VotingPluginUser user = this; + + ArrayList months = new ArrayList<>(); + ArrayList days = new ArrayList<>(); + + for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (voteSite.isEnabled() && !voteSite.isHidden()) { + long time = user.getTime(voteSite); + if (time == 0) { + return false; + } + months.add(MiscUtils.getInstance().getMonthFromMili(time)); + days.add(MiscUtils.getInstance().getDayFromMili(time)); + } } - return amount; + + // check months + for (Integer month : months) { + if (!months.get(0).equals(month)) { + return false; + } + } + + // check days + for (Integer day : days) { + if (!days.get(0).equals(day)) { + return false; + } + } + + return true; } - - public int getSitesVotedOn() { - int amount = 0; - for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (!canVoteSite(site)) { - amount++; - } - } - return amount; - } - - /** - * Gets the time. - * - * @param voteSite the vote site - * @return the time - */ - public long getTime(VoteSite voteSite) { - HashMap lastVotes = getLastVotes(); - if (lastVotes.containsKey(voteSite)) { - return lastVotes.get(voteSite); - } - return 0; - } - - /** - * Gets the top voter player. - * - * @return the top voter player - */ - public TopVoterPlayer getTopVoterPlayer() { - return new TopVoterPlayer(UUID.fromString(getUUID()), getPlayerName(), getLastOnline()); - } - - /** - * Gets the total votes for the specified top voter category. - * - * @param top the top voter category - * @return the total votes for the specified top voter category - */ - public int getTotal(TopVoter top) { - switch (top) { - case AllTime: - return getUserData().getInt("AllTimeTotal"); - case Daily: - return getUserData().getInt("DailyTotal"); - case Monthly: - if (plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) { - return getData().getInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath()); - } - return getData().getInt("MonthTotal"); - case Weekly: - return getUserData().getInt("WeeklyTotal"); - default: - break; - } - return 0; - } - - /** - * Gets the total votes for the specified top voter category at a specific time. - * - * @param top the top voter category - * @param atTime the specific time - * @return the total votes for the specified top voter category at the specific - * time - */ - public int getTotal(TopVoter top, LocalDateTime atTime) { - switch (top) { - case AllTime: - return getUserData().getInt("AllTimeTotal"); - case Daily: - return getUserData().getInt("DailyTotal"); - case Monthly: - if (plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) { - return getData().getInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath(atTime)); - } - return getData().getInt("MonthTotal"); - case Weekly: - return getUserData().getInt("WeeklyTotal"); - default: - break; - } - return 0; - } - - /** - * Gets the number of votes for the vote party. - * - * @return the number of votes for the vote party - */ - public int getVotePartyVotes() { - return getUserData().getInt("VotePartyVotes"); - } - - /** - * Gets the vote shop identifier limit. - * - * @param identifier the identifier for the vote shop - * @return the vote shop identifier limit - */ - public int getVoteShopIdentifierLimit(String identifier) { - String path = "VoteShopLimit" + identifier; - if (usesSharedMysqlPoints()) { - // Placeholder and GUI rendering may run on the Bukkit main thread or a - // Folia-owned tick thread. This accessor therefore never performs JDBC for - // shared MySQL; purchases remain protected by their atomic reservation. - return getData().getInt(path, - isCached() ? UserDataFetchMode.CACHE_ONLY : UserDataFetchMode.TEMP_ONLY); + + /** + * Checks if the user has voted on almost all sites. + * + * @return true, if the user has voted on almost all sites + */ + public boolean checkAlmostAllVotes() { + if (getSitesNotVotedOn() <= 1) { + return true; } - return getData().getInt(path); + return false; } - - /** - * Gets the weekly total votes. - * - * @return the weekly total votes - * @deprecated Use getTotal(TopVoter.Weekly) instead - */ - @Deprecated - public int getWeeklyTotal() { - return getTotal(TopVoter.Weekly); - } - - /** - * Gets the week vote streak. - * - * @return the week vote streak - */ - @Deprecated - public int getWeekVoteStreak() { - return getData().getInt("WeekVoteStreak"); - } - - /** - * Gives the daily top voter award. - * - * @param place the place of the top voter - * @param path the path to the reward configuration - */ - public void giveDailyTopVoterAward(int place, String path) { - SpecialRewardType type = SpecialRewardType.TOPVOTER; - type.setType("Daily"); - type.setAmount(1); - PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null); - Bukkit.getPluginManager().callEvent(event); - - if (event.isCancelled()) { - return; - } - new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), - plugin.getSpecialRewardsConfig().getDailyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) - .withPlaceHolder("topvoter", "Daily").withPlaceHolder("votes", "" + getTotal(TopVoter.Daily)) - .setOnline(isOnline()).send(this); - } - - /** - * Gives the monthly top voter award. - * - * @param place the place of the top voter - * @param path the path to the reward configuration - */ - public void giveMonthlyTopVoterAward(int place, String path) { - SpecialRewardType type = SpecialRewardType.TOPVOTER; - type.setType("Monthly"); - type.setAmount(1); - PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null); - Bukkit.getPluginManager().callEvent(event); - - if (event.isCancelled()) { - return; - } - new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), - plugin.getSpecialRewardsConfig().getMonthlyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) - .withPlaceHolder("topvoter", "Monthly").withPlaceHolder("votes", "" + getTotal(TopVoter.Monthly)) - .setOnline(isOnline()).send(this); - } - - /** - * Gives the weekly top voter award. - * - * @param place the place of the top voter - * @param path the path to the reward configuration - */ - public void giveWeeklyTopVoterAward(int place, String path) { - SpecialRewardType type = SpecialRewardType.TOPVOTER; - type.setType("Weekly"); - type.setAmount(1); - PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null); - Bukkit.getPluginManager().callEvent(event); - - if (event.isCancelled()) { - return; - } - new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), - plugin.getSpecialRewardsConfig().getWeeklyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) - .withPlaceHolder("topvoter", "Weekly").withPlaceHolder("votes", "" + getTotal(TopVoter.Weekly)) - .setOnline(isOnline()).send(this); - } - - /** - * Gets how many unique vote sites this user has voted on today. - * - * Uses existing LastVotes data (no storage). A site counts if its last-vote - * timestamp falls on "today" using VotingPlugin's time offset. - * - * @return number of unique sites voted on today - */ - public long getUniqueVoteSitesToday() { - LocalDateTime now = plugin.getTimeChecker().getTime(); - LocalDate today = now.toLocalDate(); - - long count = 0; - - for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { - if (site == null || !site.isEnabled() || site.isHidden()) { - continue; - } - - long time = getTime(site); - if (time <= 0) { - continue; - } - - // Match the same offset handling as canVoteSite() - LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault()) - .plusHours(plugin.getOptions().getTimeHourOffSet()); - - if (lastVote.toLocalDate().equals(today)) { - count++; - } - } - - return count; - } - - /** - * Checks if the user has a percentage of the total votes. - * - * @param top the top voter category - * @param percentage the percentage of the total votes - * @param time the specific time - * @return true if the user has the percentage of the total votes, false - * otherwise - */ - public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time) { - int total = getTotal(top, time); - switch (top) { - case Daily: - return (double) total / (double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() - * 100 > percentage; - case Monthly: - return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() - * time.getMonth().length(false)) * 100 > percentage; - case Weekly: - return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() * 7) * 100 > percentage; - default: - return false; - } - } - - /** - * Checks if the user is ignored for top voter. - * - * @return true if the user is ignored for top voter, false otherwise - */ - public boolean isTopVoterIgnore() { - return getUserData().getBoolean("TopVoterIgnore"); - } - - /** - * Gives login rewards to the user. - */ - public void loginRewards() { - if (plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LoginRewards")) { - new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LoginRewards").send(this); - } - } - - /** Storage preparation stays on its caller; AdvancedCore owns platform reward scheduling. */ - public CompletionStage loginRewardsAsync() { - if (!plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LoginRewards")) { - return CompletableFuture.completedFuture(null); - } - return new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LoginRewards").sendAsync(this); - } - - /** - * Gives logout rewards to the user. - */ - public void logoutRewards() { - if (plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LogoutRewards")) { - new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LogoutRewards").send(this); - } - } - - /** - * Merges the provided data with the current data. - * - * @param toAdd the data to add - */ - public void mergeData(HashMap toAdd) { - HashMap currentData = getData().getValues(); - HashMap newData = new HashMap<>(); - - for (TopVoter top : TopVoter.values()) { - if (toAdd.containsKey(top.getColumnName()) && currentData.containsKey(top.getColumnName())) { - newData.put(top.getColumnName(), new DataValueInt( - currentData.get(top.getColumnName()).getInt() + toAdd.get(top.getColumnName()).getInt())); - } - } - - if (newData.size() > 0) { - getData().setValues(newData); - } - } - - /** Legacy admission-only API; use offVoteAsync for dependent work. */ - public void offVote() { - if (!plugin.getOptions().isProcessRewards()) return; - Player player = getPlayer(); - if (!plugin.getOptions().isOnlineMode()) player = Bukkit.getPlayer(getPlayerName()); - offVoteAndThen(player, () -> { }); - } - - /** - * Capture permission on the player owner, then replay on the storage worker. - * Completion means durable replay finished, including an empty/disabled no-op. - * A retired player is skipped without touching its entity from a global fallback. - */ - public CompletionStage offVoteAsync(Player player) { - if (!plugin.getOptions().isProcessRewards() || player == null) { - return CompletableFuture.completedFuture(null); - } - CompletableFuture confirmed = new CompletableFuture<>(); - plugin.getOfflineVoteOwnerHandoffs().submit(confirmed, begin -> - BukkitCompletionScheduler.run(plugin, player, () -> { - if (!begin.getAsBoolean()) return; - try { - if (!player.isOnline()) { - confirmed.complete(null); - return; - } - boolean ignored = player.hasPermission("VotingPlugin.TopVoter.Ignore"); - plugin.getUserManager().getDataManager().getTimer().execute(() -> { - try { - cache(); - offVoteWithCapturedTopVoterIgnoreAsync(ignored, () -> { }, true).whenComplete((value, failure) -> { - if (failure == null) confirmed.complete(null); - else confirmed.completeExceptionally(failure); - }); - } catch (RuntimeException | Error failure) { - confirmed.completeExceptionally(failure); - } - }); - } catch (RuntimeException | Error failure) { - confirmed.completeExceptionally(failure); - } - }, () -> { if (begin.getAsBoolean()) confirmed.complete(null); }, () -> { - if (begin.getAsBoolean()) confirmed.completeExceptionally( - new IllegalStateException("Offline replay owner scheduling rejected")); - })); - return confirmed; - } - - /** Dependent work always returns to storage, even for a no-op replay. */ - public void offVoteAndThen(Player player, Runnable continuation) { - offVoteAsync(player).whenComplete((ignored, failure) -> { - if (failure != null) { - plugin.getLogger().warning("Offline vote replay remains pending for " + getUUID() + ": " + failure); - return; - } - try { - plugin.getUserManager().getDataManager().getTimer().execute(continuation); - } catch (RuntimeException rejected) { - plugin.getLogger().warning("Offline replay continuation rejected for " + getUUID() + ": " + rejected); - } - }); - } - + /** - * Legacy void entry point: enqueue durable replay on the storage worker. - * Returning means admission, not completed reward delivery. Never wait on a - * platform thread or clear a queue before the reward completion protocol. + * Checks the day vote streak and updates it if necessary. + * + * @param forceBungee whether to force bungee */ - public void offVoteWithCapturedTopVoterIgnore(boolean currentTopVoterIgnore) { - if (!plugin.getOptions().isProcessRewards()) return; - offVoteWithCapturedTopVoterIgnoreAndThen(currentTopVoterIgnore, () -> { }); - } - - /** - * Schedules legacy replay and invokes the continuation only after the durable - * replay completion. The continuation runs on the storage worker. - */ - public void offVoteWithCapturedTopVoterIgnoreAndThen(boolean currentTopVoterIgnore, Runnable continuation) { - plugin.getUserManager().getDataManager().getTimer().execute(() -> { - try { - cache(); - offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore) - .whenComplete((ignored, failure) -> { - if (failure != null) { - plugin.getLogger().warning("Offline vote replay remains pending for " - + getUUID() + ": " + failure); - } else { - continuation.run(); - } - }); - } catch (RuntimeException failure) { - plugin.getLogger().warning("Offline vote replay could not start for " + getUUID() + ": " + failure); + @Deprecated + public void checkDayVoteStreak(boolean forceBungee) { + if (!voteStreakUpdatedToday(LocalDateTime.now())) { + if (!plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() || hasPercentageTotal( + TopVoter.Daily, plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)) { + plugin.extraDebug("Adding day vote streak to " + getUUID() + " " + + plugin.getSpecialRewardsConfig().isVoteStreakRequirementUsePercentage() + " " + + hasPercentageTotal(TopVoter.Daily, + plugin.getSpecialRewardsConfig().getVoteStreakRequirementDay(), null)); + addDayVoteStreak(); + plugin.getSpecialRewards().checkVoteStreak(null, this, "Day", forceBungee); + setDayVoteStreakLastUpdate(System.currentTimeMillis()); } - }); + } } /** - * Process the next offline-reward batch on the storage worker. The original - * queue and a durable pending snapshot remain available until both reward - * delivery and a confirmed worker-side commit succeed. No Bukkit-affecting - * reward facade runs synchronously on the storage worker. + * Clears the offline votes. */ - public CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore) { - return offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore, () -> { }); + public void clearOfflineVotes() { + setOfflineVotes(new ArrayList<>()); + setOfflineRewards(new ArrayList<>()); } /** - * The callback marks the first possible nontransactional reward effect. - * Reads/cache misses before this point remain retryable in ordered VoteUpdate. + * Clears the total votes for all top voter categories. */ - public CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore, - Runnable beforeRewardEffects) { - return offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore, beforeRewardEffects, false); + public void clearTotals() { + for (TopVoter top : TopVoter.values()) { + resetTotals(top); + } } - /** Login/hook dependents may await the existing batch without starting another reward chain. */ - private CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore, - Runnable beforeRewardEffects, boolean joinExisting) { - if (!plugin.getOptions().isProcessRewards()) return CompletableFuture.completedFuture(null); - String uuid = getUUID(); - CompletableFuture confirmed = new CompletableFuture<>(); - synchronized (IN_FLIGHT_OFFLINE_VOTE_REWARDS) { - if (offlineVoteBulkClearActive) { - return CompletableFuture.failedFuture(new IllegalStateException("Offline-vote bulk clear is active")); - } - CompletableFuture existing = IN_FLIGHT_OFFLINE_VOTE_REWARDS.putIfAbsent(uuid, confirmed); - if (existing != null) { - // A dependent stage cannot cancel or complete the owning replay's future. - return joinExisting ? existing.thenApply(ignored -> null) - : CompletableFuture.failedFuture(new IllegalStateException( - "Offline-vote reward batch is still running for " + uuid)); - } - } - confirmed.whenComplete((ignored, failure) -> IN_FLIGHT_OFFLINE_VOTE_REWARDS.remove(uuid, confirmed)); - try { - if (isTopVoterIgnore() != currentTopVoterIgnore) setTopVoterIgnore(currentTopVoterIgnore); - if (!getPendingOfflineVoteRewardBatch().isEmpty()) { - beforeRewardEffects.run(); - throw new IllegalStateException("Earlier offline-vote rewards require reconciliation for " + uuid); - } - ArrayList offlineVotes = getOfflineVotes(); - if (offlineVotes.isEmpty()) { - confirmed.complete(null); - return confirmed; - } + /** + * Gets the all-time total votes. + * + * @return the all-time total votes + * @deprecated Use getTotal(TopVoter.AllTime) when able instead + */ + @Deprecated + public int getAllTimeTotal() { + return getTotal(TopVoter.AllTime); + } + + /** + * Gets the best day vote streak. + * + * @return the best day vote streak + */ + @Deprecated + public int getBestDayVoteStreak() { + return getData().getInt("BestDayVoteStreak"); + } + + /** + * Gets the best month vote streak. + * + * @return the best month vote streak + */ + @Deprecated + public int getBestMonthVoteStreak() { + return getData().getInt("BestMonthVoteStreak"); + } + + /** + * Gets the best week vote streak. + * + * @return the best week vote streak + */ + @Deprecated + public int getBestWeekVoteStreak() { + return getData().getInt("BestWeekVoteStreak"); + } + + /** + * Checks if the cooldown check is enabled. + * + * @return true, if the cooldown check is enabled + */ + public boolean getCoolDownCheck() { + return getData().getBoolean(getCoolDownCheckPath()); + } + + /** + * Gets the path for the cooldown check. + * + * @return the cooldown check path + */ + public String getCoolDownCheckPath() { + if (plugin.getBungeeSettings().isUseBungeecoord()) { + return "CoolDownCheck_" + plugin.getBungeeSettings().getServerNameStorage(); + } + return "CoolDownCheck"; + } + + /** + * Checks if the cooldown check is enabled for a specific vote site. + * + * @param site the vote site + * @return true, if the cooldown check is enabled for the site + */ + public boolean getCoolDownCheckSite(VoteSite site) { + HashMap coolDownChecks = getCoolDownCheckSiteList(); + if (coolDownChecks.containsKey(site.getKey())) { + return coolDownChecks.get(site.getKey()).booleanValue(); + } + return false; + } + + /** + * Gets the list of cooldown checks for all vote sites. + * + * @return the list of cooldown checks for all vote sites + */ + public HashMap getCoolDownCheckSiteList() { + HashMap coolDownChecks = new HashMap<>(); + ArrayList coolDownCheck = getData().getStringList(getCoolDownCheckSitePath()); + for (String str : coolDownCheck) { + String[] data = str.split("//"); + if (data.length > 1 && plugin.getVoteSiteManager().hasVoteSite(data[0])) { + VoteSite site = plugin.getVoteSiteManager().getVoteSite(data[0], true); + if (site != null) { + Boolean b = Boolean.valueOf(data[1]); + coolDownChecks.put(site.getKey(), b); + } + } + } + return coolDownChecks; + } + + /** + * Gets the path for the cooldown check site list. + * + * @return the cooldown check site list path + */ + public String getCoolDownCheckSitePath() { + if (plugin.getBungeeSettings().isUseBungeecoord()) { + return "CoolDownCheck_" + plugin.getBungeeSettings().getServerNameStorage() + "_Sites"; + } + return "CoolDownCheck" + "_Sites"; + } + + /** + * Gets the daily total votes. + * + * @return the daily total votes + * @deprecated Use getTotal(TopVoter.Daily) instead + */ + @Deprecated + public int getDailyTotal() { + return getTotal(TopVoter.Daily); + } + + /** + * Gets the day vote streak. + * + * @return the day vote streak + */ + @Deprecated + public int getDayVoteStreak() { + return getData().getInt("DayVoteStreak"); + } + + /** + * Gets the last update time for the day vote streak. + * + * @return the last update time for the day vote streak + */ + @Deprecated + public long getDayVoteStreakLastUpdate() { + String str = getData().getString("DayVoteStreakLastUpdate"); + if (str == null || str.isEmpty() || str.equals("null")) { + return 0; + } + try { + return Long.parseLong(str); + } catch (NumberFormatException e) { + return 0; + } + } + + /** + * Checks if the broadcast is disabled. + * + * @return true if the broadcast is disabled, false otherwise + */ + public boolean getDisableBroadcast() { + return getUserData().getBoolean("DisableBroadcast"); + } + + /** + * Gets the day when the user has gotten all sites. + * + * @return the day when the user has gotten all sites + */ + public int getGottenAllSitesDay() { + return getData().getInt(plugin.getVotingPluginUserManager().getGottenAllSitesDayPath(), 0); + } + + /** + * Gets the day when the user has gotten almost all sites. + * + * @return the day when the user has gotten almost all sites + */ + public int getGottenAlmostAllSitesDay() { + return getData().getInt(plugin.getVotingPluginUserManager().getGottenAlmostAllSitesDayPath(), 0); + } + + /** + * Gets the highest daily total votes. + * + * @return the highest daily total votes + */ + public int getHighestDailyTotal() { + return getData().getInt("HighestDailyTotal"); + } + + /** + * Returns whether this user has already claimed the NameMC like reward. + * + * @return true if claimed + */ + public boolean hasClaimedNameMCLikeReward() { + return getUserData().getBoolean("NameMCLikeRewardClaimed"); + } + + /** + * Sets whether this user has already claimed the NameMC like reward. + * + * @param claimed true if claimed + */ + public void setClaimedNameMCLikeReward(boolean claimed) { + // A completed NameMC reward must be durably persisted before clearing + // its recovery marker; never leave this as an ordinary queued write. + getUserData().setBoolean("NameMCLikeRewardClaimed", claimed, false); + } + + /** Whether the NameMC grant may have started but is not yet confirmed. */ + public boolean isNameMCLikeRewardPending() { + return getUserData().getBoolean("NameMCLikeRewardPending"); + } + + /** Persist/clear the NameMC replay fence on the user-data worker. */ + public void setNameMCLikeRewardPending(boolean pending) { + getUserData().setBoolean("NameMCLikeRewardPending", pending, false); + } + + /** + * Gets the highest monthly total votes. + * + * @return the highest monthly total votes + */ + public int getHighestMonthlyTotal() { + return getData().getInt("HighestMonthlyTotal"); + } + + /** + * Gets the highest weekly total votes. + * + * @return the highest weekly total votes + */ + public int getHighestWeeklyTotal() { + return getData().getInt("HighestWeeklyTotal"); + } + + /** + * Gets the total votes for the last month. + * + * @return the total votes for the last month + */ + public int getLastMonthTotal() { + return getData().getInt("LastMonthTotal"); + } + + /** + * Gets the last votes for each vote site. + * + * @return a map of vote sites and the last vote time + */ + public HashMap getLastVotes() { + HashMap lastVotes = new HashMap<>(); + ArrayList lastVotesList = getUserData().getStringList("LastVotes"); + + for (String str : lastVotesList) { + String[] data = str.split("//"); + if (data.length <= 1) { + continue; + } + + String rawSiteKey = data[0]; + String rawTime = data[1]; + + if (!plugin.getVoteSiteManager().hasVoteSite(rawSiteKey)) { + continue; + } + + VoteSite site = plugin.getVoteSiteManager().getVoteSite(rawSiteKey, true); + if (site == null) { + continue; + } + + long time = 0; + try { + time = Long.parseLong(rawTime); + } catch (NumberFormatException ignored) { + time = 0; + } + + lastVotes.put(site, time); + } + + return lastVotes; + } + + /** + * Gets the time of the last vote. + * + * @return the time of the last vote + */ + public Long getLastVoteTime() { + Long time = Long.valueOf(0); + for (Long value : getLastVotes().values()) { + if (value.longValue() > time) { + time = value; + } + } + return time; + } + + /** + * Gets the last vote time for a specific vote site. + * + * @param voteSite the vote site + * @return the last vote time for the vote site + */ + public long getLastVoteTimer(VoteSite voteSite) { + HashMap times = getLastVotes(); + if (times.containsKey(voteSite)) { + return times.get(voteSite).longValue(); + } + return 0; + } + + /** + * Gets the last vote times sorted in descending order. + * + * @return a map of vote sites and the last vote times sorted in descending + * order + */ + public HashMap getLastVoteTimesSorted() { + LinkedHashMap times = new LinkedHashMap<>(); + + for (VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + times.put(voteSite, getTime(voteSite)); + } + LinkedHashMap sorted = new LinkedHashMap<>( + times.entrySet().stream().sorted(Collections.reverseOrder(Map.Entry.comparingByValue())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + return sorted; + } + + /** + * Gets the total votes for the month. + * + * @return the total votes for the month + * @deprecated Use getTotal(TopVoter.Monthly) instead + */ + @Deprecated + public int getMonthTotal() { + return getTotal(TopVoter.Monthly); + } + + /** + * Gets the month vote streak. + * + * @return the month vote streak + */ + @Deprecated + public int getMonthVoteStreak() { + return getData().getInt("MonthVoteStreak"); + } + + /** + * Gets the next time all sites are available for voting. + * + * @return the next time all sites are available for voting + */ + public long getNextTimeAllSitesAvailable() { + long longest = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + long seconds = voteNextDurationTime(site); + if (seconds > longest) { + longest = seconds; + } + } + + return longest; + } + + /** + * Gets the next time the first site is available for voting. + * + * @return seconds until first site is available, or 0 if none + */ + public long getNextTimeFirstSiteAvailable() { + NextSite next = getNextSiteAvailable(); + return next == null ? 0 : next.getSecondsUntilAvailable(); + } + + /** + * Returns the next VoteSite that will become available, and how many seconds + * until it is available. + * + * - Only considers enabled sites. - Skips hidden sites (matching + * canVoteAll/canVoteAny intent for player-facing voting). - Only considers + * sites the player CANNOT currently vote on (seconds > 0). + * + * @return NextSite or null if there is no upcoming site (i.e. can vote all, or + * no delays) + */ + public NextSite getNextSiteAvailable() { + List sites = plugin.getVoteSiteManager().getVoteSitesEnabled(); + if (sites == null || sites.isEmpty()) { + return null; + } + + VoteSite bestSite = null; + long bestSeconds = 0; + + for (int i = 0; i < sites.size(); i++) { + VoteSite site = sites.get(i); + if (site == null) { + continue; + } + + // Match the same "don't care" sites as your other checks generally do + if (!site.isEnabled() || site.isHidden()) { + continue; + } + + // If you can already vote, it isn't "next" + if (canVoteSite(site)) { + continue; + } + + long seconds = voteNextDurationTime(site); // uses getTime(site) internally + if (seconds <= 0) { + continue; + } + + if (bestSite == null || seconds < bestSeconds) { + bestSite = site; + bestSeconds = seconds; + } + } + + return bestSite == null ? null : new NextSite(bestSite, bestSeconds); + } + + /** + * Gets the number of offline votes for the specified vote site. + * + * @param site the vote site + * @return the number of offline votes for the specified vote site + */ + public int getNumberOfOfflineVotes(VoteSite site) { + ArrayList offlineVotes = getOfflineVotes(); + int num = 0; + for (String str : offlineVotes) { + if (str.equals(site.getKey())) { + num++; + } + } + return num; + } + + /** + * Gets the list of offline votes. + * + * @return the list of offline votes + */ + public ArrayList getOfflineVotes() { + return getUserData().getStringList("OfflineVotes"); + } + + /** + * Gets the points of the user. + * + * @return the points of the user + */ + public int getPoints() { + return getUserData().getInt(getPointsPath()); + } + + /** + * Gets the path for the points. + * + * @return the points path + */ + public String getPointsPath() { + if (plugin.getBungeeSettings().isPerServerPoints()) { + return plugin.getBungeeSettings().getServerNameStorage() + "_Points"; + } + return "Points"; + } + + /** + * Gets the number of sites not voted on. + * + * @return the number of sites not voted on + */ + public int getSitesNotVotedOn() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden()) { + if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) { + if (canVoteSite(site)) { + amount++; + } + } + } + } + return amount; + } + + /** + * Match the existing offline result without resolving a live Bukkit player. + * Permission-gated sites are excluded because the normal call uses + * {@code hasPermission(permission, false)}, which returns false offline. + */ + public int getSitesNotVotedOnWithoutOnlinePermissions() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden() && site.getPermissionToView().isEmpty() && canVoteSite(site)) amount++; + } + return amount; + } + + public int getTotalNumberOfSites() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden()) { + if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) { + amount++; + } + } + } + return amount; + } + + /** + * Match the existing offline total without resolving a live Bukkit player. + * Permission-gated sites are excluded because the normal call uses + * {@code hasPermission(permission, false)}, which returns false offline. + */ + public int getTotalNumberOfSitesWithoutOnlinePermissions() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden() && site.getPermissionToView().isEmpty()) amount++; + } + return amount; + } + + public int getSitesVotedOn() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!canVoteSite(site)) { + amount++; + } + } + return amount; + } + + /** + * Gets the time. + * + * @param voteSite the vote site + * @return the time + */ + public long getTime(VoteSite voteSite) { + HashMap lastVotes = getLastVotes(); + if (lastVotes.containsKey(voteSite)) { + return lastVotes.get(voteSite); + } + return 0; + } + + /** + * Gets the top voter player. + * + * @return the top voter player + */ + public TopVoterPlayer getTopVoterPlayer() { + return new TopVoterPlayer(UUID.fromString(getUUID()), getPlayerName(), getLastOnline()); + } + + /** + * Gets the total votes for the specified top voter category. + * + * @param top the top voter category + * @return the total votes for the specified top voter category + */ + public int getTotal(TopVoter top) { + switch (top) { + case AllTime: + return getUserData().getInt("AllTimeTotal"); + case Daily: + return getUserData().getInt("DailyTotal"); + case Monthly: + if (plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) { + return getData().getInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath()); + } + return getData().getInt("MonthTotal"); + case Weekly: + return getUserData().getInt("WeeklyTotal"); + default: + break; + } + return 0; + } + + /** + * Gets the total votes for the specified top voter category at a specific time. + * + * @param top the top voter category + * @param atTime the specific time + * @return the total votes for the specified top voter category at the specific + * time + */ + public int getTotal(TopVoter top, LocalDateTime atTime) { + switch (top) { + case AllTime: + return getUserData().getInt("AllTimeTotal"); + case Daily: + return getUserData().getInt("DailyTotal"); + case Monthly: + if (plugin.getConfigFile().isUseMonthDateTotalsAsPrimaryTotal()) { + return getData().getInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath(atTime)); + } + return getData().getInt("MonthTotal"); + case Weekly: + return getUserData().getInt("WeeklyTotal"); + default: + break; + } + return 0; + } + + /** + * Gets the number of votes for the vote party. + * + * @return the number of votes for the vote party + */ + public int getVotePartyVotes() { + return getUserData().getInt("VotePartyVotes"); + } + + /** + * Gets the vote shop identifier limit. + * + * @param identifier the identifier for the vote shop + * @return the vote shop identifier limit + */ + public int getVoteShopIdentifierLimit(String identifier) { + String path = "VoteShopLimit" + identifier; + if (usesSharedMysqlPoints()) { + // Placeholder and GUI rendering may run on the Bukkit main thread or a + // Folia-owned tick thread. This accessor therefore never performs JDBC for + // shared MySQL; purchases remain protected by their atomic reservation. + return getData().getInt(path, + isCached() ? UserDataFetchMode.CACHE_ONLY : UserDataFetchMode.TEMP_ONLY); + } + return getData().getInt(path); + } + + /** + * Gets the weekly total votes. + * + * @return the weekly total votes + * @deprecated Use getTotal(TopVoter.Weekly) instead + */ + @Deprecated + public int getWeeklyTotal() { + return getTotal(TopVoter.Weekly); + } + + /** + * Gets the week vote streak. + * + * @return the week vote streak + */ + @Deprecated + public int getWeekVoteStreak() { + return getData().getInt("WeekVoteStreak"); + } + + /** + * Gives the daily top voter award. + * + * @param place the place of the top voter + * @param path the path to the reward configuration + */ + public void giveDailyTopVoterAward(int place, String path) { + SpecialRewardType type = SpecialRewardType.TOPVOTER; + type.setType("Daily"); + type.setAmount(1); + PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null); + Bukkit.getPluginManager().callEvent(event); + + if (event.isCancelled()) { + return; + } + new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), + plugin.getSpecialRewardsConfig().getDailyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) + .withPlaceHolder("topvoter", "Daily").withPlaceHolder("votes", "" + getTotal(TopVoter.Daily)) + .setOnline(isOnline()).send(this); + } + + /** + * Gives the monthly top voter award. + * + * @param place the place of the top voter + * @param path the path to the reward configuration + */ + public void giveMonthlyTopVoterAward(int place, String path) { + SpecialRewardType type = SpecialRewardType.TOPVOTER; + type.setType("Monthly"); + type.setAmount(1); + PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null); + Bukkit.getPluginManager().callEvent(event); + + if (event.isCancelled()) { + return; + } + new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), + plugin.getSpecialRewardsConfig().getMonthlyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) + .withPlaceHolder("topvoter", "Monthly").withPlaceHolder("votes", "" + getTotal(TopVoter.Monthly)) + .setOnline(isOnline()).send(this); + } + + /** + * Gives the weekly top voter award. + * + * @param place the place of the top voter + * @param path the path to the reward configuration + */ + public void giveWeeklyTopVoterAward(int place, String path) { + SpecialRewardType type = SpecialRewardType.TOPVOTER; + type.setType("Weekly"); + type.setAmount(1); + PlayerSpecialRewardEvent event = new PlayerSpecialRewardEvent(this, type, null); + Bukkit.getPluginManager().callEvent(event); + + if (event.isCancelled()) { + return; + } + new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), + plugin.getSpecialRewardsConfig().getWeeklyAwardRewardsPath(path)).withPlaceHolder("place", "" + place) + .withPlaceHolder("topvoter", "Weekly").withPlaceHolder("votes", "" + getTotal(TopVoter.Weekly)) + .setOnline(isOnline()).send(this); + } + + /** + * Gets how many unique vote sites this user has voted on today. + * + * Uses existing LastVotes data (no storage). A site counts if its last-vote + * timestamp falls on "today" using VotingPlugin's time offset. + * + * @return number of unique sites voted on today + */ + public long getUniqueVoteSitesToday() { + LocalDateTime now = plugin.getTimeChecker().getTime(); + LocalDate today = now.toLocalDate(); + + long count = 0; + + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (site == null || !site.isEnabled() || site.isHidden()) { + continue; + } + + long time = getTime(site); + if (time <= 0) { + continue; + } + + // Match the same offset handling as canVoteSite() + LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault()) + .plusHours(plugin.getOptions().getTimeHourOffSet()); + + if (lastVote.toLocalDate().equals(today)) { + count++; + } + } + + return count; + } + + /** + * Checks if the user has a percentage of the total votes. + * + * @param top the top voter category + * @param percentage the percentage of the total votes + * @param time the specific time + * @return true if the user has the percentage of the total votes, false + * otherwise + */ + public boolean hasPercentageTotal(TopVoter top, double percentage, LocalDateTime time) { + int total = getTotal(top, time); + switch (top) { + case Daily: + return (double) total / (double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() + * 100 > percentage; + case Monthly: + return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() + * time.getMonth().length(false)) * 100 > percentage; + case Weekly: + return total / ((double) plugin.getVoteSiteManager().getVoteSitesEnabled().size() * 7) * 100 > percentage; + default: + return false; + } + } + + /** + * Checks if the user is ignored for top voter. + * + * @return true if the user is ignored for top voter, false otherwise + */ + public boolean isTopVoterIgnore() { + return getUserData().getBoolean("TopVoterIgnore"); + } + + /** + * Gives login rewards to the user. + */ + public void loginRewards() { + if (plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LoginRewards")) { + new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LoginRewards").send(this); + } + } + + /** Storage preparation stays on its caller; AdvancedCore owns platform reward scheduling. */ + public CompletionStage loginRewardsAsync() { + if (!plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LoginRewards")) { + return CompletableFuture.completedFuture(null); + } + return new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LoginRewards").sendAsync(this); + } + + /** + * Gives logout rewards to the user. + */ + public void logoutRewards() { + if (plugin.getRewardHandler().hasRewards(plugin.getSpecialRewardsConfig().getData(), "LogoutRewards")) { + new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), "LogoutRewards").send(this); + } + } + + /** + * Merges the provided data with the current data. + * + * @param toAdd the data to add + */ + public void mergeData(HashMap toAdd) { + HashMap currentData = getData().getValues(); + HashMap newData = new HashMap<>(); + + for (TopVoter top : TopVoter.values()) { + if (toAdd.containsKey(top.getColumnName()) && currentData.containsKey(top.getColumnName())) { + newData.put(top.getColumnName(), new DataValueInt( + currentData.get(top.getColumnName()).getInt() + toAdd.get(top.getColumnName()).getInt())); + } + } + + if (newData.size() > 0) { + getData().setValues(newData); + } + } + + /** Legacy admission-only API; use offVoteAsync for dependent work. */ + public void offVote() { + if (!plugin.getOptions().isProcessRewards()) return; + Player player = getPlayer(); + if (!plugin.getOptions().isOnlineMode()) player = Bukkit.getPlayer(getPlayerName()); + offVoteAndThen(player, () -> { }); + } + + /** + * Capture permission on the player owner, then replay on the storage worker. + * Completion means durable replay finished, including an empty/disabled no-op. + * A retired player is skipped without touching its entity from a global fallback. + */ + public CompletionStage offVoteAsync(Player player) { + if (!plugin.getOptions().isProcessRewards() || player == null) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture confirmed = new CompletableFuture<>(); + plugin.getOfflineVoteOwnerHandoffs().submit(confirmed, begin -> + BukkitCompletionScheduler.run(plugin, player, () -> { + if (!begin.getAsBoolean()) return; + try { + if (!player.isOnline()) { + confirmed.complete(null); + return; + } + boolean ignored = player.hasPermission("VotingPlugin.TopVoter.Ignore"); + plugin.getUserManager().getDataManager().getTimer().execute(() -> { + try { + cache(); + offVoteWithCapturedTopVoterIgnoreAsync(ignored, () -> { }, true).whenComplete((value, failure) -> { + if (failure == null) confirmed.complete(null); + else confirmed.completeExceptionally(failure); + }); + } catch (RuntimeException | Error failure) { + confirmed.completeExceptionally(failure); + } + }); + } catch (RuntimeException | Error failure) { + confirmed.completeExceptionally(failure); + } + }, () -> { if (begin.getAsBoolean()) confirmed.complete(null); }, () -> { + if (begin.getAsBoolean()) confirmed.completeExceptionally( + new IllegalStateException("Offline replay owner scheduling rejected")); + })); + return confirmed; + } + + /** Dependent work always returns to storage, even for a no-op replay. */ + public void offVoteAndThen(Player player, Runnable continuation) { + offVoteAsync(player).whenComplete((ignored, failure) -> { + if (failure != null) { + plugin.getLogger().warning("Offline vote replay remains pending for " + getUUID() + ": " + failure); + return; + } + try { + plugin.getUserManager().getDataManager().getTimer().execute(continuation); + } catch (RuntimeException rejected) { + plugin.getLogger().warning("Offline replay continuation rejected for " + getUUID() + ": " + rejected); + } + }); + } + + /** + * Legacy void entry point: enqueue durable replay on the storage worker. + * Returning means admission, not completed reward delivery. Never wait on a + * platform thread or clear a queue before the reward completion protocol. + */ + public void offVoteWithCapturedTopVoterIgnore(boolean currentTopVoterIgnore) { + if (!plugin.getOptions().isProcessRewards()) return; + offVoteWithCapturedTopVoterIgnoreAndThen(currentTopVoterIgnore, () -> { }); + } + + /** + * Schedules legacy replay and invokes the continuation only after the durable + * replay completion. The continuation runs on the storage worker. + */ + public void offVoteWithCapturedTopVoterIgnoreAndThen(boolean currentTopVoterIgnore, Runnable continuation) { + plugin.getUserManager().getDataManager().getTimer().execute(() -> { + try { + cache(); + offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore) + .whenComplete((ignored, failure) -> { + if (failure != null) { + plugin.getLogger().warning("Offline vote replay remains pending for " + + getUUID() + ": " + failure); + } else { + continuation.run(); + } + }); + } catch (RuntimeException failure) { + plugin.getLogger().warning("Offline vote replay could not start for " + getUUID() + ": " + failure); + } + }); + } + + /** + * Process the next offline-reward batch on the storage worker. The original + * queue and a durable pending snapshot remain available until both reward + * delivery and a confirmed worker-side commit succeed. No Bukkit-affecting + * reward facade runs synchronously on the storage worker. + */ + public CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore) { + return offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore, () -> { }); + } + + /** + * The callback marks the first possible nontransactional reward effect. + * Reads/cache misses before this point remain retryable in ordered VoteUpdate. + */ + public CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore, + Runnable beforeRewardEffects) { + return offVoteWithCapturedTopVoterIgnoreAsync(currentTopVoterIgnore, beforeRewardEffects, false); + } + + /** Login/hook dependents may await the existing batch without starting another reward chain. */ + private CompletionStage offVoteWithCapturedTopVoterIgnoreAsync(boolean currentTopVoterIgnore, + Runnable beforeRewardEffects, boolean joinExisting) { + if (!plugin.getOptions().isProcessRewards()) return CompletableFuture.completedFuture(null); + String uuid = getUUID(); + CompletableFuture confirmed = new CompletableFuture<>(); + synchronized (IN_FLIGHT_OFFLINE_VOTE_REWARDS) { + if (offlineVoteBulkClearActive) { + return CompletableFuture.failedFuture(new IllegalStateException("Offline-vote bulk clear is active")); + } + CompletableFuture existing = IN_FLIGHT_OFFLINE_VOTE_REWARDS.putIfAbsent(uuid, confirmed); + if (existing != null) { + // A dependent stage cannot cancel or complete the owning replay's future. + return joinExisting ? existing.thenApply(ignored -> null) + : CompletableFuture.failedFuture(new IllegalStateException( + "Offline-vote reward batch is still running for " + uuid)); + } + } + confirmed.whenComplete((ignored, failure) -> IN_FLIGHT_OFFLINE_VOTE_REWARDS.remove(uuid, confirmed)); + try { + if (isTopVoterIgnore() != currentTopVoterIgnore) setTopVoterIgnore(currentTopVoterIgnore); + if (!getPendingOfflineVoteRewardBatch().isEmpty()) { + beforeRewardEffects.run(); + throw new IllegalStateException("Earlier offline-vote rewards require reconciliation for " + uuid); + } + ArrayList offlineVotes = getOfflineVotes(); + if (offlineVotes.isEmpty()) { + confirmed.complete(null); + return confirmed; + } getUserData().setStringList(OFFLINE_VOTES_REWARD_PENDING_KEY, new ArrayList<>(offlineVotes), false); beforeRewardEffects.run(); @@ -2310,384 +2388,419 @@ public void reconcileOfflineVoteRewardBatch(List expectedPending, List(), false); } - /** - * Processes a player vote. - * - * @param voteSite the vote site - * @param online whether the player is online - * @param bungee whether to use bungee - */ - public void playerVote(VoteSite voteSite, boolean online, boolean bungee) { - voteSite.giveRewards(this, online, bungee); - } - - /** - * Removes points from the user. - * MySQL calls on platform-owned threads are queued in storage-worker order. - * Their boolean result is a cached prediction, not proof of a committed debit; - * use the completion overload for a confirmed result. - * - * @param points the number of points to remove - * @return committed success for synchronous worker calls, otherwise a cached prediction - */ - public boolean removePoints(int points) { - SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.usesMysqlPointMutations()) return sharedPoints.remove(this, points, deferMysqlPointMutation()); - if (getPoints() >= points) { - setPoints(getPoints() - points); - return true; - } - return false; + /** + * Processes a player vote. + * + * @param voteSite the vote site + * @param online whether the player is online + * @param bungee whether to use bungee + */ + public void playerVote(VoteSite voteSite, boolean online, boolean bungee) { + voteSite.giveRewards(this, online, bungee); + } + + /** + * Removes points from the user. + * MySQL calls on platform-owned threads are queued in storage-worker order. + * Their boolean result is a cached prediction, not proof of a committed debit; + * use the completion overload for a confirmed result. + * + * @param points the number of points to remove + * @return committed success for synchronous worker calls, otherwise a cached prediction + */ + public boolean removePoints(int points) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.usesMysqlPointMutations()) return sharedPoints.remove(this, points, deferMysqlPointMutation()); + if (getPoints() >= points) { + setPoints(getPoints() - points); + return true; + } + return false; + } + + /** + * Removes points from the user asynchronously. + * Platform-owned MySQL callers always defer, even when async is false. + * A deferred call returns a cached prediction; use completion for confirmation. + * + * @param points the number of points to remove + * @param async whether to remove the points asynchronously + * @return committed success for synchronous worker calls, otherwise a cached prediction + */ + public boolean removePoints(int points, boolean async) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.usesMysqlPointMutations()) return sharedPoints.remove(this, points, async || deferMysqlPointMutation()); + if (getPoints() >= points) { + setPoints(getPoints() - points, async); + return true; + } + return false; + } + + /** Removes points without performing shared-database I/O on the caller thread. */ + public void removePoints(int points, Consumer completion) { + removePointsOutcome(points, outcome -> completion.accept(outcome == SharedMysqlPointMutator.MutationOutcome.CONFIRMED)); + } + + void removePointsOutcome(int points, Consumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.usesMysqlPointMutations()) { + completion.accept(removePoints(points) ? SharedMysqlPointMutator.MutationOutcome.CONFIRMED + : SharedMysqlPointMutator.MutationOutcome.REJECTED); + return; + } + Player player = getPlayer(); + String pointsColumn = getPointsPath(); + UUID pointUuid = getJavaUUID(); + var pointPlaceholders = plugin.getPlaceholders(); + pointPlaceholders.beginSharedPointDebit(pointUuid); + java.util.concurrent.atomic.AtomicBoolean fenceFinished = new java.util.concurrent.atomic.AtomicBoolean(); + java.util.function.BiConsumer finishFence = (known, trustworthy) -> { + if (fenceFinished.compareAndSet(false, true)) { + pointPlaceholders.finishSharedPointDebit(pointUuid, known, trustworthy); + } + }; + try { + String operationId = "remove-points/" + UUID.randomUUID(); + plugin.getTimer().execute(() -> { + SharedMysqlPointMutator.MutationOutcome outcome = SharedMysqlPointMutator.MutationOutcome.INDETERMINATE; + boolean trustworthy = false; + try { + SharedMysqlPointMutator.AddResult result = sharedPoints.removeCommittedFromColumn(this, + points, operationId, pointsColumn); + outcome = result.outcome(); + if (outcome == SharedMysqlPointMutator.MutationOutcome.CONFIRMED) { + sharedPoints.acknowledgePointAdditionNow(operationId); + } + var caches = plugin.getUserManager().getDataManager().getUserDataCache(); + var cache = caches.get(pointUuid); + if (cache != null) synchronized (cache) { + trustworthy = result.sourceRefreshed() + && outcome != SharedMysqlPointMutator.MutationOutcome.INDETERMINATE + && plugin.getPlaceholders() == pointPlaceholders && plugin.isEnabled() + && pointsColumn.equals(getPointsPath()) + && caches.get(pointUuid) == cache && cache.hasPublishedStorageSnapshot(); + } + } catch (RuntimeException failure) { + outcome = SharedMysqlPointMutator.MutationOutcome.INDETERMINATE; + plugin.debug(failure); + } finally { + finishFence.accept(outcome != SharedMysqlPointMutator.MutationOutcome.INDETERMINATE, trustworthy); + } + SharedMysqlPointMutator.MutationOutcome completed = outcome; + BukkitCompletionScheduler.run(plugin, player, () -> { + if (plugin.isEnabled() && plugin.getPlaceholders() == pointPlaceholders) completion.accept(completed); + }); + }); + } catch (RuntimeException rejected) { + finishFence.accept(true, false); + plugin.debug(rejected); + BukkitCompletionScheduler.run(plugin, player, + () -> { + if (plugin.isEnabled() && plugin.getPlaceholders() == pointPlaceholders) + completion.accept(SharedMysqlPointMutator.MutationOutcome.INDETERMINATE); + }); + } + } + + /** + * Atomically transfers points to another user when points are shared through + * MySQL, reporting completion on the Bukkit thread. + * + * @param target recipient + * @param points positive number of points + * @param completion whether the transfer completed + */ + public void transferPoints(VotingPluginUser target, int points, Consumer completion) { + transferPointsWithResult(target, points, result -> completion.accept(completesLegacyTransfer(result))); + } + + /** + * Legacy callers must not retry an indeterminate transfer: its approval hook + * has run and its durable journal row is retained for reconciliation. + */ + static boolean completesLegacyTransfer(PointTransferResult result) { + return result == PointTransferResult.SUCCESS || result == PointTransferResult.PENDING_CONFIRMATION; + } + + /** + * Transfers points and reports whether a failed transfer was caused by a + * conditional debit or by cancellation/availability. + */ + public void transferPointsWithResult(VotingPluginUser target, int points, Consumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.usesMysqlPointMutations()) { + sharedPoints.transferWithBukkitApproval(this, target, points, ignored -> { + PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points, false); + Bukkit.getPluginManager().callEvent(receiveEvent); + return receiveEvent.isCancelled() ? null : receiveEvent.getPoints(); + }, completion); + return; + } + boolean transferred = removePoints(points); + if (transferred) { + target.addPoints(points); + } + completion.accept(transferred ? PointTransferResult.SUCCESS : PointTransferResult.INSUFFICIENT_POINTS); + } + + /** + * Resets the last voted time for all vote sites. + */ + public void resetLastVoted() { + HashMap map = getLastVotes(); + for (Entry e : map.entrySet()) { + e.setValue(0l); + } + setLastVotes(map); + } + + /** + * Resets the last voted time for a specific vote site. + * + * @param site the vote site + */ + public void resetLastVoted(VoteSite site) { + HashMap map = getLastVotes(); + map.put(site, 0l); + setLastVotes(map); + } + + /** + * Resets the total votes for a specific top voter category. + * + * @param topVoter the top voter category + */ + public void resetTotals(TopVoter topVoter) { + setTotal(topVoter, 0); + } + + /** + * Sends vote effects to the user. + * + * @param online whether the user is online + */ + public void sendVoteEffects(boolean online) { + plugin.getRewardHandler().giveReward(this, plugin.getSpecialRewardsConfig().getData(), + plugin.getSpecialRewardsConfig().getAnySiteRewardsPath(), new RewardOptions().setOnline(online)); + } + + /** + * Sets the all-time total votes. + * + * @param allTimeTotal the all-time total votes + * @deprecated Use setTotal(TopVoter.AllTime, allTimeTotal) instead + */ + @Deprecated + public void setAllTimeTotal(int allTimeTotal) { + setTotal(TopVoter.AllTime, allTimeTotal); + } + + /** + * Sets the best day vote streak. + * + * @param streak the best day vote streak + */ + @Deprecated + public void setBestDayVoteStreak(int streak) { + getData().setInt("BestDayVoteStreak", streak); + } + + /** + * Sets the best month vote streak. + * + * @param streak the best month vote streak + */ + @Deprecated + public void setBestMonthVoteStreak(int streak) { + getData().setInt("BestMonthVoteStreak", streak); + } + + /** + * Sets the best week vote streak. + * + * @param streak the best week vote streak + */ + @Deprecated + public void setBestWeekVoteStreak(int streak) { + getData().setInt("BestWeekVoteStreak", streak); + } + + /** + * Sets the cooldown check. + * + * @param coolDownCheck whether the cooldown check is enabled + */ + public void setCoolDownCheck(boolean coolDownCheck) { + getData().setBoolean(getCoolDownCheckPath(), coolDownCheck); + } + + /** + * Sets the cooldown check for all vote sites. + * + * @param coolDownChecks the cooldown checks for all vote sites + */ + public void setCoolDownCheckSite(HashMap coolDownChecks) { + ArrayList data = new ArrayList<>(); + for (Entry entry : coolDownChecks.entrySet()) { + String str = entry.getKey() + "//" + entry.getValue().toString(); + data.add(str); + } + getUserData().setStringList(getCoolDownCheckSitePath(), data); + } + + /** + * Sets the cooldown check for a specific vote site. + * + * @param site the vote site + * @param value whether the cooldown check is enabled + */ + public void setCoolDownCheckSite(VoteSite site, boolean value) { + HashMap coolDownChecks = getCoolDownCheckSiteList(); + coolDownChecks.put(site.getKey(), Boolean.valueOf(value)); + setCoolDownCheckSite(coolDownChecks); + } + + /** + * Sets the daily total votes. + * + * @param total the daily total votes + * @deprecated Use setTotal(TopVoter.Daily, total) instead + */ + @Deprecated + public void setDailyTotal(int total) { + setTotal(TopVoter.Daily, total); + } + + /** + * Sets the day vote streak. + * + * @param streak the day vote streak + */ + @Deprecated + public void setDayVoteStreak(int streak) { + getData().setInt("DayVoteStreak", streak); + if (getBestDayVoteStreak() < streak) { + setBestDayVoteStreak(streak); + } + } + + /** + * Sets the last update time for the day vote streak. + * + * @param time the last update time for the day vote streak + */ + @Deprecated + public void setDayVoteStreakLastUpdate(long time) { + getData().setString("DayVoteStreakLastUpdate", "" + time); + } + + /** + * Sets whether the broadcast is disabled. + * + * @param value true to disable the broadcast, false otherwise + */ + public void setDisableBroadcast(boolean value) { + getUserData().setBoolean("DisableBroadcast", value); + } + + /** + * Sets the day when the user has gotten all sites. + * + * @param day the day when the user has gotten all sites + */ + public void setGottenAllSitesDay(int day) { + getData().setInt(plugin.getVotingPluginUserManager().getGottenAllSitesDayPath(), day); } /** - * Removes points from the user asynchronously. - * Platform-owned MySQL callers always defer, even when async is false. - * A deferred call returns a cached prediction; use completion for confirmation. + * Sets the day when the user has gotten almost all sites. * - * @param points the number of points to remove - * @param async whether to remove the points asynchronously - * @return committed success for synchronous worker calls, otherwise a cached prediction + * @param day the day when the user has gotten almost all sites */ - public boolean removePoints(int points, boolean async) { - SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.usesMysqlPointMutations()) return sharedPoints.remove(this, points, async || deferMysqlPointMutation()); - if (getPoints() >= points) { - setPoints(getPoints() - points, async); - return true; - } - return false; + public void setGottenAlmostAllSitesDay(int day) { + getData().setInt(plugin.getVotingPluginUserManager().getGottenAlmostAllSitesDayPath(), day); } - /** Removes points without performing shared-database I/O on the caller thread. */ - public void removePoints(int points, Consumer completion) { - removePointsOutcome(points, outcome -> completion.accept(outcome == SharedMysqlPointMutator.MutationOutcome.CONFIRMED)); + /** + * Sets the highest daily total votes. + * + * @param total the highest daily total votes + */ + public void setHighestDailyTotal(int total) { + getData().setInt("HighestDailyTotal", total); } - void removePointsOutcome(int points, Consumer completion) { - SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (!sharedPoints.usesMysqlPointMutations()) { - completion.accept(removePoints(points) ? SharedMysqlPointMutator.MutationOutcome.CONFIRMED - : SharedMysqlPointMutator.MutationOutcome.REJECTED); - return; - } - Player player = getPlayer(); - String pointsColumn = getPointsPath(); - try { - String operationId = "remove-points/" + UUID.randomUUID(); - plugin.getTimer().execute(() -> { - SharedMysqlPointMutator.AddResult result = sharedPoints.removeCommittedFromColumn(this, - points, operationId, pointsColumn); - if (result.outcome() == SharedMysqlPointMutator.MutationOutcome.CONFIRMED) { - sharedPoints.acknowledgePointAdditionNow(operationId); - } - BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(result.outcome())); - }); - } catch (RuntimeException rejected) { - plugin.debug(rejected); - BukkitCompletionScheduler.run(plugin, player, - () -> completion.accept(SharedMysqlPointMutator.MutationOutcome.INDETERMINATE)); - } + /** + * Sets the highest monthly total votes. + * + * @param total the highest monthly total votes + */ + public void setHighestMonthlyTotal(int total) { + getData().setInt("HighestMonthlyTotal", total); } /** - * Atomically transfers points to another user when points are shared through - * MySQL, reporting completion on the Bukkit thread. + * Sets the highest weekly total votes. * - * @param target recipient - * @param points positive number of points - * @param completion whether the transfer completed + * @param total the highest weekly total votes */ - public void transferPoints(VotingPluginUser target, int points, Consumer completion) { - transferPointsWithResult(target, points, result -> completion.accept(completesLegacyTransfer(result))); + public void setHighestWeeklyTotal(int total) { + getData().setInt("HighestWeeklyTotal", total); } /** - * Legacy callers must not retry an indeterminate transfer: its approval hook - * has run and its durable journal row is retained for reconciliation. + * Sets the total votes for the last month. + * + * @param total the total votes for the last month */ - static boolean completesLegacyTransfer(PointTransferResult result) { - return result == PointTransferResult.SUCCESS || result == PointTransferResult.PENDING_CONFIRMATION; + public void setLastMonthTotal(int total) { + getData().setInt("LastMonthTotal", total); } /** - * Transfers points and reports whether a failed transfer was caused by a - * conditional debit or by cancellation/availability. + * Sets the last votes for each vote site. + * + * @param lastVotes a map of vote sites and the last vote time */ - public void transferPointsWithResult(VotingPluginUser target, int points, Consumer completion) { - SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.usesMysqlPointMutations()) { - sharedPoints.transferWithBukkitApproval(this, target, points, ignored -> { - PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points, false); - Bukkit.getPluginManager().callEvent(receiveEvent); - return receiveEvent.isCancelled() ? null : receiveEvent.getPoints(); - }, completion); - return; + public void setLastVotes(HashMap lastVotes) { + ArrayList data = new ArrayList<>(); + for (Entry entry : lastVotes.entrySet()) { + String str = entry.getKey().getKey() + "//" + entry.getValue().longValue(); + data.add(str); } - boolean transferred = removePoints(points); - if (transferred) { - target.addPoints(points); + getUserData().setStringList("LastVotes", data); + } + + /** + * Sets the total votes for the month. + * + * @param total the total votes for the month + * @deprecated Use setTotal(TopVoter.Monthly, total) instead + */ + @Deprecated + public void setMonthTotal(int total) { + setTotal(TopVoter.Monthly, total); + } + + /** + * Sets the month vote streak. + * + * @param streak the month vote streak + */ + @Deprecated + public void setMonthVoteStreak(int streak) { + getData().setInt("MonthVoteStreak", streak); + if (getBestMonthVoteStreak() < streak) { + setBestMonthVoteStreak(streak); } - completion.accept(transferred ? PointTransferResult.SUCCESS : PointTransferResult.INSUFFICIENT_POINTS); } - - /** - * Resets the last voted time for all vote sites. - */ - public void resetLastVoted() { - HashMap map = getLastVotes(); - for (Entry e : map.entrySet()) { - e.setValue(0l); - } - setLastVotes(map); - } - - /** - * Resets the last voted time for a specific vote site. - * - * @param site the vote site - */ - public void resetLastVoted(VoteSite site) { - HashMap map = getLastVotes(); - map.put(site, 0l); - setLastVotes(map); - } - - /** - * Resets the total votes for a specific top voter category. - * - * @param topVoter the top voter category - */ - public void resetTotals(TopVoter topVoter) { - setTotal(topVoter, 0); - } - - /** - * Sends vote effects to the user. - * - * @param online whether the user is online - */ - public void sendVoteEffects(boolean online) { - plugin.getRewardHandler().giveReward(this, plugin.getSpecialRewardsConfig().getData(), - plugin.getSpecialRewardsConfig().getAnySiteRewardsPath(), new RewardOptions().setOnline(online)); - } - - /** - * Sets the all-time total votes. - * - * @param allTimeTotal the all-time total votes - * @deprecated Use setTotal(TopVoter.AllTime, allTimeTotal) instead - */ - @Deprecated - public void setAllTimeTotal(int allTimeTotal) { - setTotal(TopVoter.AllTime, allTimeTotal); - } - - /** - * Sets the best day vote streak. - * - * @param streak the best day vote streak - */ - @Deprecated - public void setBestDayVoteStreak(int streak) { - getData().setInt("BestDayVoteStreak", streak); - } - - /** - * Sets the best month vote streak. - * - * @param streak the best month vote streak - */ - @Deprecated - public void setBestMonthVoteStreak(int streak) { - getData().setInt("BestMonthVoteStreak", streak); - } - - /** - * Sets the best week vote streak. - * - * @param streak the best week vote streak - */ - @Deprecated - public void setBestWeekVoteStreak(int streak) { - getData().setInt("BestWeekVoteStreak", streak); - } - - /** - * Sets the cooldown check. - * - * @param coolDownCheck whether the cooldown check is enabled - */ - public void setCoolDownCheck(boolean coolDownCheck) { - getData().setBoolean(getCoolDownCheckPath(), coolDownCheck); - } - - /** - * Sets the cooldown check for all vote sites. - * - * @param coolDownChecks the cooldown checks for all vote sites - */ - public void setCoolDownCheckSite(HashMap coolDownChecks) { - ArrayList data = new ArrayList<>(); - for (Entry entry : coolDownChecks.entrySet()) { - String str = entry.getKey() + "//" + entry.getValue().toString(); - data.add(str); - } - getUserData().setStringList(getCoolDownCheckSitePath(), data); - } - - /** - * Sets the cooldown check for a specific vote site. - * - * @param site the vote site - * @param value whether the cooldown check is enabled - */ - public void setCoolDownCheckSite(VoteSite site, boolean value) { - HashMap coolDownChecks = getCoolDownCheckSiteList(); - coolDownChecks.put(site.getKey(), Boolean.valueOf(value)); - setCoolDownCheckSite(coolDownChecks); - } - - /** - * Sets the daily total votes. - * - * @param total the daily total votes - * @deprecated Use setTotal(TopVoter.Daily, total) instead - */ - @Deprecated - public void setDailyTotal(int total) { - setTotal(TopVoter.Daily, total); - } - - /** - * Sets the day vote streak. - * - * @param streak the day vote streak - */ - @Deprecated - public void setDayVoteStreak(int streak) { - getData().setInt("DayVoteStreak", streak); - if (getBestDayVoteStreak() < streak) { - setBestDayVoteStreak(streak); - } - } - - /** - * Sets the last update time for the day vote streak. - * - * @param time the last update time for the day vote streak - */ - @Deprecated - public void setDayVoteStreakLastUpdate(long time) { - getData().setString("DayVoteStreakLastUpdate", "" + time); - } - - /** - * Sets whether the broadcast is disabled. - * - * @param value true to disable the broadcast, false otherwise - */ - public void setDisableBroadcast(boolean value) { - getUserData().setBoolean("DisableBroadcast", value); - } - - /** - * Sets the day when the user has gotten all sites. - * - * @param day the day when the user has gotten all sites - */ - public void setGottenAllSitesDay(int day) { - getData().setInt(plugin.getVotingPluginUserManager().getGottenAllSitesDayPath(), day); - } - - /** - * Sets the day when the user has gotten almost all sites. - * - * @param day the day when the user has gotten almost all sites - */ - public void setGottenAlmostAllSitesDay(int day) { - getData().setInt(plugin.getVotingPluginUserManager().getGottenAlmostAllSitesDayPath(), day); - } - - /** - * Sets the highest daily total votes. - * - * @param total the highest daily total votes - */ - public void setHighestDailyTotal(int total) { - getData().setInt("HighestDailyTotal", total); - } - - /** - * Sets the highest monthly total votes. - * - * @param total the highest monthly total votes - */ - public void setHighestMonthlyTotal(int total) { - getData().setInt("HighestMonthlyTotal", total); - } - - /** - * Sets the highest weekly total votes. - * - * @param total the highest weekly total votes - */ - public void setHighestWeeklyTotal(int total) { - getData().setInt("HighestWeeklyTotal", total); - } - - /** - * Sets the total votes for the last month. - * - * @param total the total votes for the last month - */ - public void setLastMonthTotal(int total) { - getData().setInt("LastMonthTotal", total); - } - - /** - * Sets the last votes for each vote site. - * - * @param lastVotes a map of vote sites and the last vote time - */ - public void setLastVotes(HashMap lastVotes) { - ArrayList data = new ArrayList<>(); - for (Entry entry : lastVotes.entrySet()) { - String str = entry.getKey().getKey() + "//" + entry.getValue().longValue(); - data.add(str); - } - getUserData().setStringList("LastVotes", data); - } - - /** - * Sets the total votes for the month. - * - * @param total the total votes for the month - * @deprecated Use setTotal(TopVoter.Monthly, total) instead - */ - @Deprecated - public void setMonthTotal(int total) { - setTotal(TopVoter.Monthly, total); - } - - /** - * Sets the month vote streak. - * - * @param streak the month vote streak - */ - @Deprecated - public void setMonthVoteStreak(int streak) { - getData().setInt("MonthVoteStreak", streak); - if (getBestMonthVoteStreak() < streak) { - setBestMonthVoteStreak(streak); - } - } - - /** - * Sets the list of offline votes. - * - * @param offlineVotes the list of offline votes - */ + + /** + * Sets the list of offline votes. + * + * @param offlineVotes the list of offline votes + */ public void setOfflineVotes(ArrayList offlineVotes) { ArrayList pending = getPendingOfflineVoteRewardBatch(); if ((!pending.isEmpty() && (offlineVotes.size() < pending.size() @@ -2697,8 +2810,8 @@ public void setOfflineVotes(ArrayList offlineVotes) { + getUUID() + "; use /av OfflineVoteRecovery before replacing the queue"); } getUserData().setStringList("OfflineVotes", offlineVotes); - } - + } + private boolean deferMysqlPointMutation() { return plugin.getUserManager().getDataManager().mustDeferSharedStorageAccess(); } @@ -2717,14 +2830,14 @@ public void setPoints(int value) { } else { getUserData().setInt(getPointsPath(), value, false); } - } - - /** - * Sets the points of the user asynchronously. - * - * @param value the number of points - * @param async whether to set the points asynchronously - */ + } + + /** + * Sets the points of the user asynchronously. + * + * @param value the number of points + * @param async whether to set the points asynchronously + */ public void setPoints(int value, boolean async) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (sharedPoints.usesMysqlPointMutations()) { @@ -2734,94 +2847,94 @@ public void setPoints(int value, boolean async) { } else { getUserData().setInt(getPointsPath(), value, false, async); } - } - - /** - * Sets the current time for the specified vote site. - * - * @param voteSite the vote site - */ - public void setTime(VoteSite voteSite) { - setTime(voteSite, LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); - } - - /** - * Sets the specified time for the specified vote site. - * - * @param voteSite the vote site - * @param time the time to set - */ - public void setTime(VoteSite voteSite, Long time) { - HashMap lastVotes = getLastVotes(); - if (lastVotes != null && lastVotes.containsKey(voteSite)) { - if (lastVotes.get(voteSite).longValue() == time.longValue()) { - plugin.debug("Not setting last vote time for " + voteSite.getKey() + ", already set to " + time); - return; - } - } - lastVotes.put(voteSite, time); - setLastVotes(lastVotes); - } - - /** - * Sets whether the user is ignored for top voter. - * - * @param topVoterIgnore true to ignore the user for top voter, false otherwise - */ - public void setTopVoterIgnore(boolean topVoterIgnore) { - getUserData().setString("TopVoterIgnore", "" + topVoterIgnore); - } - - /** - * Sets the total votes for the specified top voter category. - * - * @param top the top voter category - * @param value the total votes to set - */ - public void setTotal(TopVoter top, int value) { - switch (top) { - case AllTime: - getUserData().setInt("AllTimeTotal", value); - break; - case Daily: - getUserData().setInt("DailyTotal", value); - break; - case Monthly: - if (plugin.getConfigFile().isLimitMonthlyVotes()) { - LocalDateTime time = plugin.getTimeChecker().getTime(); - int days = time.getDayOfMonth(); - if (value >= days * plugin.getVoteSiteManager().getVoteSitesEnabled().size()) { - value = days * plugin.getVoteSiteManager().getVoteSitesEnabled().size(); - } - } - getData().setInt("MonthTotal", value); - if (plugin.getConfigFile().isStoreMonthTotalsWithDate()) { - getData().setInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath(), value); - } - break; - case Weekly: - getUserData().setInt("WeeklyTotal", value); - break; - default: - break; - } - } - - /** - * Sets the number of votes for the vote party. - * - * @param value the number of votes to set - */ - public void setVotePartyVotes(int value) { - getUserData().setInt("VotePartyVotes", value); - } - - /** - * Sets the vote shop identifier limit. - * - * @param identifier the identifier for the vote shop - * @param value the limit to set - */ + } + + /** + * Sets the current time for the specified vote site. + * + * @param voteSite the vote site + */ + public void setTime(VoteSite voteSite) { + setTime(voteSite, LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + } + + /** + * Sets the specified time for the specified vote site. + * + * @param voteSite the vote site + * @param time the time to set + */ + public void setTime(VoteSite voteSite, Long time) { + HashMap lastVotes = getLastVotes(); + if (lastVotes != null && lastVotes.containsKey(voteSite)) { + if (lastVotes.get(voteSite).longValue() == time.longValue()) { + plugin.debug("Not setting last vote time for " + voteSite.getKey() + ", already set to " + time); + return; + } + } + lastVotes.put(voteSite, time); + setLastVotes(lastVotes); + } + + /** + * Sets whether the user is ignored for top voter. + * + * @param topVoterIgnore true to ignore the user for top voter, false otherwise + */ + public void setTopVoterIgnore(boolean topVoterIgnore) { + getUserData().setString("TopVoterIgnore", "" + topVoterIgnore); + } + + /** + * Sets the total votes for the specified top voter category. + * + * @param top the top voter category + * @param value the total votes to set + */ + public void setTotal(TopVoter top, int value) { + switch (top) { + case AllTime: + getUserData().setInt("AllTimeTotal", value); + break; + case Daily: + getUserData().setInt("DailyTotal", value); + break; + case Monthly: + if (plugin.getConfigFile().isLimitMonthlyVotes()) { + LocalDateTime time = plugin.getTimeChecker().getTime(); + int days = time.getDayOfMonth(); + if (value >= days * plugin.getVoteSiteManager().getVoteSitesEnabled().size()) { + value = days * plugin.getVoteSiteManager().getVoteSitesEnabled().size(); + } + } + getData().setInt("MonthTotal", value); + if (plugin.getConfigFile().isStoreMonthTotalsWithDate()) { + getData().setInt(plugin.getVotingPluginUserManager().getMonthTotalsWithDatePath(), value); + } + break; + case Weekly: + getUserData().setInt("WeeklyTotal", value); + break; + default: + break; + } + } + + /** + * Sets the number of votes for the vote party. + * + * @param value the number of votes to set + */ + public void setVotePartyVotes(int value) { + getUserData().setInt("VotePartyVotes", value); + } + + /** + * Sets the vote shop identifier limit. + * + * @param identifier the identifier for the vote shop + * @param value the limit to set + */ public void setVoteShopIdentifierLimit(String identifier, int value) { String path = "VoteShopLimit" + identifier; // VoteShopLimit columns are not server-suffixed, even when points are. On @@ -2837,300 +2950,300 @@ private boolean usesSharedMysqlPoints() { return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) && !plugin.getBungeeSettings().isPerServerPoints(); } - - /** - * Sets the weekly total votes. - * - * @param total the weekly total votes - * @deprecated Use setTotal(TopVoter.Weekly, total) instead - */ - @Deprecated - public void setWeeklyTotal(int total) { - setTotal(TopVoter.Weekly, total); - } - - /** - * Sets the week vote streak. - * - * @param streak the week vote streak - */ - @Deprecated - public void setWeekVoteStreak(int streak) { - getData().setInt("WeekVoteStreak", streak); - if (getBestWeekVoteStreak() < streak) { - setBestWeekVoteStreak(streak); - } - } - - /** - * Checks if the user should be reminded. - * - * @return true if the user should be reminded, false otherwise - */ - public boolean shouldBeReminded() { - Player player = getPlayer(); - if (player != null) { - if (player.hasPermission("VotingPlugin.NoRemind")) { - return false; - } - } - return true; - } - - /** - * Gets the last vote date for the specified vote site. - * - * @param voteSite the vote site - * @return the last vote date as a string - * @deprecated Use getTime(VoteSite) instead - */ - @Deprecated - public String voteCommandLastDate(VoteSite voteSite) { - long time = getTime(voteSite); - if (time > 0) { - Date date = new Date(time); - String timeString = new SimpleDateFormat(plugin.getConfigFile().getFormatTimeFormat()).format(date); - if (MessageAPI.containsIgnorecase(timeString, "YamlConfiguration")) { - plugin.getLogger().warning("Detected issue parsing time, check time format"); - } - return timeString; - } - return ""; - } - - /** - * Gets the duration since the last vote for the specified vote site. - * - * @param voteSite the vote site - * @return the duration since the last vote as a string - */ - public String voteCommandLastDuration(VoteSite voteSite) { - long time = getTime(voteSite); - if (time > 0) { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault()); - - Duration dur = Duration.between(lastVote, now); - - long diffSecond = dur.getSeconds(); - int diffDays = (int) (diffSecond / 60 / 60 / 24); - int diffHours = (int) (diffSecond / 60 / 60 - diffDays * 24); - int diffMinutes = (int) (diffSecond / 60 - diffHours * 60 - diffDays * 24 * 60); - int diffSeconds = (int) (diffSecond - diffMinutes * 60 - diffHours * 60 * 60 - diffDays * 24 * 60 * 60); - - String info = ""; - if (diffDays == 1) { - info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", - plugin.getConfigFile().getFormatTimeFormatsDay()), "amount", "" + diffDays); - info += " "; - } else if (diffDays > 1) { - info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", - plugin.getConfigFile().getFormatTimeFormatsDays()), "amount", "" + diffDays); - info += " "; - } - - if (diffHours == 1) { - info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", - plugin.getConfigFile().getFormatTimeFormatsHour()), "amount", "" + diffHours); - info += " "; - } else if (diffHours > 1) { - info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", - plugin.getConfigFile().getFormatTimeFormatsHours()), "amount", "" + diffHours); - info += " "; - } - - if (diffMinutes == 1) { - info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", - plugin.getConfigFile().getFormatTimeFormatsMinute()), "amount", "" + diffMinutes); - info += " "; - } else if (diffMinutes > 1) { - info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", - plugin.getConfigFile().getFormatTimeFormatsMinutes()), "amount", "" + diffMinutes); - info += " "; - } - - if (plugin.getConfigFile().isFormatCommandsVoteLastIncludeSeconds()) { - if (diffSeconds == 1) { - info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", - plugin.getConfigFile().getFormatTimeFormatsSecond()), "amount", "" + diffSeconds); - } else { - info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", - plugin.getConfigFile().getFormatTimeFormatsSeconds()), "amount", "" + diffSeconds); - } - } - - info = PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatCommandsVoteLastLastVoted(), - "times", info); - - return info; - } - return plugin.getConfigFile().getFormatCommandsVoteLastNeverVoted(); - } - - /** - * Gets the last vote date and duration for the specified vote site for the GUI. - * - * @param voteSite the vote site - * @return the last vote date and duration as a string for the GUI - */ - public String voteCommandLastGUILine(VoteSite voteSite) { - String timeString = voteCommandLastDate(voteSite); - String timeSince = voteCommandLastDuration(voteSite); - - HashMap placeholders = new HashMap<>(); - placeholders.put("time", timeString); + + /** + * Sets the weekly total votes. + * + * @param total the weekly total votes + * @deprecated Use setTotal(TopVoter.Weekly, total) instead + */ + @Deprecated + public void setWeeklyTotal(int total) { + setTotal(TopVoter.Weekly, total); + } + + /** + * Sets the week vote streak. + * + * @param streak the week vote streak + */ + @Deprecated + public void setWeekVoteStreak(int streak) { + getData().setInt("WeekVoteStreak", streak); + if (getBestWeekVoteStreak() < streak) { + setBestWeekVoteStreak(streak); + } + } + + /** + * Checks if the user should be reminded. + * + * @return true if the user should be reminded, false otherwise + */ + public boolean shouldBeReminded() { + Player player = getPlayer(); + if (player != null) { + if (player.hasPermission("VotingPlugin.NoRemind")) { + return false; + } + } + return true; + } + + /** + * Gets the last vote date for the specified vote site. + * + * @param voteSite the vote site + * @return the last vote date as a string + * @deprecated Use getTime(VoteSite) instead + */ + @Deprecated + public String voteCommandLastDate(VoteSite voteSite) { + long time = getTime(voteSite); + if (time > 0) { + Date date = new Date(time); + String timeString = new SimpleDateFormat(plugin.getConfigFile().getFormatTimeFormat()).format(date); + if (MessageAPI.containsIgnorecase(timeString, "YamlConfiguration")) { + plugin.getLogger().warning("Detected issue parsing time, check time format"); + } + return timeString; + } + return ""; + } + + /** + * Gets the duration since the last vote for the specified vote site. + * + * @param voteSite the vote site + * @return the duration since the last vote as a string + */ + public String voteCommandLastDuration(VoteSite voteSite) { + long time = getTime(voteSite); + if (time > 0) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault()); + + Duration dur = Duration.between(lastVote, now); + + long diffSecond = dur.getSeconds(); + int diffDays = (int) (diffSecond / 60 / 60 / 24); + int diffHours = (int) (diffSecond / 60 / 60 - diffDays * 24); + int diffMinutes = (int) (diffSecond / 60 - diffHours * 60 - diffDays * 24 * 60); + int diffSeconds = (int) (diffSecond - diffMinutes * 60 - diffHours * 60 * 60 - diffDays * 24 * 60 * 60); + + String info = ""; + if (diffDays == 1) { + info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( + plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", + plugin.getConfigFile().getFormatTimeFormatsDay()), "amount", "" + diffDays); + info += " "; + } else if (diffDays > 1) { + info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( + plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", + plugin.getConfigFile().getFormatTimeFormatsDays()), "amount", "" + diffDays); + info += " "; + } + + if (diffHours == 1) { + info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( + plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", + plugin.getConfigFile().getFormatTimeFormatsHour()), "amount", "" + diffHours); + info += " "; + } else if (diffHours > 1) { + info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( + plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", + plugin.getConfigFile().getFormatTimeFormatsHours()), "amount", "" + diffHours); + info += " "; + } + + if (diffMinutes == 1) { + info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( + plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", + plugin.getConfigFile().getFormatTimeFormatsMinute()), "amount", "" + diffMinutes); + info += " "; + } else if (diffMinutes > 1) { + info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( + plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", + plugin.getConfigFile().getFormatTimeFormatsMinutes()), "amount", "" + diffMinutes); + info += " "; + } + + if (plugin.getConfigFile().isFormatCommandsVoteLastIncludeSeconds()) { + if (diffSeconds == 1) { + info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( + plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", + plugin.getConfigFile().getFormatTimeFormatsSecond()), "amount", "" + diffSeconds); + } else { + info += PlaceholderUtils.replacePlaceHolder(PlaceholderUtils.replacePlaceHolder( + plugin.getConfigFile().getFormatCommandsVoteLastTimeFormat(), "TimeType", + plugin.getConfigFile().getFormatTimeFormatsSeconds()), "amount", "" + diffSeconds); + } + } + + info = PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatCommandsVoteLastLastVoted(), + "times", info); + + return info; + } + return plugin.getConfigFile().getFormatCommandsVoteLastNeverVoted(); + } + + /** + * Gets the last vote date and duration for the specified vote site for the GUI. + * + * @param voteSite the vote site + * @return the last vote date and duration as a string for the GUI + */ + public String voteCommandLastGUILine(VoteSite voteSite) { + String timeString = voteCommandLastDate(voteSite); + String timeSince = voteCommandLastDuration(voteSite); + + HashMap placeholders = new HashMap<>(); + placeholders.put("time", timeString); placeholders.put("SiteName", voteSite.getDisplayName()); - placeholders.put("timesince", timeSince); - - return PlaceholderUtils.replacePlaceHolder(plugin.getGui().getChestVoteLastLine(), placeholders); - } - - /** - * Gets the last vote date and duration for the specified vote site. - * - * @param voteSite the vote site - * @return the last vote date and duration as a string - */ - public String voteCommandLastLine(VoteSite voteSite) { - String timeString = voteCommandLastDate(voteSite); - String timeSince = voteCommandLastDuration(voteSite); - - HashMap placeholders = new HashMap<>(); - placeholders.put("time", timeString); + placeholders.put("timesince", timeSince); + + return PlaceholderUtils.replacePlaceHolder(plugin.getGui().getChestVoteLastLine(), placeholders); + } + + /** + * Gets the last vote date and duration for the specified vote site. + * + * @param voteSite the vote site + * @return the last vote date and duration as a string + */ + public String voteCommandLastLine(VoteSite voteSite) { + String timeString = voteCommandLastDate(voteSite); + String timeSince = voteCommandLastDuration(voteSite); + + HashMap placeholders = new HashMap<>(); + placeholders.put("time", timeString); placeholders.put("SiteName", voteSite.getDisplayName()); - placeholders.put("timesince", timeSince); - - return PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatCommandsVoteLastLine(), - placeholders); - } - - /** - * Gets the next available vote time for the specified vote site. - * - * @param voteSite the vote site - * @return the next available vote time as a string - */ - public String voteCommandNextInfo(VoteSite voteSite) { - return voteCommandNextInfo(voteSite, getTime(voteSite)); - } - - /** - * Gets the next available vote time for the specified vote site. - * - * @param voteSite the vote site - * @param time the current time - * @return the next available vote time as a string - */ - public String voteCommandNextInfo(VoteSite voteSite, long time) { - String info = new String(); - - long nextTime = voteNextDurationTime(voteSite, time); - if (nextTime == 0) { - info = plugin.getConfigFile().getFormatCommandsVoteNextInfoCanVote(); - } else { - int diffHours = (int) (nextTime / (60 * 60)); - long diffMinutes = nextTime / 60 - diffHours * 60; - - if (diffHours < 0) { - diffHours = diffHours * -1; - } - if (diffHours >= 24) { - diffHours = diffHours - 24; - } - if (diffMinutes < 0) { - diffMinutes = diffMinutes * -1; - } - - String timeMsg = plugin.getConfigFile().getFormatCommandsVoteNextInfoVoteDelayDaily(); - timeMsg = MessageAPI.replaceIgnoreCase(timeMsg, "%hours%", Integer.toString(diffHours)); - timeMsg = MessageAPI.replaceIgnoreCase(timeMsg, "%minutes%", Long.toString(diffMinutes)); - info = timeMsg; - } - - return info; - } - - /** - * Gets the next available vote duration time for the specified vote site. - * - * @param voteSite the vote site - * @return the next available vote duration time in seconds - */ - public long voteNextDurationTime(VoteSite voteSite) { - return voteNextDurationTime(voteSite, getTime(voteSite)); - } - - /** - * Gets the next available vote duration time for the specified vote site. - * - * @param voteSite the vote site - * @param time the last vote time (epoch millis) - * @return the next available vote duration time in seconds - */ - public long voteNextDurationTime(VoteSite voteSite, long time) { - LocalDateTime now = plugin.getTimeChecker().getTime(); - - LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault()) - .plusHours(plugin.getOptions().getTimeHourOffSet()); - - if (!voteSite.isVoteDelayDaily()) { - ParsedDuration voteDelay = voteSite.getVoteDelay(); - - if (time == 0 || voteDelay == null || voteDelay.isEmpty()) { - return 0; - } - - // Ignore months, use fixed duration only - LocalDateTime nextVote = lastVote.plus(Duration.ofMillis(voteDelay.getMillis())); - - if (now.isAfter(nextVote)) { - return 0; - } - - return Duration.between(now, nextVote).getSeconds(); - } - - // Daily reset logic (unchanged) - LocalDateTime resetTime = lastVote.withHour(voteSite.getVoteDelayDailyHour()).withMinute(0).withSecond(0); - - LocalDateTime resetTimeTomorrow = resetTime.plusHours(24); - - if (lastVote.isBefore(resetTime)) { - if (now.isBefore(resetTime)) { - return Duration.between(now, resetTime).getSeconds(); - } - } else { - if (now.isBefore(resetTimeTomorrow)) { - return Duration.between(now, resetTimeTomorrow).getSeconds(); - } - } - - return 0; - } - - /** - * Checks if the vote streak was updated today. - * - * @param time the current time - * @return true if the vote streak was updated today, false otherwise - */ - @Deprecated - public boolean voteStreakUpdatedToday(LocalDateTime time) { - return MiscUtils.getInstance().getTime(getDayVoteStreakLastUpdate()).getDayOfYear() == time.getDayOfYear(); - } - - public String getVoteStreakState(String columnName) { - return getData().getString(columnName); - } - + placeholders.put("timesince", timeSince); + + return PlaceholderUtils.replacePlaceHolder(plugin.getConfigFile().getFormatCommandsVoteLastLine(), + placeholders); + } + + /** + * Gets the next available vote time for the specified vote site. + * + * @param voteSite the vote site + * @return the next available vote time as a string + */ + public String voteCommandNextInfo(VoteSite voteSite) { + return voteCommandNextInfo(voteSite, getTime(voteSite)); + } + + /** + * Gets the next available vote time for the specified vote site. + * + * @param voteSite the vote site + * @param time the current time + * @return the next available vote time as a string + */ + public String voteCommandNextInfo(VoteSite voteSite, long time) { + String info = new String(); + + long nextTime = voteNextDurationTime(voteSite, time); + if (nextTime == 0) { + info = plugin.getConfigFile().getFormatCommandsVoteNextInfoCanVote(); + } else { + int diffHours = (int) (nextTime / (60 * 60)); + long diffMinutes = nextTime / 60 - diffHours * 60; + + if (diffHours < 0) { + diffHours = diffHours * -1; + } + if (diffHours >= 24) { + diffHours = diffHours - 24; + } + if (diffMinutes < 0) { + diffMinutes = diffMinutes * -1; + } + + String timeMsg = plugin.getConfigFile().getFormatCommandsVoteNextInfoVoteDelayDaily(); + timeMsg = MessageAPI.replaceIgnoreCase(timeMsg, "%hours%", Integer.toString(diffHours)); + timeMsg = MessageAPI.replaceIgnoreCase(timeMsg, "%minutes%", Long.toString(diffMinutes)); + info = timeMsg; + } + + return info; + } + + /** + * Gets the next available vote duration time for the specified vote site. + * + * @param voteSite the vote site + * @return the next available vote duration time in seconds + */ + public long voteNextDurationTime(VoteSite voteSite) { + return voteNextDurationTime(voteSite, getTime(voteSite)); + } + + /** + * Gets the next available vote duration time for the specified vote site. + * + * @param voteSite the vote site + * @param time the last vote time (epoch millis) + * @return the next available vote duration time in seconds + */ + public long voteNextDurationTime(VoteSite voteSite, long time) { + LocalDateTime now = plugin.getTimeChecker().getTime(); + + LocalDateTime lastVote = LocalDateTime.ofInstant(Instant.ofEpochMilli(time), ZoneId.systemDefault()) + .plusHours(plugin.getOptions().getTimeHourOffSet()); + + if (!voteSite.isVoteDelayDaily()) { + ParsedDuration voteDelay = voteSite.getVoteDelay(); + + if (time == 0 || voteDelay == null || voteDelay.isEmpty()) { + return 0; + } + + // Ignore months, use fixed duration only + LocalDateTime nextVote = lastVote.plus(Duration.ofMillis(voteDelay.getMillis())); + + if (now.isAfter(nextVote)) { + return 0; + } + + return Duration.between(now, nextVote).getSeconds(); + } + + // Daily reset logic (unchanged) + LocalDateTime resetTime = lastVote.withHour(voteSite.getVoteDelayDailyHour()).withMinute(0).withSecond(0); + + LocalDateTime resetTimeTomorrow = resetTime.plusHours(24); + + if (lastVote.isBefore(resetTime)) { + if (now.isBefore(resetTime)) { + return Duration.between(now, resetTime).getSeconds(); + } + } else { + if (now.isBefore(resetTimeTomorrow)) { + return Duration.between(now, resetTimeTomorrow).getSeconds(); + } + } + + return 0; + } + + /** + * Checks if the vote streak was updated today. + * + * @param time the current time + * @return true if the vote streak was updated today, false otherwise + */ + @Deprecated + public boolean voteStreakUpdatedToday(LocalDateTime time) { + return MiscUtils.getInstance().getTime(getDayVoteStreakLastUpdate()).getDayOfYear() == time.getDayOfYear(); + } + + public String getVoteStreakState(String columnName) { + return getData().getString(columnName); + } + public void setVoteStreakState(String columnName, String value) { getData().setString(columnName, value); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java index 7aeacd3124..8aa7e36072 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -29,6 +29,14 @@ * arbitrary, non-idempotent side effects.

*/ final class SharedMysqlPurchaseJournal { + /** Positive proof that this invocation did not attempt a points write. */ + static final class PreparationFailure extends SQLException { + private static final long serialVersionUID = 1L; + PreparationFailure(SQLException cause) { + super(cause.getMessage(), cause.getSQLState(), cause.getErrorCode(), cause); + } + } + private static final String PENDING = "PENDING"; private static final String HOOK_STARTED = "HOOK_STARTED"; private static final String COMPENSATING = "COMPENSATING"; @@ -119,60 +127,68 @@ private static void expungeInitialized() { /** Atomically records a pending purchase and conditionally charges it. */ boolean reserve(String purchaseId, String uuid, String pointsColumn, String limitColumn, int cost, int limit, String limitGeneration, long limitGenerationExpiresAt, long now) throws SQLException { - String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid") - + ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", " - + qi("limit_value") + ", " + qi("limit_generation") + ", " - + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state") - + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; - String points = qi(pointsColumn); - StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ") - .append(points).append(" = COALESCE(").append(points).append(", 0) - ?"); - if (limitColumn != null) { - debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn)) - .append(", 0) + 1"); - } - debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND COALESCE(").append(points) - .append(", 0) >= ?"); - if (limitColumn != null) { - debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?"); - } - try (Connection connection = connection()) { - connection.setAutoCommit(false); - try { - Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn); - try (PreparedStatement insertStatement = connection.prepareStatement(insert); - PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) { - insertStatement.setString(1, purchaseId); - insertStatement.setString(2, uuid); - insertStatement.setString(3, pointsColumn); - insertStatement.setString(4, limitColumn); - insertStatement.setInt(5, cost); - if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER); - else insertStatement.setInt(6, limit); - if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR); - else insertStatement.setString(7, limitGeneration); - if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT); - else insertStatement.setLong(8, limitGenerationExpiresAt); - if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT); - else insertStatement.setLong(9, limitEpoch.longValue()); - insertStatement.setString(10, PENDING); - insertStatement.setLong(11, now); - insertStatement.executeUpdate(); - - debitStatement.setInt(1, cost); - debitStatement.setString(2, uuid); - debitStatement.setInt(3, cost); - if (limitColumn != null) debitStatement.setInt(4, limit); - if (debitStatement.executeUpdate() != 1) { - rollback(connection); - return false; + boolean pointWriteAttempted = false; + try { + String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid") + + ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", " + + qi("limit_value") + ", " + qi("limit_generation") + ", " + + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state") + + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + String points = qi(pointsColumn); + StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ") + .append(points).append(" = COALESCE(").append(points).append(", 0) - ?"); + if (limitColumn != null) { + debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn)) + .append(", 0) + 1"); + } + debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND COALESCE(").append(points) + .append(", 0) >= ?"); + if (limitColumn != null) { + debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?"); + } + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try { + Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn); + try (PreparedStatement insertStatement = connection.prepareStatement(insert); + PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) { + insertStatement.setString(1, purchaseId); + insertStatement.setString(2, uuid); + insertStatement.setString(3, pointsColumn); + insertStatement.setString(4, limitColumn); + insertStatement.setInt(5, cost); + if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER); + else insertStatement.setInt(6, limit); + if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR); + else insertStatement.setString(7, limitGeneration); + if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT); + else insertStatement.setLong(8, limitGenerationExpiresAt); + if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT); + else insertStatement.setLong(9, limitEpoch.longValue()); + insertStatement.setString(10, PENDING); + insertStatement.setLong(11, now); + insertStatement.executeUpdate(); + + debitStatement.setInt(1, cost); + debitStatement.setString(2, uuid); + debitStatement.setInt(3, cost); + if (limitColumn != null) debitStatement.setInt(4, limit); + pointWriteAttempted = true; + if (debitStatement.executeUpdate() != 1) { + rollback(connection); + return false; + } + return commitAndConfirm(connection, purchaseId, PENDING); } - return commitAndConfirm(connection, purchaseId, PENDING); + } catch (SQLException failure) { + rollback(connection); + throw failure; } - } catch (SQLException failure) { - rollback(connection); - throw failure; } + } catch (SQLException failure) { + if (failure instanceof PreparationFailure) throw failure; + if (!pointWriteAttempted) throw new PreparationFailure(failure); + throw failure; } } @@ -370,65 +386,73 @@ private boolean setTerminal(String purchaseId, String terminalState, long now, S private RefundedPurchase setTerminalDetails(String purchaseId, String terminalState, long now, String... refundableStates) throws SQLException { - boolean refund = REFUNDED.equals(terminalState); - String refundedUuid = null; - String refundedPointsColumn = null; - String refundedLimitColumn = null; - String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column") - + ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", " - + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE " - + qi("purchase_id") + " = ? FOR UPDATE"; - try (Connection connection = connection()) { - connection.setAutoCommit(false); - try (PreparedStatement selectStatement = connection.prepareStatement(select)) { - selectStatement.setString(1, purchaseId); - try (ResultSet result = selectStatement.executeQuery()) { - if (!result.next()) { - rollback(connection); - return null; - } - String state = result.getString(1); - if (COMPLETED.equals(state) || REFUNDED.equals(state)) { - rollback(connection); - return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null; - } - if (refund && !isRefundableState(state, refundableStates)) { - rollback(connection); - return null; + boolean pointWriteAttempted = false; + try { + boolean refund = REFUNDED.equals(terminalState); + String refundedUuid = null; + String refundedPointsColumn = null; + String refundedLimitColumn = null; + String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column") + + ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", " + + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE " + + qi("purchase_id") + " = ? FOR UPDATE"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, purchaseId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + return null; + } + String state = result.getString(1); + if (COMPLETED.equals(state) || REFUNDED.equals(state)) { + rollback(connection); + return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null; + } + if (refund && !isRefundableState(state, refundableStates)) { + rollback(connection); + return null; + } + if (!refund && !HOOK_STARTED.equals(state)) { + rollback(connection); + return null; + } + String uuid = result.getString(2); + String pointsColumn = result.getString(3); + String limitColumn = result.getString(4); + refundedUuid = uuid; + refundedPointsColumn = pointsColumn; + refundedLimitColumn = limitColumn; + int cost = result.getInt(5); + String limitGeneration = result.getString(6); + Long limitEpoch = nullableLong(result, 8); + if (refund) { + boolean refundLimit = shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch); + pointWriteAttempted = true; + refund(connection, uuid, pointsColumn, limitColumn, cost, refundLimit); + } } - if (!refund && !HOOK_STARTED.equals(state)) { + } + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id") + + " = ?"; + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, terminalState); + updateStatement.setString(2, purchaseId); + if (updateStatement.executeUpdate() != 1) { rollback(connection); return null; } - String uuid = result.getString(2); - String pointsColumn = result.getString(3); - String limitColumn = result.getString(4); - refundedUuid = uuid; - refundedPointsColumn = pointsColumn; - refundedLimitColumn = limitColumn; - int cost = result.getInt(5); - String limitGeneration = result.getString(6); - Long limitEpoch = nullableLong(result, 8); - if (refund) { - refund(connection, uuid, pointsColumn, limitColumn, cost, - shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch)); - } - } - } - String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id") - + " = ?"; - try (PreparedStatement updateStatement = connection.prepareStatement(update)) { - updateStatement.setString(1, terminalState); - updateStatement.setString(2, purchaseId); - if (updateStatement.executeUpdate() != 1) { - rollback(connection); - return null; } + if (!commitAndConfirm(connection, purchaseId, terminalState)) return null; + return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn) + : new RefundedPurchase(null, null, null); + } catch (SQLException failure) { + throw failure; } - if (!commitAndConfirm(connection, purchaseId, terminalState)) return null; - return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn) - : new RefundedPurchase(null, null, null); } catch (SQLException failure) { + if (failure instanceof PreparationFailure) throw failure; + if (!pointWriteAttempted) throw new PreparationFailure(failure); throw failure; } } @@ -442,20 +466,28 @@ private static boolean isRefundableState(String state, String... refundableState private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost, boolean refundLimit) throws SQLException { - if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) { - throw new SQLException("Unsafe durable purchase column"); - } - StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ") - .append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?"); - if (refundLimit) { - refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn)) - .append(", 0) - 1, 0)"); - } - refund.append(" WHERE ").append(qi("uuid")).append(uuidCast()); - try (PreparedStatement statement = connection.prepareStatement(refund.toString())) { - statement.setInt(1, cost); - statement.setString(2, uuid); - if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing"); + boolean pointWriteAttempted = false; + try { + if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) { + throw new SQLException("Unsafe durable purchase column"); + } + StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ") + .append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?"); + if (refundLimit) { + refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn)) + .append(", 0) - 1, 0)"); + } + refund.append(" WHERE ").append(qi("uuid")).append(uuidCast()); + try (PreparedStatement statement = connection.prepareStatement(refund.toString())) { + statement.setInt(1, cost); + statement.setString(2, uuid); + pointWriteAttempted = true; + if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing"); + } + } catch (SQLException failure) { + if (failure instanceof PreparationFailure) throw failure; + if (!pointWriteAttempted) throw new PreparationFailure(failure); + throw failure; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java index 21bf19fbe5..2e0a65f4b4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java @@ -186,15 +186,24 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, // or entity task runs could pair an old debit with a newly loaded reward. FileConfiguration shopData = plugin.getShopFile().getData(); HashMap placeholders = purchasePlaceholders(item); + var pointPlaceholders = plugin.getPlaceholders(); + Consumer admission = pointPlaceholders == null ? null + : pointPlaceholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID())); try { plugin.getTimer().execute(() -> { try { SharedPurchaseDebit debit; - synchronized (purchaseLock(user.getUUID())) { - // Sample the reset window beside the conditional debit. A queued - // persistence task may otherwise cross into a new limit period. - debit = reserveSharedMysqlPurchase(user, item, - limitGeneration(item, System.currentTimeMillis())); + boolean settled = false; + try { + synchronized (purchaseLock(user.getUUID())) { + // Sample the reset window beside the conditional debit. A queued + // persistence task may otherwise cross into a new limit period. + debit = reserveSharedMysqlPurchase(user, item, + limitGeneration(item, System.currentTimeMillis())); + } + settled = true; + } finally { + if (admission != null) admission.accept(settled); } if (debit.result() != VoteShopPurchaseResult.SUCCESS) { completePurchaseResult(player, completion, debit.result()); @@ -207,6 +216,7 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, } }); } catch (RuntimeException persistenceRejected) { + if (admission != null) admission.accept(false); plugin.debug(persistenceRejected); completeFailedPurchase(player, completion); } @@ -479,20 +489,29 @@ private void completeClaimedRewardFailure(Player player, } private void refundCompensatingMysqlDebit(VotingPluginUser user, SharedPurchaseDebit debit) { + var publication = plugin.getPlaceholders(); + UUID uuid = publication == null ? null : UUID.fromString(user.getUUID()); + if (publication != null) publication.beginSharedPointDebit(uuid); + boolean known = false; try { - if (debit.journal().refundCompensatingReward(debit.purchaseId())) { - refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn()); - } + debit.journal().refundCompensatingReward(debit.purchaseId()); + known = true; } catch (SQLException failure) { + known = failure instanceof SharedMysqlPurchaseJournal.PreparationFailure; // A commit/confirmation failure is indeterminate: the refund transaction // may have committed even though this worker could not observe its terminal // journal state. Drop the affected snapshots before any later cache dump so // a stale debit cannot overwrite a durable refund. Recovery will reconcile // the journal state if the transaction did not commit. - refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn()); plugin.getLogger().severe("Unable to refund an incomplete vote shop purchase: " + failure.getClass().getSimpleName()); plugin.debug(failure); + } finally { + try { + refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn()); + } finally { + if (publication != null) publication.finishSharedPointDebit(uuid, known, false); + } } } @@ -738,37 +757,50 @@ private static Connection requireConnection(MySQL table) throws SQLException { private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, VoteShopItem item, LimitGeneration limitGeneration) { - MySQL table = plugin.getMysql(); - String pointsColumn = user.getPointsPath(); - String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; - ensurePerServerPointsColumn(table, pointsColumn); - drainPurchaseCache(user, pointsColumn); - if (limitColumn != null) { - table.checkColumn(limitColumn, DataType.INTEGER); - } + var publication = plugin.getPlaceholders(); + UUID pointUuid = UUID.fromString(user.getUUID()); + if (publication != null) publication.beginSharedPointDebit(pointUuid); + boolean known = true; try { - SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table); - String purchaseId = UUID.randomUUID().toString(); - if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(), - limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis())) { - // reserve() returns only after its transaction and connection are closed; - // NO_CACHE reads must not contend with its one-connection pool handle. + MySQL table = plugin.getMysql(); + String pointsColumn = user.getPointsPath(); + String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; + ensurePerServerPointsColumn(table, pointsColumn); + drainPurchaseCache(user, pointsColumn); + if (limitColumn != null) { + table.checkColumn(limitColumn, DataType.INTEGER); + } + try { + SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table); + String purchaseId = UUID.randomUUID().toString(); + known = false; + boolean reserved = journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(), + limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis()); + known = true; + if (reserved) { + // reserve() returns only after its transaction and connection are closed; + // NO_CACHE reads must not contend with its one-connection pool handle. + refreshPurchaseCache(user, pointsColumn, limitColumn); + return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn, + limitColumn); + } + } catch (SQLException failure) { + known |= failure instanceof SharedMysqlPurchaseJournal.PreparationFailure; + // reserve() can throw after its commit acknowledgement and confirmation + // both fail. The debit may therefore be durable even though this caller + // reports FAILED; drop snapshots recreated during that transaction so a + // later cache dump cannot restore the pre-reservation values. refreshPurchaseCache(user, pointsColumn, limitColumn); - return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn, - limitColumn); + plugin.getLogger().severe("Unable to atomically debit vote shop points: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null); } - } catch (SQLException failure) { - // reserve() can throw after its commit acknowledgement and confirmation - // both fail. The debit may therefore be durable even though this caller - // reports FAILED; drop snapshots recreated during that transaction so a - // later cache dump cannot restore the pre-reservation values. refreshPurchaseCache(user, pointsColumn, limitColumn); - plugin.getLogger().severe("Unable to atomically debit vote shop points: " - + failure.getClass().getSimpleName()); - plugin.debug(failure); - return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null); + return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null); + } finally { + if (publication != null) publication.finishSharedPointDebit(pointUuid, known, false); } - return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null); } /** The legacy cache path created server-suffixed points columns before direct SQL used them. */ diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/commands/RejectedPointDebitPlaceholderTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/commands/RejectedPointDebitPlaceholderTest.java new file mode 100644 index 0000000000..cb30ab4c40 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/commands/RejectedPointDebitPlaceholderTest.java @@ -0,0 +1,153 @@ +package com.bencodez.votingplugin.tests.commands; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.advancedcore.api.command.PlayerCommandHandler; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.commands.CommandLoader; +import com.bencodez.votingplugin.placeholders.PlaceHolders; +import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel; +import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class RejectedPointDebitPlaceholderTest { + @Test void rejectedDebitRetiresStalePointPlaceholderBeforeCallback() throws Exception { debitOutcomeAndRefillRecovery(false, false); } + @Test void confirmedDebitRecoversAfterRefillFailure() throws Exception { debitOutcomeAndRefillRecovery(true, true); } + @Test void rejectedDebitRecoversAfterRefillFailure() throws Exception { debitOutcomeAndRefillRecovery(false, true); } + + private void debitOutcomeAndRefillRecovery(boolean confirmed, boolean failInitialRefill) throws Exception { + var uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + var timer = mock(ScheduledExecutorService.class); + var pending = new java.util.ArrayDeque(); + doAnswer(call -> { pending.addLast(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + var user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + var field = VotingPluginUser.class.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(user, plugin); + doReturn(uuid.toString()).when(user).getUUID(); + doReturn(uuid).when(user).getJavaUUID(); + doReturn("Points").when(user).getPointsPath(); + doReturn(false).when(user).isCached(); + doNothing().when(user).cache(); + doReturn(mock(Player.class)).when(user).getPlayer(); + doReturn(4).when(user).getPoints(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getTimer()).thenReturn(timer); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getConfigFile().getPlaceholderCacheLevel()).thenReturn(PlaceholderCacheLevel.AUTOALL); + when(plugin.getConfigFile().getCachedPlaceholders()).thenReturn(new ArrayList<>(List.of("points"))); + when(plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>()); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(new PlaceholderPlayerPresence()); + var offline = mock(OfflinePlayer.class); + when(offline.getUniqueId()).thenReturn(uuid); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(offline)).thenReturn(user); + when(plugin.getVotingPluginUserManager().getVotingPluginUser("TestUser")).thenReturn(user); + var cache = new UserDataCache(null, uuid); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(20)))); + var manager = plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new ConcurrentHashMap<>(Map.of(uuid, cache))); + var refillFails = new java.util.concurrent.atomic.AtomicBoolean(failInitialRefill); + doAnswer(call -> { + if (refillFails.get()) throw new IllegalStateException("synthetic refill unavailable"); + cache.updateSharedSnapshot(new HashMap<>(Map.of("Points", new DataValueInt(4))), cache.getSharedSnapshotVersion()); + return null; + }).when(manager).cacheUser(uuid, null); + var table = mock(MySQL.class); + var sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, RETURNS_DEEP_STUBS); + var connection = mock(Connection.class); + var statement = mock(PreparedStatement.class); + when(statement.executeUpdate()).thenReturn(confirmed ? 1 : 0); + var read = mock(PreparedStatement.class); + var total = mock(ResultSet.class); + when(total.next()).thenReturn(true); + when(total.getInt(1)).thenReturn(4); + when(read.executeQuery()).thenReturn(total); + var lookup = mock(PreparedStatement.class); + when(lookup.executeQuery()).thenReturn(mock(ResultSet.class)); + when(plugin.getMysql()).thenReturn(table); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenAnswer(call -> + call.getArgument(0).startsWith("SELECT `player_uuid`") ? lookup + : call.getArgument(0).startsWith("SELECT `Points` FROM") ? read : statement); + var commands = new ArrayList(); + when(plugin.getAdminVoteCommand()).thenReturn(commands); + var oldInstance = AdvancedCorePlugin.getInstance(); + var oldPlugin = VotingPluginMain.plugin; + AdvancedCorePlugin.setInstance(plugin); + VotingPluginMain.plugin = plugin; + try (var coreCommands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) { + var helper = mock(com.bencodez.advancedcore.command.CommandLoader.class); + coreCommands.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(helper); + when(helper.getBasicAdminCommands("VotingPlugin")).thenReturn(new ArrayList<>()); + var placeholders = new PlaceHolders(plugin); + when(plugin.getPlaceholders()).thenReturn(placeholders); + placeholders.load(); + var points = placeholders.getPlaceholders().stream().filter(p -> p.matches("points")).findFirst().orElseThrow(); + points.getCache().get("points").put(uuid, "20"); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertEquals("20", placeholders.getPlaceHolder(offline, "points", false)); + var register = CommandLoader.class.getDeclaredMethod("loadAdminVoteCommand"); + register.setAccessible(true); + register.invoke(new CommandLoader(plugin)); + var command = (PlayerCommandHandler) commands.stream() + .filter(c -> Arrays.equals(c.getArgs(), new String[] {"User", "(player)", "RemovePoints", "(number)"})) + .findFirst().orElseThrow(); + command.executeSinglePlayer(mock(CommandSender.class), new String[] {"User", "TestUser", "RemovePoints", "16"}); + assertEquals("...", placeholders.getPlaceHolder(offline, "points", false)); + verify(user, never()).cache(); + verify(sql.getConnectionManager(), never()).getConnection(); + assertEquals(1, pending.size()); + pending.removeFirst().run(); + if (failInitialRefill) { + assertNull(cache.snapshotIfPublished()); + assertEquals("...", placeholders.getPlaceHolder(offline, "points_nocache", false)); + assertEquals(1, pending.size(), "normal request must admit recovery once"); + refillFails.set(false); + pending.removeFirst().run(); + } else { + assertEquals("...", placeholders.getPlaceHolder(offline, "points", false)); + assertEquals(1, pending.size()); + pending.removeFirst().run(); + } + assertEquals(4, cache.snapshotIfPublished().get("Points").getInt()); + assertEquals("4", placeholders.getPlaceHolder(offline, "points", false)); + assertTrue(pending.isEmpty()); + } + } finally { + AdvancedCorePlugin.setInstance(oldInstance); + VotingPluginMain.plugin = oldPlugin; + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/ColdOfflinePlaceholderCacheTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/ColdOfflinePlaceholderCacheTest.java index 116069cb14..4baafa69ef 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/ColdOfflinePlaceholderCacheTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/ColdOfflinePlaceholderCacheTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; @@ -45,6 +46,1182 @@ import com.bencodez.votingplugin.util.BukkitCompletionScheduler; class ColdOfflinePlaceholderCacheTest { + + @org.junit.jupiter.api.io.TempDir java.nio.file.Path transferFallbackData; + + + @Test void activeRefillCannotBeReopenedByKnownDebitCompletion() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + f.placeholders.beginSharedPointDebit(f.uuid); + var refill = f.placeholders.sharedPointRefillCompletion(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true, true); + try { + f.placeholders.onUpdate(f.user, true); + verify(f.user, never()).getPoints(); + assertEquals("...", f.request("points_nocache")); + } finally { refill.accept(true); } + } + + @Test void normalPointRequestRecoversKnownUnavailableSnapshotOnWorker() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + when(f.user.getPoints()).thenReturn(4); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true, false); + assertEquals("...", f.request("points")); + assertEquals(1, f.pending.size(), "ordinary requests must admit one recovery worker"); + assertEquals("...", f.request("points_nocache")); + f.placeholders.onUpdate(f.user, false); + assertEquals(1, f.pending.size(), "requests and onUpdate must coalesce while recovery is pending"); + f.drain(); + assertEquals("4", f.request("points_nocache")); + } + + @Test void publishedAbsentPerServerPointFieldUsesZeroWithoutStorageRecovery() { + Fixture f = new Fixture(PlaceholderCacheLevel.NONE); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(f.plugin.getBungeeSettings().getServerNameStorage()).thenReturn("serverA"); + when(f.user.getPointsPath()).thenReturn("serverA_Points"); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Other", new com.bencodez.simpleapi.sql.data.DataValueInt(7)))); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + assertEquals("0", f.request("points_nocache")); + assertEquals("0", f.request("points_format_nocache")); + assertEquals("false", f.request("enoughpoints_16_nocache")); + assertTrue(f.pending.isEmpty()); + verify(f.user, never()).getPoints(); + verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any()); + } + + @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedMalformedPointsAUTO() { + assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.AUTO, new com.bencodez.simpleapi.sql.data.DataValueString("invalid")); + } + + @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedUnsupportedPointsAUTO() { + assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.AUTO, new com.bencodez.simpleapi.sql.data.DataValueBoolean(true)); + } + + @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedNullPointsAUTO() { + assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.AUTO, null); + } + + @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedMalformedPointsSPECIFIC() { + assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.SPECIFIC, new com.bencodez.simpleapi.sql.data.DataValueString("invalid")); + } + + @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedUnsupportedPointsSPECIFIC() { + assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.SPECIFIC, new com.bencodez.simpleapi.sql.data.DataValueBoolean(true)); + } + + @Test void commonOfflineMonthlyWorkerDoesNotReloadPublishedNullPointsSPECIFIC() { + assertCommonOfflineInvalidPoints(PlaceholderCacheLevel.SPECIFIC, null); + } + + private void assertCommonOfflineInvalidPoints(PlaceholderCacheLevel level, com.bencodez.simpleapi.sql.data.DataValue invalid) { + Fixture f = new Fixture(level, "total_monthly"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var row = new java.util.HashMap(); + row.put("Points", invalid); + cache.updateCache(row); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { cache.updateCache(new java.util.HashMap<>(row)); return null; }) + .when(manager).cacheUser(f.uuid, null); + for (int n = 0; n < 3; n++) { + assertEquals("...", f.request("total_monthly")); + assertEquals(1, f.pending.size()); + f.pending.removeFirst().run(); + assertTrue(f.pending.isEmpty()); + assertFalse(f.placeholder("total_monthly").getCache().get("total_monthly").containsKey(f.uuid)); + assertEquals("...", f.request("points_nocache")); + assertTrue(f.pending.isEmpty()); + } + verify(manager, never()).cacheUser(any(), any()); + verify(f.user, never()).getTotal(TopVoter.Monthly); + verify(f.user, never()).getPoints(); + } + + @Test void commonThenDirectColdRequestsShareQueuedAndExecutingRecovery() { + assertCommonAndDirectColdRecovery(false); + } + + @Test void directThenCommonColdRequestsShareQueuedAndExecutingRecovery() { + assertCommonAndDirectColdRecovery(true); + } + + private void assertCommonAndDirectColdRecovery(boolean directFirst) { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points", "total_monthly"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + // Reentrant public requests model admission while the worker owns its token/refill. + assertEquals("...", f.request("total_monthly")); + assertEquals("...", f.request("points_nocache")); + assertTrue(f.pending.isEmpty(), "executing common/direct recovery must not queue nested work"); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + assertEquals("...", f.request(directFirst ? "points_nocache" : "total_monthly")); + assertEquals("...", f.request(directFirst ? "total_monthly" : "points_nocache")); + assertEquals("...", f.request("total_monthly")); + assertEquals(1, f.pending.size(), "queued common/direct requests must share admission"); + f.pending.removeFirst().run(); + assertTrue(f.pending.isEmpty()); + assertEquals("4", f.request("points_nocache")); + assertEquals("42", f.request("total_monthly"), "ordinary nonpoint warming must still complete"); + verify(manager, times(1)).cacheUser(f.uuid, null); + verify(f.user, never()).getPoints(); + } + + @Test void commonWorkerRepairsKnownTransientSnapshotWithoutNestedTask() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true, false); + assertEquals("...", f.request("total_monthly")); + f.pending.removeFirst().run(); + assertTrue(f.pending.isEmpty()); + assertEquals("4", f.request("points_nocache")); + assertEquals("42", f.request("total_monthly")); + verify(manager, times(1)).cacheUser(f.uuid, null); + } + + @Test void commonWorkerWarmsNonPointsWithoutRefillingPendingOrUnknownPoints() { + for (boolean unknown : List.of(false, true)) { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + f.placeholders.beginSharedPointDebit(f.uuid); + if (unknown) f.placeholders.finishSharedPointDebit(f.uuid, false, false); + try { + assertEquals("...", f.request("total_monthly")); + f.pending.removeFirst().run(); + assertEquals("42", f.request("total_monthly")); + assertEquals("...", f.request("points_nocache")); + assertTrue(f.pending.isEmpty()); + verify(manager, never()).cacheUser(any(), any()); + } finally { + if (!unknown) f.placeholders.finishSharedPointDebit(f.uuid, true, false); + } + } + } + + @Test void staleCommonWorkersDoNotReadStorageOrWarmAfterDisableOrReplacement() { + for (boolean disabled : List.of(false, true)) { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + assertEquals("...", f.request("total_monthly")); + if (disabled) when(f.plugin.isEnabled()).thenReturn(false); + else when(f.plugin.getPlaceholders()).thenReturn(mock(PlaceHolders.class)); + f.pending.removeFirst().run(); + assertTrue(f.pending.isEmpty()); + verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any()); + verify(f.user, never()).getTotal(TopVoter.Monthly); + } + } + + @Test void staleReloadedCommonWorkerCannotRemoveNewRecoveryAdmission() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + assertEquals("...", f.request("total_monthly")); + Runnable retired = f.pending.removeFirst(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + f.placeholders.reload(); + } + assertEquals("...", f.request("total_monthly")); + assertEquals(1, f.pending.size()); + retired.run(); + verify(manager, never()).cacheUser(any(), any()); + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size(), "stale token retirement must preserve new worker admission"); + f.pending.removeFirst().run(); + assertEquals("4", f.request("points_nocache")); + assertEquals("42", f.request("total_monthly")); + assertTrue(f.pending.isEmpty()); + verify(manager, times(1)).cacheUser(f.uuid, null); + } + + @Test void rejectedCommonWorkerAdmissionAllowsLaterDirectRecovery() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + doThrow(new java.util.concurrent.RejectedExecutionException("closed")) + .doAnswer(call -> { f.pending.addLast(call.getArgument(0)); return null; }) + .when(f.timer).execute(any(Runnable.class)); + assertEquals("...", f.request("total_monthly")); + assertTrue(f.pending.isEmpty()); + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size()); + f.pending.removeFirst().run(); + assertEquals("4", f.request("points_nocache")); + assertEquals("42", f.request("total_monthly")); + assertTrue(f.pending.isEmpty()); + verify(manager, times(1)).cacheUser(f.uuid, null); + } + + @Test void lateAutomaticNonPointAdmissionIsNotLostWhileCommonWorkerWarms() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly"); + when(f.user.getLastMonthTotal()).thenReturn(7); + doAnswer(call -> { + assertEquals("..", f.request("lastmonthtotal")); + return 42; + }).doReturn(42).when(f.user).getTotal(TopVoter.Monthly); + assertEquals("...", f.request("total_monthly")); + f.pending.removeFirst().run(); + assertEquals(1, f.pending.size(), "late new identifier needs a retained warming continuation"); + f.drain(); + assertEquals("7", f.request("lastmonthtotal")); + assertEquals("42", f.request("total_monthly")); + assertTrue(f.pending.isEmpty()); + } + + @Test void disableDuringCommonStorageRecoveryPreventsNonPointWarming() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "total_monthly"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + when(f.plugin.isEnabled()).thenReturn(false); + return null; + }).when(manager).cacheUser(f.uuid, null); + assertEquals("...", f.request("total_monthly")); + f.pending.removeFirst().run(); + assertTrue(f.pending.isEmpty()); + verify(manager, times(1)).cacheUser(f.uuid, null); + verify(f.user, never()).getTotal(TopVoter.Monthly); + } + + @Test void completedWorkerPublishedMalformedPointsDoNotLoopRecovery() throws Exception { + assertCompletedInvalidSnapshot(new com.bencodez.simpleapi.sql.data.DataValueString("invalid")); + } + @Test void completedWorkerPublishedUnsupportedPointsDoNotLoopRecovery() throws Exception { + assertCompletedInvalidSnapshot(new com.bencodez.simpleapi.sql.data.DataValueBoolean(true)); + } + @Test void completedWorkerPublishedNullPointsDoNotBecomeZeroOrLoopRecovery() throws Exception { + assertCompletedInvalidSnapshot(null); + } + + private void assertCompletedInvalidSnapshot(com.bencodez.simpleapi.sql.data.DataValue invalid) { + Fixture f = new Fixture(PlaceholderCacheLevel.NONE); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + var values = new java.util.HashMap(); + values.put("Points", invalid); cache.updateCache(values); return null; + }).when(manager).cacheUser(f.uuid, null); + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size()); + f.pending.removeFirst().run(); + assertTrue(f.pending.isEmpty()); + for (int n = 0; n < 5; n++) { + assertEquals("...", f.request("points_nocache")); + assertTrue(f.pending.isEmpty(), "a completed invalid published row must not be reloaded per request"); + } + verify(manager, times(1)).cacheUser(f.uuid, null); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + assertEquals("4", f.request("points_nocache")); + assertTrue(f.pending.isEmpty(), "new valid published data needs no extra storage query"); + verify(f.user, never()).getPoints(); + } + + @Test void publicKnownUnstartedSetAcceptsCompletePublishedAbsentPointField() throws Exception { + Fixture f = mysqlPublicFixture(); + when(f.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(f.plugin.getBungeeSettings().getServerNameStorage()).thenReturn("serverA"); + when(f.user.getPointsPath()).thenReturn("serverA_Points"); + when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(null); + var manager = f.plugin.getUserManager().getDataManager(); + doAnswer(call -> { + var complete = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + complete.updateCache(new java.util.HashMap<>()); + manager.getUserDataCache().put(f.uuid, complete); return null; + }).when(manager).cacheUser(f.uuid, null); + org.mockito.Mockito.doCallRealMethod().when(f.user).setPointsStorageAware(eq(4), any()); + var results = new java.util.ArrayList(); + f.user.setPointsStorageAware(4, results::add); + f.pending.removeFirst().run(); + assertEquals(List.of(false), results); + assertEquals("0", f.request("points_nocache")); + assertTrue(f.pending.isEmpty(), "published absence after known completion must not start another recovery"); + verify(manager, times(1)).cacheUser(f.uuid, null); + verify(f.user, never()).getPoints(); + } + + @Test void publishedInvalidThenValidSnapshotUpdatesCachedPointRendering() throws Exception { + Fixture f = mysqlPublicFixture(); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueString("invalid")))); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + assertEquals("...", f.request("points")); + assertTrue(f.pending.isEmpty()); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + f.placeholders.onUpdate(f.user, true); + assertEquals("4", f.request("points")); + assertTrue(f.pending.isEmpty()); + verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any()); + verify(f.user, never()).getPoints(); + } + + @Test void unpublishedAbsenceAndGenuineUncertaintyNeverDefaultToZero() { + Fixture f = new Fixture(PlaceholderCacheLevel.NONE); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size()); + f.placeholders.beginSharedPointDebit(f.uuid); + cache.updateCache(new java.util.HashMap<>()); + assertEquals("...", f.request("points_nocache"), "an active mutation cannot publish default zero"); + f.placeholders.finishSharedPointDebit(f.uuid, false, true); + f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true); + assertEquals("...", f.request("points_nocache"), "published absence cannot clear transaction uncertainty"); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); f.placeholders.reload(); + } + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size(), "unknown state must not add recovery work"); + verify(f.user, never()).getPoints(); + } + + @Test void perServerNumericStringSnapshotPreservesPointValue() { + Fixture f = new Fixture(PlaceholderCacheLevel.NONE); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(f.plugin.getBungeeSettings().getServerNameStorage()).thenReturn("serverA"); + when(f.user.getPointsPath()).thenReturn("serverA_Points"); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("serverA_Points", new com.bencodez.simpleapi.sql.data.DataValueString("17")))); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + assertEquals("17", f.request("points_nocache")); + assertEquals("17", f.request("points_format_nocache")); + assertEquals("true", f.request("enoughpoints_16_nocache")); + } + + @Test void reorderedRefillsCannotReopenBeforeEveryReadRetires() { + for (boolean reverse : List.of(false, true)) { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + f.placeholders.beginSharedPointDebit(f.uuid); + var first = f.placeholders.sharedPointRefillCompletion(f.uuid); + var second = f.placeholders.sharedPointRefillCompletion(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true, true); + (reverse ? second : first).accept(true); + assertEquals("...", f.request("points_nocache")); + assertTrue(f.pending.isEmpty(), "an active storage read must not admit recovery"); + (reverse ? first : second).accept(true); + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size(), "an obsolete completion requires a fresh coalesced read"); + } + } + + @Test void rejectedRecoveryAdmissionCanBeRetriedWithoutRetryingDebit() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true, false); + doThrow(new java.util.concurrent.RejectedExecutionException("stopped")) + .doAnswer(call -> { f.pending.add(call.getArgument(0)); return null; }) + .when(f.timer).execute(any(Runnable.class)); + assertEquals("...", f.request("points")); + assertTrue(f.pending.isEmpty()); + assertEquals("...", f.request("points")); + assertEquals(1, f.pending.size()); + verify(f.user, never()).removePoints(org.mockito.ArgumentMatchers.anyInt(), any(java.util.function.Consumer.class)); + } + + @Test void recoveryQueuedBeforeReloadCannotReadStorageInNewLifecycle() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true, false); + assertEquals("...", f.request("points")); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + f.placeholders.reload(); + } + f.pending.removeFirst().run(); + verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any()); + assertEquals("...", f.request("points")); + assertEquals(1, f.pending.size()); + } + + @Test void malformedNumericStringRemainsUnavailableWithoutOwnerStorageRead() { + for (String text : List.of("invalid", "", "2147483648")) { + Fixture f = new Fixture(PlaceholderCacheLevel.NONE); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueString(text)))); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + assertEquals("...", f.request("points_nocache")); + assertTrue(f.pending.isEmpty(), "published invalid data must not schedule storage recovery"); + verify(f.user, never()).getPoints(); + verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any()); + } + } + + @Test void publicReplayAdditionClosesRenderingBeforeWorkerAdmission() throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.user.getPointsPath()).thenReturn("Points"); + org.mockito.Mockito.doReturn(f.player).when(f.user).getPlayer(); + var field = VotingPluginUser.class.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(f.user, f.plugin); + org.mockito.Mockito.doCallRealMethod().when(f.user).addPointsStorageAwareAsync(org.mockito.ArgumentMatchers.anyInt(), anyString()); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + var completion = f.user.addPointsStorageAwareAsync(16, "synthetic-public-entry-" + UUID.randomUUID()); + try { assertEquals("...", f.request("points")); } + finally { completion.toCompletableFuture().cancel(false); } + } + + @Test void publicSharedAndPerServerReplayFailuresRemainUnavailableAfterRefill() throws Exception { + for (boolean perServer : new boolean[]{false, true}) { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(perServer); + when(f.plugin.getBungeeSettings().getServerNameStorage()).thenReturn("Synthetic"); + String column = perServer ? "Synthetic_Points" : "Points"; + when(f.user.getPointsPath()).thenReturn(column); + org.mockito.Mockito.doReturn(f.player).when(f.user).getPlayer(); + var field = VotingPluginUser.class.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(f.user, f.plugin); + org.mockito.Mockito.doCallRealMethod().when(f.user).addPointsStorageAwareAsync(org.mockito.ArgumentMatchers.anyInt(), anyString()); + when(f.plugin.getMysql().getTableName()).thenReturn("SyntheticPoints"); + when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()) + .thenThrow(new IllegalStateException("synthetic unavailable acknowledgement")); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of(column, new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + var completion = f.user.addPointsStorageAwareAsync(16, "synthetic-replay-failure-" + UUID.randomUUID()).toCompletableFuture(); + assertEquals("...", f.request("points")); + f.pending.removeFirst().run(); + assertTrue(completion.isCompletedExceptionally()); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of(column, new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true); + assertEquals("...", f.request("points_nocache")); + assertTrue(f.pending.isEmpty(), "uncertain completion must not schedule an automatic retry/refill"); + verify(f.user, never()).getPoints(); + } + } + + @Test void completedTransferRefreshesSourceAndTargetRender() throws Exception { assertTransferRender("COMPLETED"); } + @Test void refundedTransferRefreshesSourceAndTargetRender() throws Exception { assertTransferRender("REFUNDED"); } + @Test void indeterminateTransferDoesNotPublishRefillAsConfirmation() throws Exception { assertTransferRender("INDETERMINATE"); } + + private void assertTransferRender(String outcome) throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.user.getPointsPath()).thenReturn("Points"); + UUID targetUuid = UUID.fromString("00000000-0000-0000-0000-000000000002"); + var target = mock(VotingPluginUser.class); + when(target.getUUID()).thenReturn(targetUuid.toString()); + when(target.getJavaUUID()).thenReturn(targetUuid); + when(target.getPointsPath()).thenReturn("Points"); + var offlineTarget = mock(OfflinePlayer.class); + when(f.plugin.getVotingPluginUserManager().getVotingPluginUser(offlineTarget)).thenReturn(target); + var caches = new java.util.concurrent.ConcurrentHashMap(); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(caches); + for (UUID uuid : List.of(f.uuid, targetUuid)) { + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(uuid.equals(f.uuid) ? 20 : 0)))); + caches.put(uuid, cache); + } + doAnswer(call -> { + UUID uuid = call.getArgument(0); + int balance = uuid.equals(f.uuid) ? (outcome.equals("REFUNDED") ? 20 : 4) : (outcome.equals("REFUNDED") ? 0 : 16); + caches.get(uuid).updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(balance)))); + return null; + }).when(manager).cacheUser(any(UUID.class), org.mockito.ArgumentMatchers.isNull()); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + f.placeholder("points").getCache().get("points").put(targetUuid, "0"); + var journal = com.bencodez.votingplugin.tests.user.SharedPointTransferJournalAccess.mock(); + journal.stub(org.mockito.Mockito.doReturn(com.bencodez.votingplugin.tests.support.ReflectiveAccess.field( + "com.bencodez.votingplugin.user.SharedPointTransferJournal$SettlementOutcome", outcome))) + .settleWithConfirmation("synthetic-transfer", "owner", f.uuid.toString(), "Points", targetUuid.toString(), "Points", 16, 16); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class)); + var completions = new java.util.ArrayList(); + java.util.function.Consumer completion = completions::add; + com.bencodez.votingplugin.tests.support.ReflectiveAccess.call("com.bencodez.votingplugin.user.SharedMysqlPointMutator", + new com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess(f.plugin), "settleTransfer", + new String[]{"VotingPluginUser", "VotingPluginUser", "Player", "int", "Consumer", + "SharedPointTransferJournal", "String", "String", "String", "String", "Integer"}, + new Object[]{f.user, target, null, 16, completion, journal, "synthetic-transfer", "owner", "Points", "Points", 16}); + assertEquals(1, completions.size()); + journal.verify(org.mockito.Mockito.times(1)).settleWithConfirmation("synthetic-transfer", "owner", f.uuid.toString(), "Points", targetUuid.toString(), "Points", 16, 16); + if (outcome.equals("INDETERMINATE")) { + assertEquals(com.bencodez.votingplugin.user.PointTransferResult.PENDING_CONFIRMATION, completions.getFirst()); + assertEquals("...", f.request("points_nocache")); + } else { + assertEquals(outcome.equals("COMPLETED") ? com.bencodez.votingplugin.user.PointTransferResult.SUCCESS + : com.bencodez.votingplugin.user.PointTransferResult.CANCELLED, completions.getFirst()); + assertEquals("...", f.request("points")); + assertEquals(outcome.equals("REFUNDED") ? "20" : "4", f.request("points_nocache")); + assertEquals(outcome.equals("REFUNDED") ? "0" : "16", f.placeholders.getPlaceholderValue(offlineTarget, "points", false, true, false)); + } + } + + @Test void confirmedRefundRetiresStaleRenderedBalance() throws Exception { assertRefundRender(false); } + @Test void ambiguousRefundCannotPublishAsConfirmedBalance() throws Exception { assertRefundRender(true); } + + private void assertRefundRender(boolean ambiguous) throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.user.getPointsPath()).thenReturn("Points"); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + var journal = com.bencodez.votingplugin.tests.user.SharedPointTransferJournalAccess.mock(); + if (ambiguous) when(journal.refundHookStarted("synthetic-refund", f.uuid.toString(), "Points", 16)) + .thenThrow(new java.sql.SQLException("synthetic acknowledgement lost")); + else when(journal.refundHookStarted("synthetic-refund", f.uuid.toString(), "Points", 16)).thenReturn(true); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class)); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + var completions = new java.util.ArrayList(); + new com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess(f.plugin).refundClaimedAfterSchedulingFailure( + f.user, null, completions::add, journal, "synthetic-refund", "Points", 16, new java.util.concurrent.RejectedExecutionException("synthetic retired owner")); + assertEquals(List.of(com.bencodez.votingplugin.user.PointTransferResult.UNAVAILABLE), completions); + journal.verify(org.mockito.Mockito.times(1)).refundHookStarted("synthetic-refund", f.uuid.toString(), "Points", 16); + if (ambiguous) assertEquals("...", f.request("points_nocache")); + else { + assertEquals("...", f.request("points")); + assertEquals("4", f.request("points_nocache")); + } + } + + @Test void publicSetAdmissionRetiresRenderedValueBeforeWorkerExecution() throws Exception { + Fixture f = mysqlPublicFixture(); + org.mockito.Mockito.doCallRealMethod().when(f.user).setPointsStorageAware(eq(4), any()); + f.user.setPointsStorageAware(4, ignored -> { }); + assertEquals(1, f.pending.size()); + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size(), "pending mutation must not admit a competing recovery"); + } + + @Test void publicPurchaseAdmissionRetiresRenderedValueBeforeWorkerExecution() throws Exception { + Fixture f = mysqlPublicFixture(); + startPublicPurchase(f, ignored -> { }); + assertEquals(1, f.pending.size()); + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size(), "pending reservation must not admit a competing recovery"); + } + + @Test void publicSetNullConnectionIsRecoverableWithoutExecutingUpdate() throws Exception { assertPublicSetPhase("acquire"); } + @Test void publicSetPreparationFailureIsRecoverableWithoutExecutingUpdate() throws Exception { assertPublicSetPhase("prepare"); } + @Test void publicSetBindFailureIsRecoverableWithoutExecutingUpdate() throws Exception { assertPublicSetPhase("bind"); } + @Test void publicSetExecutionFailureStaysUncertain() throws Exception { assertPublicSetPhase("execute"); } + @Test void publicSetConfirmedUpdatePublishesFreshSnapshot() throws Exception { assertPublicSetPhase("confirmed"); } + + private void assertPublicSetPhase(String phase) throws Exception { + Fixture f = mysqlPublicFixture(); + var connection = mock(java.sql.Connection.class); + var statement = mock(java.sql.PreparedStatement.class); + when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(phase.equals("acquire") ? null : connection); + if (phase.equals("prepare")) when(connection.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("synthetic prepare failure")); + else when(connection.prepareStatement(anyString())).thenReturn(statement); + if (phase.equals("bind")) doThrow(new java.sql.SQLException("synthetic bind failure")).when(statement).setInt(1, 4); + if (phase.equals("execute")) when(statement.executeUpdate()).thenThrow(new java.sql.SQLException("synthetic acknowledgement failure")); + else when(statement.executeUpdate()).thenReturn(1); + org.mockito.Mockito.doCallRealMethod().when(f.user).setPointsStorageAware(eq(4), any()); + var results = new java.util.ArrayList(); + f.user.setPointsStorageAware(4, results::add); + assertEquals(1, f.pending.size()); + f.pending.removeFirst().run(); + assertEquals(List.of(phase.equals("confirmed")), results); + boolean attempted = phase.equals("execute") || phase.equals("confirmed"); + verify(statement, times(attempted ? 1 : 0)).executeUpdate(); + assertEquals("...", f.request("points")); + if (!phase.equals("execute")) { + f.drain(); + assertEquals("4", f.request("points_nocache"), "known unstarted writes must recover through ordinary requests"); + } else { + assertTrue(f.pending.isEmpty(), "ambiguous writes must not be retried/reopened"); + assertEquals("...", f.request("points_nocache")); + } + verify(f.user, never()).getPoints(); + } + + private Fixture mysqlPublicFixture() throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(f.user, f.plugin); + org.mockito.Mockito.doReturn(f.player).when(f.user).getPlayer(); + when(f.scheduler.getFoliaLib()).thenReturn(null); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class), eq(f.player)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class)); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + var caches = new java.util.concurrent.ConcurrentHashMap(); + caches.put(f.uuid, cache); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(caches); + doAnswer(call -> { + var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + caches.put(f.uuid, fresh); return null; + }).when(manager).cacheUser(f.uuid, null); + when(f.plugin.getMysql().getTableName()).thenReturn("SyntheticPoints"); + when(f.plugin.getMysql().qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`"); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + return f; + } + + @Test void publicTransferRejectedReservationRepairsRecreatedSourceBeforeCallback() throws Exception { assertPublicTransferReserve("rejected"); } + @Test void publicTransferAmbiguousReservationCannotPublishRefillAsConfirmation() throws Exception { assertPublicTransferReserve("ambiguous"); } + + @Test void publicTransferPreparationFailureRecoversWithoutDebit() throws Exception { assertPublicTransferReserve("prepare"); } + @Test void publicTransferAcquisitionFailureRecoversWithoutDebit() throws Exception { assertPublicTransferReserve("acquire"); } + + private void assertPublicTransferReserve(String phase) throws Exception { + boolean ambiguous = phase.equals("ambiguous"); + Fixture f = mysqlPublicFixture(); + var target = mock(VotingPluginUser.class); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(target.getPointsPath()).thenReturn("Points"); + org.mockito.Mockito.doCallRealMethod().when(f.user).transferPointsWithResult(eq(target), eq(16), any()); + var connection = mock(java.sql.Connection.class); + when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(phase.equals("acquire") ? null : connection); + var updates = new java.util.concurrent.atomic.AtomicInteger(); + when(connection.prepareStatement(anyString())).thenAnswer(call -> { + String sql = call.getArgument(0); + if (phase.equals("prepare") && sql.startsWith("INSERT INTO")) throw new java.sql.SQLException("synthetic reservation preparation failure"); + var statement = mock(java.sql.PreparedStatement.class); + when(statement.executeQuery()).thenReturn(mock(java.sql.ResultSet.class)); + if (sql.startsWith("UPDATE `SyntheticPoints` SET")) when(statement.executeUpdate()).thenAnswer(update -> { + updates.incrementAndGet(); + var recreated = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + recreated.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + f.plugin.getUserManager().getDataManager().getUserDataCache().put(f.uuid, recreated); + if (ambiguous) throw new java.sql.SQLException("synthetic unconfirmed reservation"); + return 0; + }); + return statement; + }); + var results = new java.util.ArrayList(); + var callbackBalances = new java.util.ArrayList(); + f.user.transferPointsWithResult(target, 16, result -> { + results.add(result); callbackBalances.add(f.request("points_nocache")); + }); + f.pending.removeFirst().run(); + assertEquals(List.of(phase.equals("rejected") ? com.bencodez.votingplugin.user.PointTransferResult.INSUFFICIENT_POINTS + : com.bencodez.votingplugin.user.PointTransferResult.UNAVAILABLE), results); + assertEquals(phase.equals("prepare") || phase.equals("acquire") ? 0 : 1, updates.get()); + assertEquals(List.of(ambiguous ? "..." : "4"), callbackBalances); + verify(target, never()).addPoints(org.mockito.ArgumentMatchers.anyInt()); + verify(f.plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any()); + if (ambiguous) { + f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true); + assertEquals("...", f.request("points_nocache")); + assertTrue(f.pending.isEmpty(), "ambiguous reservation is never automatically retried"); + } + } + + @Test void publicTransferApprovalRejectionConfirmedRefundRepairsBeforeCallback() throws Exception { assertPublicTransferApprovalRejection("confirmed"); } + @Test void publicTransferApprovalRejectionAmbiguousRefundRemainsUnavailable() throws Exception { assertPublicTransferApprovalRejection("ambiguous"); } + @Test void publicTransferApprovalRejectionUnstartedRefundRemainsRecoverable() throws Exception { assertPublicTransferApprovalRejection("prepare"); } + + private void assertPublicTransferApprovalRejection(String phase) throws Exception { + Fixture f = mysqlPublicFixture(); + var target = mock(VotingPluginUser.class); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(target.getPointsPath()).thenReturn("Points"); + org.mockito.Mockito.doCallRealMethod().when(f.user).transferPointsWithResult(eq(target), eq(16), any()); + var storage = new java.util.concurrent.atomic.AtomicInteger(20); + var debits = new java.util.concurrent.atomic.AtomicInteger(); + var refunds = new java.util.concurrent.atomic.AtomicInteger(); + var manager = f.plugin.getUserManager().getDataManager(); + doAnswer(call -> { + var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(storage.get())))); + manager.getUserDataCache().put(f.uuid, fresh); return null; + }).when(manager).cacheUser(f.uuid, null); + var connection = mock(java.sql.Connection.class); + when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenAnswer(call -> { + String sql = call.getArgument(0); + boolean points = sql.startsWith("UPDATE `SyntheticPoints` SET"); + boolean refund = points && sql.contains(" + ?"); + if (refund && phase.equals("prepare")) throw new java.sql.SQLException("synthetic refund prepare failure"); + var statement = mock(java.sql.PreparedStatement.class); + when(statement.executeQuery()).thenAnswer(query -> { + var result = mock(java.sql.ResultSet.class); + if (sql.contains("FOR UPDATE")) { + when(result.next()).thenReturn(true, false); when(result.getString(1)).thenReturn("RESERVED"); + } + return result; + }); + when(statement.executeUpdate()).thenAnswer(update -> { + if (points) { + if (refund) { refunds.incrementAndGet(); storage.set(20); } + else { debits.incrementAndGet(); storage.set(4); } + if (refund && phase.equals("ambiguous")) throw new java.sql.SQLException("synthetic unconfirmed refund"); + } + return 1; + }); + return statement; + }); + doThrow(new java.util.concurrent.RejectedExecutionException("synthetic approval rejected")) + .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class)); + var results = new java.util.ArrayList(); + var balances = new java.util.ArrayList(); + f.user.transferPointsWithResult(target, 16, result -> { results.add(result); balances.add(f.request("points_nocache")); }); + f.pending.removeFirst().run(); + assertEquals(List.of(com.bencodez.votingplugin.user.PointTransferResult.UNAVAILABLE), results); + assertEquals(1, debits.get()); assertEquals(phase.equals("prepare") ? 0 : 1, refunds.get()); + assertEquals(List.of(phase.equals("ambiguous") ? "..." : phase.equals("prepare") ? "4" : "20"), balances); + verify(target, never()).addPoints(org.mockito.ArgumentMatchers.anyInt()); + assertTrue(f.pending.isEmpty(), "no retry may be scheduled for a refund outcome"); + } + + @Test void publicClaimedTransferRefundAcquisitionFailureRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("acquire"); } + @Test void publicClaimedTransferRefundSelectPreparationRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("select"); } + @Test void publicClaimedTransferRefundReadFailureRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("read"); } + @Test void publicClaimedTransferRefundStatementPreparationRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("prepare"); } + @Test void publicClaimedTransferRefundBindFailureRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("bind"); } + @Test void publicClaimedTransferRefundSelectCloseFailureRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("select-close"); } + @Test void publicClaimedTransferRefundConfirmedRestoresBalanceOnce() throws Exception { assertPublicClaimedTransferRefund("confirmed"); } + @Test void publicClaimedTransferRefundExecutionFailureStaysUnavailable() throws Exception { assertPublicClaimedTransferRefund("execute"); } + @Test void publicClaimedTransferRefundCommitFailureStaysUnavailable() throws Exception { assertPublicClaimedTransferRefund("commit"); } + @Test void publicClaimedTransferRefundPostWriteCloseFailureStaysUnavailable() throws Exception { assertPublicClaimedTransferRefund("close"); } + + @Test void publicMarkerFailureFallbackExecutionRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("execute", "marker-failure"); } + @Test void publicMarkerFailureFallbackCommitRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("commit", "marker-failure"); } + @Test void publicMarkerFailureFallbackConfirmedRefundRemainsUsable() throws Exception { assertPublicClaimedTransferRefund("confirmed", "marker-failure"); } + @Test void publicMarkerFailureFallbackUnstartedRefundRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("prepare", "marker-failure"); } + @Test void publicMarkerFailureFallbackRefusedRefundDoesNotMutate() throws Exception { assertPublicClaimedTransferRefund("refused", "marker-failure"); } + @Test void publicIndeterminateClaimExecutionRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("execute", "indeterminate"); } + @Test void publicIndeterminateClaimCommitRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("commit", "indeterminate"); } + @Test void publicIndeterminateClaimConfirmedRefundRemainsUsable() throws Exception { assertPublicClaimedTransferRefund("confirmed", "indeterminate"); } + @Test void publicIndeterminateClaimUnstartedRefundRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("prepare", "indeterminate"); } + @Test void publicIndeterminateClaimRefusedRefundDoesNotMutate() throws Exception { assertPublicClaimedTransferRefund("refused", "indeterminate"); } + @Test void publicIndeterminateClaimMarkerFallbackExecutionRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("execute", "indeterminate-marker-failure"); } + @Test void publicIndeterminateClaimMarkerFallbackCommitRemainsUnavailable() throws Exception { assertPublicClaimedTransferRefund("commit", "indeterminate-marker-failure"); } + @Test void publicIndeterminateClaimMarkerFallbackConfirmedRefundRemainsUsable() throws Exception { assertPublicClaimedTransferRefund("confirmed", "indeterminate-marker-failure"); } + @Test void publicIndeterminateClaimMarkerFallbackUnstartedRefundRemainsRecoverable() throws Exception { assertPublicClaimedTransferRefund("prepare", "indeterminate-marker-failure"); } + @Test void publicIndeterminateClaimMarkerFallbackRefusedRefundDoesNotMutate() throws Exception { assertPublicClaimedTransferRefund("refused", "indeterminate-marker-failure"); } + + private void assertPublicClaimedTransferRefund(String phase) throws Exception { + assertPublicClaimedTransferRefund(phase, "normal"); + } + + private void assertPublicClaimedTransferRefund(String phase, String branch) throws Exception { + Fixture f = mysqlPublicFixture(); + boolean indeterminateClaim = branch.startsWith("indeterminate"); + boolean failedMarker = branch.contains("marker-failure"); + when(f.plugin.getDataFolder()).thenReturn(transferFallbackData.toFile()); + var target = mock(VotingPluginUser.class); + var targetPlayer = mock(Player.class); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(target.getPointsPath()).thenReturn("Points"); when(target.getPlayer()).thenReturn(targetPlayer); + org.mockito.Mockito.doCallRealMethod().when(f.user).transferPointsWithResult(eq(target), eq(16), any()); + var state = new java.util.concurrent.atomic.AtomicReference("RESERVED"); + var storage = new java.util.concurrent.atomic.AtomicInteger(20); + var marked = new java.util.concurrent.atomic.AtomicBoolean(); + var debits = new java.util.concurrent.atomic.AtomicInteger(); + var refunds = new java.util.concurrent.atomic.AtomicInteger(); + var manager = f.plugin.getUserManager().getDataManager(); + doAnswer(call -> { + var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(storage.get())))); + manager.getUserDataCache().put(f.uuid, fresh); return null; + }).when(manager).cacheUser(f.uuid, null); + var connection = mock(java.sql.Connection.class); + when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenAnswer(call -> + marked.get() && phase.equals("acquire") ? null : connection); + doAnswer(call -> { + if (refunds.get() > 0 && phase.equals("commit")) throw new java.sql.SQLException("synthetic unconfirmed refund commit"); + if (state.get().equals("COMPENSATING")) marked.set(true); + return null; + }).when(connection).commit(); + doAnswer(call -> { + if (refunds.get() > 0 && phase.equals("close")) throw new java.sql.SQLException("synthetic post-write connection close"); + return null; + }).when(connection).close(); + when(connection.prepareStatement(anyString())).thenAnswer(call -> { + String sql = call.getArgument(0); + boolean refundRead = marked.get() && sql.contains("FOR UPDATE"); + boolean points = sql.startsWith("UPDATE `SyntheticPoints` SET"); + boolean refund = points && sql.contains(" + ?"); + if (refundRead && phase.equals("select")) throw new java.sql.SQLException("synthetic pre-write select preparation"); + if (refund && phase.equals("prepare")) throw new java.sql.SQLException("synthetic pre-write refund preparation"); + var statement = mock(java.sql.PreparedStatement.class); + var bound = new java.util.HashMap(); + doAnswer(bind -> { bound.put(bind.getArgument(0), bind.getArgument(1)); return null; }) + .when(statement).setString(org.mockito.ArgumentMatchers.anyInt(), any()); + if (refund && phase.equals("bind")) doThrow(new java.sql.SQLException("synthetic pre-write bind")) + .when(statement).setInt(1, 16); + if (refundRead && phase.equals("select-close")) doThrow(new java.sql.SQLException("synthetic pre-write select close")) + .when(statement).close(); + when(statement.executeQuery()).thenAnswer(query -> { + if (refundRead && phase.equals("read")) throw new java.sql.SQLException("synthetic pre-write read"); + var result = mock(java.sql.ResultSet.class); + if (sql.contains("FOR UPDATE")) { + if (indeterminateClaim && !marked.get() && sql.contains("hook_owner")) state.set("HOOK_STARTED"); + when(result.next()).thenReturn(true, false); + when(result.getString(1)).thenReturn(marked.get() && phase.equals("refused") ? "COMPLETED" : state.get()); + if (indeterminateClaim && !marked.get()) when(result.getString(2)).thenReturn("synthetic-other-claim"); + } + // Other journal lookups/recovery remain empty, including confirmation after a lost commit. + return result; + }); + when(statement.executeUpdate()).thenAnswer(update -> { + if (points) { + if (refund) { + refunds.incrementAndGet(); storage.set(20); + if (phase.equals("execute")) { + // Durable terminal evidence becomes visible only to the later guarded fallback, + // while this invocation still loses its execution acknowledgement. + state.set("REFUNDED"); + throw new java.sql.SQLException("synthetic unconfirmed refund execution"); + } + } else { debits.incrementAndGet(); storage.set(4); } + } else if (sql.startsWith("UPDATE") && bound.containsKey(1)) { + if (bound.get(1).equals("COMPENSATING")) { + marked.set(true); + if (failedMarker) throw new java.sql.SQLException("synthetic compensation marker acknowledgement lost"); + } + state.set(bound.get(1)); + } + return 1; + }); + return statement; + }); + // First global task admits the claim. After the real claim succeeds, both approval lanes reject. + var globals = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { + if (globals.incrementAndGet() == 2) throw new java.util.concurrent.RejectedExecutionException("synthetic approval fallback rejected"); + call.getArgument(1, Runnable.class).run(); return null; + }).when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class)); + doThrow(new java.util.concurrent.RejectedExecutionException("synthetic target entity retired")) + .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class), eq(targetPlayer)); + var results = new java.util.ArrayList(); + f.user.transferPointsWithResult(target, 16, results::add); + f.pending.removeFirst().run(); // reserve -> first global admission + assertEquals(1, f.pending.size()); + f.pending.removeFirst().run(); // real claim -> rejected entity/fallback -> compensation admission + if (!indeterminateClaim) { + assertEquals("HOOK_STARTED", state.get()); + assertEquals(1, f.pending.size()); + f.pending.removeFirst().run(); // real markCompensating -> refundHookStarted -> completion + } + assertTrue(marked.get(), "must reach the post-claim durable compensation path"); + assertEquals(List.of(com.bencodez.votingplugin.user.PointTransferResult.UNAVAILABLE), results); + assertEquals(1, debits.get()); + boolean attempted = List.of("confirmed", "execute", "commit", "close").contains(phase); + assertEquals(attempted ? 1 : 0, refunds.get()); + assertTrue(f.pending.isEmpty(), "no automatic transaction retry may be scheduled"); + if (attempted && !phase.equals("confirmed")) { + com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(f.plugin, f.uuid.toString(), "Points"); + assertEquals("...", f.request("points_nocache")); + f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true); + assertEquals("...", f.request("points_nocache")); + assertTrue(f.pending.isEmpty()); + } else { + f.request("points_nocache"); f.drain(); + assertEquals(phase.equals("confirmed") ? "20" : "4", f.request("points_nocache")); + } + + if (phase.equals("refused") || failedMarker && !phase.equals("confirmed")) { + assertTrue(java.nio.file.Files.isDirectory(transferFallbackData.resolve(".point-transfer-compensations")), + "the original local compensation proof must survive an unresolved fallback"); + } + verify(target, never()).addPoints(org.mockito.ArgumentMatchers.anyInt()); + verify(f.plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any()); + verify(f.plugin.getRewardHandler(), never()).giveRewardAsync(any(), any(), any(), any()); + } + + @Test void publicPurchaseNullConnectionRemainsRecoverableWithoutDebit() throws Exception { assertPublicPurchasePreparation("acquire"); } + @Test void publicPurchaseStatementPreparationRemainsRecoverableWithoutDebit() throws Exception { assertPublicPurchasePreparation("prepare"); } + + @Test void publicPurchaseReservePreparationHasNoDebitAndRemainsRecoverable() throws Exception { assertPublicPurchasePreparation("reserve-prepare"); } + @Test void publicPurchaseReserveBindHasNoDebitAndRemainsRecoverable() throws Exception { assertPublicPurchasePreparation("reserve-bind"); } + + private void assertPublicPurchasePreparation(String phase) throws Exception { + Fixture f = mysqlPublicFixture(); + var connection = mock(java.sql.Connection.class); + when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(phase.equals("acquire") ? null : connection); + var pointUpdates = new java.util.concurrent.atomic.AtomicInteger(); + when(connection.prepareStatement(anyString())).thenAnswer(call -> { + String sql = call.getArgument(0); + if (phase.equals("prepare") || (phase.equals("reserve-prepare") && sql.startsWith("INSERT INTO"))) + throw new java.sql.SQLException("synthetic preparation failed"); + var statement = mock(java.sql.PreparedStatement.class); + if (phase.equals("reserve-bind") && sql.startsWith("UPDATE `SyntheticPoints` SET")) + doThrow(new java.sql.SQLException("synthetic bind failure")).when(statement).setInt(1, 16); + when(statement.executeUpdate()).thenAnswer(update -> { if (sql.startsWith("UPDATE `SyntheticPoints` SET")) pointUpdates.incrementAndGet(); return 1; }); + return statement; + }); + var results = new java.util.ArrayList(); + startPublicPurchase(f, results::add); + f.pending.removeFirst().run(); + assertEquals(List.of(com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult.FAILED), results); + assertEquals("...", f.request("points")); + f.drain(); + assertEquals("4", f.request("points_nocache")); + assertEquals(0, pointUpdates.get(), "no points UPDATE may be attempted in preparation failures"); + verify(f.plugin.getRewardHandler(), never()).giveRewardAsync(any(), any(), any(), any()); + } + + @Test void publicPurchaseCompensationConfirmedRefundRestoresBalance() throws Exception { assertPublicCompensation("confirmed"); } + @Test void publicPurchaseCompensationGuardDoesNotRefundOrReward() throws Exception { assertPublicCompensation("guard"); } + @Test void publicPurchaseCompensationUnconfirmedRefundStaysUnavailable() throws Exception { assertPublicCompensation("ambiguous"); } + @Test void publicPurchaseCompensationPreparationFailureDoesNotInventUncertainty() throws Exception { assertPublicCompensation("prepare"); } + + @Test void publicLimitedPurchaseEpochReadFailureIsRecoverableBeforeRefundUpdate() throws Exception { assertPublicCompensation("epoch"); } + @Test void publicLimitedPurchaseConfirmedRefundKeepsEpochGuard() throws Exception { assertPublicCompensation("epoch-confirmed"); } + @Test void publicLimitedPurchaseUnconfirmedRefundStaysUnavailable() throws Exception { assertPublicCompensation("epoch-ambiguous"); } + + private void assertPublicCompensation(String phase) throws Exception { + Fixture f = mysqlPublicFixture(); + boolean limited = phase.startsWith("epoch"); + boolean ambiguous = phase.equals("ambiguous") || phase.equals("epoch-ambiguous"); + var storage = new java.util.concurrent.atomic.AtomicInteger(20); + var state = new java.util.concurrent.atomic.AtomicReference("PENDING"); + var refunds = new java.util.concurrent.atomic.AtomicInteger(); + var debits = new java.util.concurrent.atomic.AtomicInteger(); + var connection = mock(java.sql.Connection.class); + when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()).thenReturn(connection); + doAnswer(call -> { + if (ambiguous && refunds.get() > 0) throw new java.sql.SQLException("synthetic refund acknowledgement lost"); + return null; + }).when(connection).commit(); + var manager = f.plugin.getUserManager().getDataManager(); + doAnswer(call -> { + var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(storage.get())))); + manager.getUserDataCache().put(f.uuid, fresh); return null; + }).when(manager).cacheUser(f.uuid, null); + when(connection.prepareStatement(anyString())).thenAnswer(call -> { + String sql = call.getArgument(0); + if (phase.equals("epoch") && state.get().equals("COMPENSATING") && sql.startsWith("SELECT `epoch`")) + throw new java.sql.SQLException("synthetic epoch prerequisite failure"); + boolean pointUpdate = sql.startsWith("UPDATE `SyntheticPoints` SET"); + boolean refund = pointUpdate && sql.contains(" + ?"); + if (refund && phase.equals("prepare")) throw new java.sql.SQLException("synthetic refund preparation failure"); + var statement = mock(java.sql.PreparedStatement.class); + var bound = new java.util.HashMap(); + doAnswer(bind -> { bound.put(bind.getArgument(0), bind.getArgument(1)); return null; }).when(statement).setString(org.mockito.ArgumentMatchers.anyInt(), any()); + when(statement.executeUpdate()).thenAnswer(update -> { + if (pointUpdate) { + if (refund) { refunds.incrementAndGet(); storage.set(20); } + else { debits.incrementAndGet(); storage.set(4); } + } else if (sql.startsWith("UPDATE") && bound.containsKey(1)) state.set(bound.get(1)); + return 1; + }); + when(statement.executeQuery()).thenAnswer(query -> { + var result = mock(java.sql.ResultSet.class); + if (sql.contains("FOR UPDATE") || (ambiguous && sql.startsWith("SELECT"))) { + when(result.next()).thenReturn(true, false); + when(result.getString(1)).thenReturn(phase.equals("guard") ? "HOOK_STARTED" + : ambiguous && refunds.get() > 0 ? "COMPENSATING" : state.get()); + when(result.getString(2)).thenReturn(f.uuid.toString()); + when(result.getString(3)).thenReturn("Points"); + when(result.getInt(5)).thenReturn(16); + if (limited) { + when(result.getString(4)).thenReturn("VoteShopLimitsynthetic-public-item"); + when(result.getObject(8)).thenReturn(7L); + when(result.getLong(1)).thenReturn(7L); + } + when(result.wasNull()).thenReturn(!limited); + } + return result; + }); + return statement; + }); + var asyncAdmissions = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { + if (asyncAdmissions.incrementAndGet() == 2) throw new java.util.concurrent.RejectedExecutionException("synthetic claim scheduler stopped"); + call.getArgument(1, Runnable.class).run(); return null; + }).when(f.scheduler).runTaskAsynchronously(eq(f.plugin), any(Runnable.class)); + var results = new java.util.ArrayList(); + startPublicPurchase(f, results::add, limited ? 3 : 0); + f.pending.removeFirst().run(); // reserve, then rejected claim schedules compensation + assertEquals(1, f.pending.size()); + f.pending.removeFirst().run(); + assertEquals(List.of(com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult.FAILED), results); + assertEquals(1, debits.get()); + assertEquals(phase.equals("prepare") || phase.equals("guard") || phase.equals("epoch") ? 0 : 1, refunds.get()); + assertTrue(f.pending.isEmpty(), "compensation must not retry its mutation automatically"); + if (ambiguous) { + assertEquals("...", f.request("points_nocache"), "an unconfirmed refund cannot reopen numeric publication"); + f.placeholders.sharedPointRefillCompletion(f.uuid).accept(true); + assertEquals("...", f.request("points_nocache")); + } else { + f.request("points_nocache"); f.drain(); + assertEquals(phase.equals("confirmed") || phase.equals("epoch-confirmed") ? "20" : "4", f.request("points_nocache")); + } + verify(f.plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any()); + verify(f.plugin.getRewardHandler(), never()).giveRewardAsync(any(), any(), any(), any()); + } + + private void startPublicPurchase(Fixture f, java.util.function.Consumer completion) { + startPublicPurchase(f, completion, 0); + } + + private void startPublicPurchase(Fixture f, java.util.function.Consumer completion, int limit) { + var definition = mock(com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + var item = mock(com.bencodez.votingplugin.voteshop.shop.VoteShopItem.class); + when(item.getPermission()).thenReturn(""); when(item.getCost()).thenReturn(16); when(item.getLimit()).thenReturn(limit); + when(item.getIdentifier()).thenReturn("synthetic-public-item"); + new com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService(f.plugin, definition).purchase(f.player, f.user, item, completion); + } + + @Test void confirmedSetRetiresRenderedBalanceAndPublishesRefill() throws Exception { assertSetOutcome(1, false); } + @Test void zeroRowSetRetiresRenderedBalanceAndPublishesRefill() throws Exception { assertSetOutcome(0, false); } + @Test void ambiguousSetDoesNotPublishAsAConfirmedBalance() throws Exception { assertSetOutcome(0, true); } + + private void assertSetOutcome(int affectedRows, boolean ambiguous) throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.user.getPointsPath()).thenReturn("Points"); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + var table = f.plugin.getMysql(); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`"); + var connection = mock(java.sql.Connection.class); + var statement = mock(java.sql.PreparedStatement.class); + when(table.getMysql().getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + if (ambiguous) when(statement.executeUpdate()).thenThrow(new java.sql.SQLException("synthetic acknowledgement lost")); + else when(statement.executeUpdate()).thenReturn(affectedRows); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + boolean result = new com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess(f.plugin).setCommitted(f.user, 4); + assertEquals(!ambiguous && affectedRows == 1, result); + verify(statement, org.mockito.Mockito.times(1)).executeUpdate(); + if (ambiguous) assertEquals("...", f.request("points_nocache")); + else { + assertEquals("...", f.request("points")); + f.drain(); + assertEquals("4", f.request("points")); + } + } + + @Test void falseVoteShopReservationDoesNotRewardAndRetiresRecreatedStaleRender() throws Exception { assertFalseShopReservation(true); } + @Test void falseVoteShopReservationRecoversFromExplicitAuthoritativeRefill() throws Exception { assertFalseShopReservation(false); } + + private void assertFalseShopReservation(boolean requireImmediateInvalidation) throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.user.getPointsPath()).thenReturn("Points"); + when(f.user.isCached()).thenReturn(true); + var old = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + old.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + var caches = new java.util.concurrent.ConcurrentHashMap(); + caches.put(f.uuid, old); + when(f.user.getCache()).thenReturn(old); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(caches); + doAnswer(call -> { caches.remove(f.uuid); return null; }).when(manager).removeCache(f.uuid, null); + doAnswer(call -> { + var fresh = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + fresh.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + caches.put(f.uuid, fresh); + return null; + }).when(manager).cacheUser(f.uuid, null); + var table = f.plugin.getMysql(); + when(table.getTableName()).thenReturn("VotingPlugin_Users"); + when(table.qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`"); + var connection = mock(java.sql.Connection.class); + when(table.getMysql().getConnectionManager().getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenAnswer(call -> { + var statement = mock(java.sql.PreparedStatement.class); + if (call.getArgument(0).startsWith("UPDATE `VotingPlugin_Users` SET")) { + when(statement.executeUpdate()).thenAnswer(update -> { + // Recreate a stale read snapshot while the authoritative conditional debit rejects. + var recreated = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + recreated.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + caches.put(f.uuid, recreated); + return 0; + }); + } + return statement; + }); + when(f.scheduler.getFoliaLib()).thenReturn(null); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class), eq(f.player)); + var definition = mock(com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + var item = mock(com.bencodez.votingplugin.voteshop.shop.VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + when(item.getCost()).thenReturn(16); + when(item.getLimit()).thenReturn(0); + when(item.getIdentifier()).thenReturn("synthetic-item"); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + var result = new java.util.concurrent.atomic.AtomicReference(); + new com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService(f.plugin, definition) + .purchase(f.player, f.user, item, result::set); + assertEquals(1, f.pending.size()); + f.pending.removeFirst().run(); + assertEquals(com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult.NOT_ENOUGH_POINTS, result.get()); + verify(f.plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any()); + verify(f.plugin.getRewardHandler(), never()).giveRewardAsync(any(), any(), any(), any()); + if (requireImmediateInvalidation) assertEquals("...", f.request("points"), "a failed reservation must retire its stale rendered balance"); + com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(f.plugin, f.uuid.toString(), "Points"); + assertEquals("4", f.request("points_nocache")); + } private VotingPluginMain previousPlugin; @BeforeEach void rememberPlugin() { previousPlugin = VotingPluginMain.plugin; } @@ -127,6 +1304,277 @@ class ColdOfflinePlaceholderCacheTest { } } + @Test void reconciledDebitRetiresTheStaleRenderedBalance() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + assertEquals("20", f.request("points")); + f.placeholders.beginSharedPointDebit(f.uuid); + assertEquals("...", f.request("points")); + assertEquals("...", f.request("points_nocache")); + when(f.user.getPoints()).thenReturn(4); + f.placeholders.finishSharedPointDebit(f.uuid, true); + assertEquals("...", f.request("points")); + f.drain(); + assertEquals("4", f.request("points")); + assertEquals("4", f.request("points_nocache")); + } + + @Test void uncertainOrUnavailableSnapshotNeverPublishesOldPoints() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, false); + assertEquals("...", f.request("points")); + assertEquals("...", f.request("points_nocache")); + f.placeholders.onUpdate(f.user, true); + verify(f.user, never()).getPoints(); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true); + assertEquals("...", f.request("points"), "a later debit cannot resolve an uncertain earlier outcome"); + } + + @Test void confirmedDebitCanRecoverAfterTransientRefillFailure() { + assertDebitRefillRecovery(true); + } + + @Test void rejectedDebitCanRecoverAfterTransientRefillFailure() { + assertDebitRefillRecovery(true); + } + + @Test void unresolvedTransactionIsNotClearedByVerifiedRefill() { assertDebitRefillRecovery(false); } + + private static void assertDebitRefillRecovery(boolean known) { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, known, false); + assertEquals("...", f.request("points_nocache")); + when(f.user.getPoints()).thenReturn(4); + com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(f.plugin, f.uuid.toString(), "Points"); + assertEquals(known ? "4" : "...", f.request("points_nocache")); + } + + @Test void workerPointReconciliationRetiresSiblingMutationRenderedCache() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + when(f.user.getPoints()).thenReturn(4); + com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(f.plugin, f.uuid.toString(), "Points"); + assertEquals("...", f.request("points")); + f.drain(); + assertEquals("4", f.request("points")); + } + + @Test void asyncPointReconciliationRetiresRenderedBalanceBeforeRefill() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefresh(f.plugin, f.uuid.toString(), "Points"); + assertEquals("...", f.request("points")); + assertEquals("...", f.request("points_nocache")); + verify(f.user, never()).getPoints(); + } + + @Test void mysqlNoCachePlaceholderUsesPublishedSnapshotWithoutStorageGetter() { + Fixture f = new Fixture(PlaceholderCacheLevel.NONE); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.user.getPointsPath()).thenReturn("Points"); + when(f.user.getUserDataFetchMode()).thenReturn(com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_CACHE); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doThrow(new AssertionError("platform placeholder entered storage-backed getter")).when(f.user).getPoints(); + assertEquals("4", f.request("points_nocache")); + } + + @Test void coldMysqlNoCacheReadQueuesWorkerWithoutEnteringStorage() { + Fixture f = new Fixture(PlaceholderCacheLevel.NONE); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.user.getPointsPath()).thenReturn("Points"); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>()); + doThrow(new AssertionError("cold platform read entered storage")).when(f.user).getPoints(); + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size()); + verify(f.plugin.getUserManager().getDataManager(), never()).cacheUser(any(), any()); + } + + @Test void rejectedBulkAdmissionRetiresStaleRenderedPoints() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.user.getPointsPath()).thenReturn("Points"); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + doThrow(new java.util.concurrent.RejectedExecutionException("full")).when(f.timer).execute(any(Runnable.class)); + VotingPluginUser.removePointsStorageAware(f.plugin, List.of(f.user), new java.util.IdentityHashMap<>(), + 16, "synthetic-batch", (user, success) -> { }); + assertEquals("...", f.request("points")); + } + + @Test void rejectedDuplicateBulkAdmissionsEachRetireAndPermitWorkerRecovery() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + doThrow(new java.util.concurrent.RejectedExecutionException("full")) + .doAnswer(call -> { f.pending.addLast(call.getArgument(0)); return null; }) + .when(f.timer).execute(any(Runnable.class)); + VotingPluginUser.removePointsStorageAware(f.plugin, List.of(f.user, f.user), new java.util.IdentityHashMap<>(), + 16, "synthetic-duplicate-batch", (user, success) -> { }); + assertEquals("...", f.request("points_nocache")); + assertEquals(1, f.pending.size(), "all rejected occurrences must retire before coalesced recovery"); + f.pending.removeFirst().run(); + assertEquals("4", f.request("points_nocache")); + } + + @Test void acceptedBulkAdmissionClosesRenderingUntilItsWorkerSettles() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + when(f.plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + VotingPluginUser.removePointsStorageAware(f.plugin, List.of(f.user), new java.util.IdentityHashMap<>(), + 16, "synthetic-accepted-batch", (user, success) -> { }); + assertEquals(1, f.pending.size()); + assertEquals("...", f.request("points")); + assertEquals(1, f.pending.size(), "an owned mutation must not admit a separate recovery"); + } + + @Test void indeterminateAdditionCannotPublishFromAnUnrelatedSuccessfulRefill() throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(f.user.getPointsPath()).thenReturn("Points"); + when(f.plugin.getMysql().getTableName()).thenReturn("VotingPlugin_Users"); + when(f.plugin.getMysql().getMysql().getConnectionManager().getConnection()) + .thenReturn((java.sql.Connection) null); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(null, f.uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(20)))); + var manager = f.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(f.uuid, cache))); + doAnswer(call -> { + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))); + return null; + }).when(manager).cacheUser(f.uuid, null); + var result = new com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess(f.plugin) + .addCommitted(f.user, 16, "synthetic-addition"); + assertEquals(com.bencodez.votingplugin.tests.user.SharedMysqlPointMutatorAccess.MutationOutcome.INDETERMINATE, result.outcome()); + assertEquals("...", f.request("points_nocache")); + } + + @Test void stoppedPlaceholderWorkerDoesNotExposeRetiredBalanceOrThrow() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true); + doThrow(new java.util.concurrent.RejectedExecutionException("stopped")) + .when(f.timer).execute(any(Runnable.class)); + assertEquals("...", f.request("points")); + verify(f.user, never()).getPoints(); + } + + @Test void overlappingDebitsRemainUnavailableUntilBothHaveSettled() { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + f.placeholder("points").getCache().get("points").put(f.uuid, "20"); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true); + assertEquals("...", f.request("points")); + f.placeholders.finishSharedPointDebit(f.uuid, false); + assertEquals("...", f.request("points")); + verify(f.user, never()).getPoints(); + } + + @Test void oldRenderCannotPublishAfterNewerDebit() throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + var entered = new java.util.concurrent.CountDownLatch(1); + var resume = new java.util.concurrent.CountDownLatch(1); + when(f.user.getPoints()).thenAnswer(call -> { + entered.countDown(); + assertTrue(resume.await(5, TimeUnit.SECONDS)); + return 20; + }); + try (var worker = Executors.newSingleThreadExecutor()) { + var old = worker.submit(() -> f.placeholders.onUpdate(f.user, true)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + f.placeholders.beginSharedPointDebit(f.uuid); + f.placeholders.finishSharedPointDebit(f.uuid, true); + resume.countDown(); + old.get(5, TimeUnit.SECONDS); + } finally { resume.countDown(); } + assertFalse(f.placeholder("points").getCache().get("points").containsKey(f.uuid)); + when(f.user.getPoints()).thenReturn(4); + assertEquals("...", f.request("points")); + f.drain(); + assertEquals("4", f.request("points")); + } + + @Test void anotherPlayersDebitDoesNotDiscardThisPlayersRender() throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + var entered = new java.util.concurrent.CountDownLatch(1); + var resume = new java.util.concurrent.CountDownLatch(1); + when(f.user.getPoints()).thenAnswer(call -> { + entered.countDown(); + assertTrue(resume.await(5, TimeUnit.SECONDS)); + return 17; + }); + try (var worker = Executors.newSingleThreadExecutor()) { + var render = worker.submit(() -> f.placeholders.onUpdate(f.user, true)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + UUID other = UUID.randomUUID(); + f.placeholders.beginSharedPointDebit(other); + f.placeholders.finishSharedPointDebit(other, true); + resume.countDown(); + render.get(5, TimeUnit.SECONDS); + } finally { resume.countDown(); } + assertEquals("17", f.request("points")); + } + + @Test void renderStartedBeforeReloadCannotPublishAfterReload() throws Exception { + Fixture f = new Fixture(PlaceholderCacheLevel.AUTOALL, "points"); + var entered = new java.util.concurrent.CountDownLatch(1); + var resume = new java.util.concurrent.CountDownLatch(1); + when(f.user.getPoints()).thenAnswer(call -> { + entered.countDown(); + assertTrue(resume.await(5, TimeUnit.SECONDS)); + return 20; + }); + try (var worker = Executors.newSingleThreadExecutor()) { + var render = worker.submit(() -> f.placeholders.onUpdate(f.user, true)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + f.placeholders.reload(); + } + resume.countDown(); + render.get(5, TimeUnit.SECONDS); + } finally { resume.countDown(); } + assertFalse(f.placeholder("points").getCache().get("points").containsKey(f.uuid)); + } + @Test void specificAllDoesNotAutomaticallyAdmitAnUnconfiguredIdentifier() { Fixture f = new Fixture(PlaceholderCacheLevel.SPECIFICALL); assertEquals("..", f.request("points")); @@ -339,6 +1787,7 @@ private static final class Fixture { when(plugin.getVotingPluginUserManager().getVotingPluginUser(offline)).thenReturn(user); when(user.getJavaUUID()).thenReturn(uuid); when(user.getUUID()).thenReturn(uuid.toString()); + when(user.getPointsPath()).thenReturn("Points"); when(user.getTotal(TopVoter.Monthly)).thenReturn(42); when(user.getPoints()).thenReturn(17); when(user.getTime(site)).thenReturn(1234L); @@ -346,6 +1795,7 @@ private static final class Fixture { doThrow(new AssertionError("worker read live player state")).when(user).isOnline(); doThrow(new AssertionError("worker requested live player")).when(user).getPlayer(); placeholders = new PlaceHolders(plugin); + when(plugin.getPlaceholders()).thenReturn(placeholders); VotingPluginMain.plugin = plugin; placeholders.load(); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointSchedulingTest.java index d3206a359f..1ec7e0f17f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointSchedulingTest.java @@ -58,6 +58,7 @@ import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.simpleapi.sql.data.DataValueInt; import com.bencodez.simpleapi.sql.mysql.ConnectionManager; import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.simpleapi.folialib.FoliaLib; @@ -108,6 +109,7 @@ void ordinaryBulkPointArithmeticUsesAuthoritativeUserReads() { void sharedBulkPointMutationsUseOnePersistenceSubmission() throws Exception { PointFixture fixture = pointFixture(); VotingPluginUser second = mock(VotingPluginUser.class); + when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); java.util.List users = java.util.List.of(fixture.user, second); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { @@ -137,6 +139,7 @@ void capturedPlayerSetPointsDoesNotLookupPlayerOnStorageCaller() throws Exceptio void rejectedSharedBulkMutationCompletesEveryUserAsFailed() throws Exception { PointFixture fixture = pointFixture(); VotingPluginUser second = mock(VotingPluginUser.class); + when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); Player secondPlayer = mock(Player.class); when(second.getPlayer()).thenReturn(secondPlayer); java.util.List results = new java.util.ArrayList<>(); @@ -161,6 +164,7 @@ void rejectedSharedBulkMutationCompletesEveryUserAsFailed() throws Exception { void sharedBulkMutationCapturesPlayersBeforePersistenceWork() throws Exception { PointFixture fixture = pointFixture(); VotingPluginUser second = mock(VotingPluginUser.class); + when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); Player secondPlayer = mock(Player.class); when(second.getPlayer()).thenReturn(secondPlayer); PluginManager pluginManager = mock(PluginManager.class); @@ -1058,6 +1062,21 @@ void rejectedSingleUserPointCallbacksCompleteAsFailures() throws Exception { verify(fixture.sql.getConnectionManager(), never()).getConnection(); } + @Test + void rejectedDebitCallbackDoesNotRunAfterDisable() throws Exception { + PointFixture fixture = pointFixture(); + AtomicReference result = new AtomicReference<>(); + doThrow(new RejectedExecutionException("stopped")).when(fixture.persistence).execute(any(Runnable.class)); + fixture.user.removePoints(5, result::set); + verify(fixture.plugin.getPlaceholders()).finishSharedPointDebit(fixture.user.getJavaUUID(), true, false); + ArgumentCaptor entity = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), entity.capture(), eq(fixture.player)); + when(fixture.plugin.isEnabled()).thenReturn(false); + entity.getValue().run(); + assertEquals(null, result.get()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + @Test void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { PointFixture fixture = pointFixture(); @@ -1361,6 +1380,42 @@ void sharedRemoveConsumerRunsJdbcOnPersistenceExecutorAndReportsOnEntity() throw verify(fixture.sql.getConnectionManager(), org.mockito.Mockito.atLeastOnce()).getConnection(); } + @Test + void rejectedDebitRefreshesPublishedSnapshotBeforeCompletionAndDoesNotLogDatabaseFailure() throws Exception { + PointFixture fixture = pointFixture(); + var uuid = fixture.user.getJavaUUID(); + var cache = new UserDataCache(null, uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new DataValueInt(20)))); + var manager = fixture.plugin.getUserManager().getDataManager(); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, cache))); + doAnswer(call -> { + assertFalse(cache.hasPublishedStorageSnapshot()); + cache.updateSharedSnapshot(new java.util.HashMap<>(java.util.Map.of("Points", new DataValueInt(4))), + cache.getSharedSnapshotVersion()); + return null; + }).when(manager).cacheUser(uuid, null); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(lookup.executeQuery()).thenReturn(missing); + when(fixture.connection.prepareStatement(anyString())).thenAnswer(call -> + call.getArgument(0).startsWith("SELECT `player_uuid`") ? lookup : fixture.statement); + AtomicReference result = new AtomicReference<>(); + fixture.user.removePoints(16, result::set); + verify(fixture.plugin.getPlaceholders()).beginSharedPointDebit(uuid); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + ArgumentCaptor worker = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(worker.capture()); + worker.getValue().run(); + assertEquals(4, cache.snapshotIfPublished().get("Points").getInt()); + verify(fixture.plugin.getPlaceholders(), org.mockito.Mockito.times(2)).finishSharedPointDebit(uuid, true, true); + verify(fixture.plugin.getLogger(), never()).severe(anyString()); + assertEquals(null, result.get()); + ArgumentCaptor entity = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), entity.capture(), eq(fixture.player)); + entity.getValue().run(); + assertEquals(Boolean.FALSE, result.get()); + } + @Test void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() throws Exception { TransferSchedulingFixture fixture = transferSchedulingFixture(); @@ -1985,6 +2040,7 @@ private static void runTransferApprovalGate(ScheduledExecutorService persistence private static PointFixture pointFixture() throws Exception { PointFixture fixture = new PointFixture(); fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(fixture.plugin.isEnabled()).thenReturn(true); fixture.persistence = mock(ScheduledExecutorService.class); fixture.scheduler = mock(BukkitScheduler.class); fixture.entityScheduler = configureEntityScheduler(fixture.plugin, fixture.scheduler); @@ -2010,6 +2066,8 @@ private static PointFixture pointFixture() throws Exception { pluginField.setAccessible(true); ReflectiveAccess.set(pluginField, fixture.user, fixture.plugin); doReturn("00000000-0000-0000-0000-000000000001").when(fixture.user).getUUID(); + doReturn(java.util.UUID.fromString("00000000-0000-0000-0000-000000000001")) + .when(fixture.user).getJavaUUID(); doReturn("Points").when(fixture.user).getPointsPath(); doReturn(fixture.player).when(fixture.user).getPlayer(); doReturn(false).when(fixture.user).isCached(); diff --git a/docs/point-shop-integrations.md b/docs/point-shop-integrations.md new file mode 100644 index 0000000000..81afebfcbb --- /dev/null +++ b/docs/point-shop-integrations.md @@ -0,0 +1,19 @@ +# Point balances and external shops + +`%votingplugin_points_nocache%` bypasses VotingPlugin's rendered-placeholder +cache. It does not guarantee a new MySQL read or reserve points, and another +server may change a shared balance immediately after the value is read. + +An external shop that grants a reward before a separate point-removal command +has confirmed payment cannot make the two actions atomic. Concurrent clicks or +requests on different servers can grant rewards even when later debits fail. +Use VotingPlugin's transaction-backed VoteShop when confirmed reservation is +required, or use an integration that grants rewards only after an authoritative +payment succeeds. + +Point placeholders fail closed while a point mutation's outcome is uncertain. +They can remain unavailable even after a reload, a later successful operation or +a snapshot refresh: those events do not confirm the original mutation. The +current recovery interface does not provide the operation-bound terminal +evidence needed to clear that condition automatically. Do not repeat an uncertain +debit as a cache workaround.