mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-04 09:02:00 -05:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8fa948ecd | ||
|
|
4b83cc08bd | ||
|
|
b212f5cabb | ||
|
|
e09fa0f039 | ||
|
|
79bbdb1395 | ||
|
|
51f0b5e4a9 | ||
|
|
eb9fc8618b | ||
|
|
86735060bc | ||
|
|
f2eb2152fc | ||
|
|
d2bc2feb8e | ||
|
|
b34229ced0 | ||
|
|
9f56166708 | ||
|
|
7746fc52dc | ||
|
|
ee33a291a6 | ||
|
|
66d1c858c7 | ||
|
|
2e8cb4bd5d | ||
|
|
2a1e4e19ce | ||
|
|
73947ee4cc | ||
|
|
dc127ce300 | ||
|
|
1f27359df7 | ||
|
|
c80e460640 | ||
|
|
c3248a0a85 | ||
|
|
3db2ba2d2a | ||
|
|
bfded64b30 | ||
|
|
8c098ede6d | ||
|
|
6ff81d7919 | ||
|
|
0554c2b954 | ||
|
|
cfd4cf5cf0 | ||
|
|
84a8724ce2 | ||
|
|
032c3834f3 | ||
|
|
bc33141f43 | ||
|
|
0c83e75a16 | ||
|
|
5081f95e63 | ||
|
|
04a83ad54c | ||
|
|
8d3b35fa4f | ||
|
|
d3f7c43550 | ||
|
|
cd531e323b | ||
|
|
e0c4a1c92f | ||
|
|
3b4208c595 | ||
|
|
d7b5a89955 | ||
|
|
cf8def4c8a | ||
|
|
a9b4af5fc5 | ||
|
|
a89bb5594e | ||
|
|
4409f8fdd9 | ||
|
|
81993afd60 |
No files matched your search
+11
-4
@@ -271,7 +271,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
<version>3.6.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-test</id>
|
||||
@@ -316,6 +316,13 @@
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>tcoded-releases</id>
|
||||
<url>https://repo.tcoded.com/releases</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>velocity</id>
|
||||
<url>https://nexus.velocitypowered.com/repository/maven-public/</url>
|
||||
@@ -401,7 +408,7 @@
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
<version>2.6</version>
|
||||
<version>2.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.spigotmc</groupId>
|
||||
@@ -424,7 +431,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.14.0</version>
|
||||
<version>2.15.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -475,7 +482,7 @@
|
||||
<dependency>
|
||||
<groupId>redis.clients</groupId>
|
||||
<artifactId>jedis</artifactId>
|
||||
<version>8.0.1</version>
|
||||
<version>8.0.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.clip</groupId>
|
||||
|
||||
@@ -287,6 +287,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Getter
|
||||
private TopVoterState topVoterState;
|
||||
private volatile com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter topVoterDisplayFilter;
|
||||
|
||||
|
||||
@Getter
|
||||
@@ -338,6 +339,9 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
private volatile VotingPluginBackgroundTask backgroundTask;
|
||||
private volatile AtomicBoolean basicBungeeUpdateRunning;
|
||||
private final AtomicBoolean unloadInProgress = new AtomicBoolean();
|
||||
private Set<String> completedUnloadSteps;
|
||||
private java.util.concurrent.CompletionStage<Void> signShutdownPublication;
|
||||
|
||||
private synchronized AtomicBoolean basicBungeeUpdateAdmission() {
|
||||
if (basicBungeeUpdateRunning == null) basicBungeeUpdateRunning = new AtomicBoolean();
|
||||
@@ -526,6 +530,84 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
return topVoterState.getLastMonthTopVoters();
|
||||
}
|
||||
|
||||
/** Display-only snapshots; raw getters remain the award/accounting contract. */
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> getTopVoterDisplay(TopVoter top) {
|
||||
return projectTopVoterDisplay(getTopVoter(top));
|
||||
}
|
||||
|
||||
public java.util.concurrent.CompletionStage<LinkedHashMap<TopVoterPlayer, Integer>> getTopVoterDisplayAsync(TopVoter top) {
|
||||
var filter = topVoterDisplayFilter;
|
||||
var raw = new LinkedHashMap<>(getTopVoter(top));
|
||||
return filter == null || !isEnabled() ? java.util.concurrent.CompletableFuture.completedFuture(raw)
|
||||
: filter.projectAsync(raw, configFile != null && configFile.isHideTopVoterIfNotPlayedBefore());
|
||||
}
|
||||
|
||||
private final java.util.concurrent.atomic.AtomicLong topVoterDisplayGeneration = new java.util.concurrent.atomic.AtomicLong();
|
||||
|
||||
public long getTopVoterDisplayGeneration() { return topVoterDisplayGeneration.get(); }
|
||||
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> getLastMonthTopVoterDisplay() {
|
||||
return projectTopVoterDisplay(getLastMonthTopVoter());
|
||||
}
|
||||
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> getPreviousMonthTopVoterDisplay(YearMonth month) {
|
||||
return projectTopVoterDisplay(getPreviousMonthsTopVoters().getOrDefault(month, new LinkedHashMap<>()));
|
||||
}
|
||||
|
||||
private LinkedHashMap<TopVoterPlayer, Integer> projectTopVoterDisplay(LinkedHashMap<TopVoterPlayer, Integer> raw) {
|
||||
var filter = topVoterDisplayFilter;
|
||||
return filter == null || !isEnabled() ? new LinkedHashMap<>(raw)
|
||||
: filter.project(raw, configFile != null && configFile.isHideTopVoterIfNotPlayedBefore());
|
||||
}
|
||||
|
||||
public void initializeTopVoterDisplayFilter() {
|
||||
retireTopVoterDisplayFilter();
|
||||
boolean onlineMode = getOptions().isOnlineMode();
|
||||
var filter = new com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter(row ->
|
||||
com.bencodez.votingplugin.topvoter.TopVoterPresenceIdentity
|
||||
.identities(row, onlineMode, UuidLookup.getInstance()::getCachedUUID),
|
||||
(row, aliases) -> checkTopVoterPresence(aliases, onlineMode), System::nanoTime,
|
||||
this::topVoterDisplayRankingRows);
|
||||
registerEvents(filter);
|
||||
topVoterDisplayFilter = filter;
|
||||
}
|
||||
|
||||
/** Cache capacity follows already loaded rankings, including disjoint historical identities. */
|
||||
private long topVoterDisplayRankingRows() {
|
||||
var state = topVoterState;
|
||||
if (state == null) return 0L;
|
||||
long rows = state.getLastMonthTopVoters().size();
|
||||
for (var board : state.getTopVoters().values()) if (board != null) rows += board.size();
|
||||
for (var board : state.getPreviousMonthsTopVoters().values()) if (board != null) rows += board.size();
|
||||
return rows;
|
||||
}
|
||||
|
||||
private java.util.concurrent.CompletionStage<com.bencodez.advancedcore.api.user.presence.PlayerPresence>
|
||||
checkTopVoterPresence(java.util.List<java.util.UUID> aliases, boolean onlineMode) {
|
||||
boolean completeOnlineHistory = onlineMode || aliases.stream().anyMatch(alias ->
|
||||
alias != null && alias.version() == 4 && alias.variant() == 2);
|
||||
if (!completeOnlineHistory) return checkPlayerPresenceIdentitiesAsync(aliases);
|
||||
try {
|
||||
return checkPlayerPresenceIdentitiesAsync(aliases, true);
|
||||
} catch (NoSuchMethodError | AbstractMethodError unsupported) {
|
||||
// Older SDKs can retain a positive but cannot certify the wider absence scope.
|
||||
return checkPlayerPresenceIdentitiesAsync(aliases).thenApply(evidence ->
|
||||
evidence == com.bencodez.advancedcore.api.user.presence.PlayerPresence.NEVER_JOINED
|
||||
? com.bencodez.advancedcore.api.user.presence.PlayerPresence.UNKNOWN : evidence);
|
||||
}
|
||||
}
|
||||
|
||||
private void retireTopVoterDisplayFilter() {
|
||||
topVoterDisplayGeneration.incrementAndGet();
|
||||
if (discordHandler != null) discordHandler.retireLeaderboardPublications();
|
||||
var filter = topVoterDisplayFilter;
|
||||
topVoterDisplayFilter = null;
|
||||
if (filter != null) {
|
||||
filter.close();
|
||||
org.bukkit.event.HandlerList.unregisterAll(filter);
|
||||
}
|
||||
}
|
||||
|
||||
public LinkedHashMap<YearMonth, LinkedHashMap<TopVoterPlayer, Integer>> getPreviousMonthsTopVoters() {
|
||||
return topVoterState.getPreviousMonthsTopVoters();
|
||||
}
|
||||
@@ -715,6 +797,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onPostLoad() {
|
||||
serverData.refreshVotePartyDisplay();
|
||||
ensureCommunicationSecret();
|
||||
// auto conversion for Shop.yml
|
||||
if (plugin.getShopFile().isJustCreated()) {
|
||||
@@ -802,6 +885,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
});
|
||||
|
||||
topVoterState = new TopVoterState();
|
||||
initializeTopVoterDisplayFilter();
|
||||
topVoterHandler = new TopVoterHandler(this);
|
||||
|
||||
new AdminGUI(this).loadHook();
|
||||
@@ -1931,9 +2015,95 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
configFile.saveData();
|
||||
}
|
||||
|
||||
@Getter
|
||||
private volatile com.bencodez.votingplugin.util.ServerDataPublicationFence serverDataPublicationFence;
|
||||
|
||||
private volatile com.bencodez.votingplugin.data.GenerationFencedServerDataFile fencedServerDataFile;
|
||||
|
||||
private java.util.concurrent.CompletableFuture<Void> serverDataFinalPublication;
|
||||
private boolean serverDataFinalPublicationReserved;
|
||||
private boolean serverDataFinalStageAttached;
|
||||
private volatile boolean serverDataEnableCleanupStarted;
|
||||
private volatile boolean serverDataEnableAdmissionRejected;
|
||||
|
||||
@Override
|
||||
public com.bencodez.advancedcore.data.ServerData getServerDataFile() {
|
||||
var file = super.getServerDataFile();
|
||||
var current = fencedServerDataFile;
|
||||
if (current != null && current.wraps(file)) {
|
||||
// The SDK replaces its file after pre-load. A failed new generation has
|
||||
// no file to finalize; never expose the predecessor as its current owner.
|
||||
return current.belongsTo(serverDataPublicationFence) ? current : null;
|
||||
}
|
||||
synchronized (this) {
|
||||
file = super.getServerDataFile();
|
||||
var owner = serverDataPublicationFence;
|
||||
if (file == null || owner == null) return file;
|
||||
current = fencedServerDataFile;
|
||||
if (current != null && current.wraps(file) && !current.belongsTo(owner)) return null;
|
||||
if (current == null || !current.wraps(file)) {
|
||||
current = new com.bencodez.votingplugin.data.GenerationFencedServerDataFile(this, file, owner);
|
||||
fencedServerDataFile = current;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture the existing AdvancedCore executors which can still publish server-data state. */
|
||||
public ScheduledExecutorService[] getServerDataWriterExecutors() {
|
||||
var checker = getTimeChecker();
|
||||
return new ScheduledExecutorService[] { getTimer(), getLoginTimer(), getInventoryTimer(),
|
||||
checker == null ? null : checker.getTimer() };
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
synchronized (this) {
|
||||
// Bind a retained SDK file before changing owner. AdvancedCore replaces its
|
||||
// private file only later in loadHook(), after onPreLoad().
|
||||
if (serverDataPublicationFence != null) getServerDataFile();
|
||||
serverDataPublicationFence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(
|
||||
getDataFolder().toPath().resolve("ServerData.yml"));
|
||||
try { serverDataPublicationFence.retainForProcessExit(); }
|
||||
catch (RuntimeException | Error registrationFailure) {
|
||||
serverDataEnableAdmissionRejected = true;
|
||||
try { serverDataPublicationFence.retire(); }
|
||||
catch (RuntimeException | Error cleanupFailure) {
|
||||
if (cleanupFailure != registrationFailure) registrationFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw registrationFailure;
|
||||
}
|
||||
serverDataFinalPublication = new java.util.concurrent.CompletableFuture<>();
|
||||
serverDataFinalPublicationReserved = false;
|
||||
serverDataFinalStageAttached = false;
|
||||
serverDataEnableCleanupStarted = false;
|
||||
serverDataEnableAdmissionRejected = false;
|
||||
}
|
||||
// Failed startup can already own SDK writers; disable must retire them before replacement.
|
||||
try { super.onEnable(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
// SDK admission can fail before its startup-cleanup try/catch. Such an
|
||||
// attempt admitted no new writers and must not strand an empty plugin owner.
|
||||
// An admitted startup failure already entered our onDisable; preserve its drain.
|
||||
if (!serverDataEnableCleanupStarted) {
|
||||
serverDataEnableAdmissionRejected = true;
|
||||
try {
|
||||
serverDataPublicationFence.retire();
|
||||
serverDataFinalPublication.complete(null);
|
||||
} catch (RuntimeException | Error cleanupFailure) {
|
||||
if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPreLoad() {
|
||||
plugin = this;
|
||||
unloadInProgress.set(false);
|
||||
completedUnloadSteps = new java.util.HashSet<>();
|
||||
signShutdownPublication = null;
|
||||
|
||||
setupFiles();
|
||||
if ("SQLITE".equalsIgnoreCase(configFile.getData().getString("DataStorage", "SQLITE"))) {
|
||||
@@ -1975,15 +2145,68 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
stopAutoCreateVoteSitesSetupReminder();
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
|
||||
// The rejected enable owns no SDK shutdown stage to complete a new reservation.
|
||||
if (serverDataEnableAdmissionRejected) return;
|
||||
runShutdownStep("server-data process drain", this::beginServerDataProcessDrain);
|
||||
serverDataEnableCleanupStarted = true;
|
||||
runShutdownStep("Discord leaderboards", () -> {
|
||||
if (discordHandler != null) discordHandler.close();
|
||||
});
|
||||
runShutdownStep("setup reminder", this::stopAutoCreateVoteSitesSetupReminder);
|
||||
runShutdownStep("server-data writer retirement", () -> {
|
||||
if (serverDataPublicationFence != null) {
|
||||
retainServerDataFinalPublication();
|
||||
var coreWriters = getServerDataWriterExecutors();
|
||||
var writers = java.util.Arrays.copyOf(coreWriters, coreWriters.length + 1);
|
||||
writers[coreWriters.length] = voteTimer;
|
||||
serverDataPublicationFence.retainUntilTermination(writers);
|
||||
}
|
||||
});
|
||||
try {
|
||||
shutdownVoteReminders();
|
||||
runShutdownStep("offline vote handoffs", () -> {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
|
||||
});
|
||||
} finally {
|
||||
super.onDisable();
|
||||
try { shutdownVoteReminders(); }
|
||||
finally {
|
||||
try { super.onDisable(); }
|
||||
finally { runShutdownStep("server-data publication owner", () -> {
|
||||
if (serverDataPublicationFence != null) serverDataPublicationFence.retire();
|
||||
}); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserve before SDK shutdown can enqueue a final file publication. No YAML lock or wait. */
|
||||
private void retainServerDataFinalPublication() {
|
||||
synchronized (this) {
|
||||
if (serverDataFinalPublicationReserved) return;
|
||||
if (serverDataFinalPublication == null)
|
||||
serverDataFinalPublication = new java.util.concurrent.CompletableFuture<>();
|
||||
serverDataPublicationFence.retainUntilSuccessfulPublication(serverDataFinalPublication);
|
||||
serverDataFinalPublicationReserved = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShutdownComplete(java.util.concurrent.CompletionStage<Void> finalFilesStage) {
|
||||
java.util.concurrent.CompletableFuture<Void> publication;
|
||||
synchronized (this) {
|
||||
publication = serverDataFinalStageAttached ? null : serverDataFinalPublication;
|
||||
serverDataFinalStageAttached = true;
|
||||
}
|
||||
try {
|
||||
if (publication != null) {
|
||||
try {
|
||||
java.util.Objects.requireNonNull(finalFilesStage).whenComplete((ignored, failure) -> {
|
||||
if (failure == null) publication.complete(null);
|
||||
else publication.completeExceptionally(failure);
|
||||
});
|
||||
} catch (Throwable failure) { publication.completeExceptionally(failure); }
|
||||
}
|
||||
} finally { super.onShutdownComplete(finalFilesStage); }
|
||||
}
|
||||
|
||||
private Settings autoCreateVoteSitesReminderSettings() {
|
||||
return new Settings(true, configFile.isAutoCreateVoteSites(),
|
||||
AutoCreateVoteSitesReminderState.DEFAULT_THRESHOLD_MILLIS,
|
||||
@@ -2011,50 +2234,107 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onUnLoad() {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.close();
|
||||
stopAutoCreateVoteSitesSetupReminder();
|
||||
placeholderPlayerPresence.clear();
|
||||
stopBackendHostedControlLifecycle();
|
||||
stopBackendControlConnectorLifecycle();
|
||||
if (getBackendProxyHandler() != null) {
|
||||
try {
|
||||
getBackendProxyHandler().closeForShutdown();
|
||||
} catch (Exception e) {
|
||||
debug(e);
|
||||
}
|
||||
runShutdownStep("server-data process drain", this::beginServerDataProcessDrain);
|
||||
if (!unloadInProgress.compareAndSet(false, true)) {
|
||||
runShutdownStep("time queue retry", () -> {
|
||||
if (timeQueueHandler != null) timeQueueHandler.saveForShutdown();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (backendOrderedVoteOverflowQueue != null) {
|
||||
backendOrderedVoteOverflowQueue.close();
|
||||
backendOrderedVoteOverflowQueue = null;
|
||||
}
|
||||
if (webhookManager != null) {
|
||||
webhookManager.shutdown();
|
||||
}
|
||||
voteTimer.shutdown();
|
||||
try {
|
||||
voteTimer.awaitTermination(1, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
voteTimer.shutdownNow();
|
||||
if (votifierVoteOverflowQueue != null) {
|
||||
votifierVoteOverflowQueue.close();
|
||||
votifierVoteOverflowQueue = null;
|
||||
}
|
||||
if (timeQueueHandler != null) {
|
||||
timeQueueHandler.save();
|
||||
}
|
||||
if (completedUnloadSteps == null) completedUnloadSteps = new java.util.HashSet<>();
|
||||
runUnloadStep("Discord leaderboards", () -> {
|
||||
if (discordHandler != null) discordHandler.close();
|
||||
});
|
||||
runUnloadStep("top voter display", this::retireTopVoterDisplayFilter);
|
||||
runUnloadStep("streak menu", () -> {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.close();
|
||||
});
|
||||
runUnloadStep("setup reminder", this::stopAutoCreateVoteSitesSetupReminder);
|
||||
runUnloadStep("player presence", placeholderPlayerPresence::clear);
|
||||
runUnloadStep("backend hosted Control", this::stopBackendHostedControlLifecycle);
|
||||
runUnloadStep("backend Control connector", this::stopBackendControlConnectorLifecycle);
|
||||
runUnloadStep("backend proxy", () -> {
|
||||
if (getBackendProxyHandler() != null) getBackendProxyHandler().closeForShutdown();
|
||||
});
|
||||
runUnloadStep("backend ordered vote queue", () -> {
|
||||
if (backendOrderedVoteOverflowQueue != null) {
|
||||
backendOrderedVoteOverflowQueue.close();
|
||||
backendOrderedVoteOverflowQueue = null;
|
||||
}
|
||||
});
|
||||
runUnloadStep("webhook manager", () -> {
|
||||
if (webhookManager != null) webhookManager.shutdown();
|
||||
});
|
||||
runUnloadStep("vote timer", this::shutdownVoteTimer);
|
||||
runUnloadStep("Votifier vote queue", () -> {
|
||||
if (votifierVoteOverflowQueue != null) {
|
||||
votifierVoteOverflowQueue.close();
|
||||
votifierVoteOverflowQueue = null;
|
||||
}
|
||||
});
|
||||
runShutdownStep("time queue", () -> {
|
||||
if (timeQueueHandler != null) timeQueueHandler.saveForShutdown();
|
||||
});
|
||||
runUnloadStep("vote reminders", this::shutdownVoteReminders);
|
||||
runUnloadStep("cooldown checker", () -> {
|
||||
if (coolDownCheck != null) {
|
||||
coolDownCheck.shutdown();
|
||||
coolDownCheck = null;
|
||||
}
|
||||
});
|
||||
runShutdownStep("signs", this::shutdownSigns);
|
||||
runUnloadStep("event handlers", () -> HandlerList.unregisterAll(this));
|
||||
// Late producers must see rejection until their overflow and handler cleanup finishes.
|
||||
if (completedUnloadSteps.contains("vote timer")) voteTimer = null;
|
||||
if (plugin == this) plugin = null;
|
||||
} finally { unloadInProgress.set(false); }
|
||||
}
|
||||
|
||||
shutdownVoteReminders();
|
||||
private void beginServerDataProcessDrain() {
|
||||
var owner = serverDataPublicationFence;
|
||||
if (owner != null) owner.beginShutdown(System.nanoTime());
|
||||
}
|
||||
|
||||
if (coolDownCheck != null) {
|
||||
coolDownCheck.shutdown();
|
||||
coolDownCheck = null;
|
||||
private void shutdownSigns() {
|
||||
if (signs == null) return;
|
||||
var previous = signShutdownPublication;
|
||||
if (previous != null && !previous.toCompletableFuture().isCompletedExceptionally()) return;
|
||||
signShutdownPublication = java.util.Objects.requireNonNull(signs.storeSignsForShutdown());
|
||||
}
|
||||
|
||||
private void runUnloadStep(String name, Runnable step) {
|
||||
if (!completedUnloadSteps.contains(name) && runShutdownStep(name, step)) completedUnloadSteps.add(name);
|
||||
}
|
||||
|
||||
private boolean runShutdownStep(String name, Runnable step) {
|
||||
try {
|
||||
step.run();
|
||||
return true;
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try {
|
||||
getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down " + name, failure);
|
||||
} catch (RuntimeException | Error ignored) {
|
||||
// Logging must not prevent the next independent cleanup step.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getSigns().storeSigns();
|
||||
HandlerList.unregisterAll(plugin);
|
||||
plugin = null;
|
||||
private void shutdownVoteTimer() {
|
||||
if (voteTimer == null) return;
|
||||
try {
|
||||
voteTimer.shutdown();
|
||||
if (!voteTimer.awaitTermination(1, TimeUnit.SECONDS)) voteTimer.shutdownNow();
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
voteTimer.shutdownNow();
|
||||
} catch (RuntimeException failure) {
|
||||
try {
|
||||
getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down vote timer", failure);
|
||||
} catch (RuntimeException | Error ignored) { }
|
||||
voteTimer.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2176,6 +2456,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl,
|
||||
boolean updateActiveBackendRuntime) {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.pause();
|
||||
retireTopVoterDisplayFilter();
|
||||
try {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.suspend();
|
||||
configFile.reloadData();
|
||||
@@ -2196,6 +2477,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
updateAdvancedCoreHook();
|
||||
|
||||
reloadAdvancedCore(userStorage);
|
||||
initializeTopVoterDisplayFilter();
|
||||
// PerServerPoints can switch shared point storage on during a live reload.
|
||||
// Re-evaluate after storage has reloaded; UserManager keeps this lifecycle task unique.
|
||||
getVotingPluginUserManager().startSharedPointTransferRecovery();
|
||||
|
||||
@@ -607,6 +607,28 @@ public class CommandLoader {
|
||||
/**
|
||||
* Load admin vote command.
|
||||
*/
|
||||
private void clearDiscordMessageId(CommandSender sender, TopVoter top) {
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
boolean confirmed;
|
||||
try { confirmed = clearDiscordMessageId(top); }
|
||||
catch (RuntimeException failure) { confirmed = false; }
|
||||
String message = confirmed ? "&cCleared discord message ID for " + top.toString()
|
||||
: "&cUnable to confirm the cleared Discord message ID; check storage before restarting.";
|
||||
runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize(message)));
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize(
|
||||
"&cUnable to schedule clearing the Discord message ID; no changes made.")));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean clearDiscordMessageId(TopVoter top) {
|
||||
var discord = plugin.getDiscordHandler();
|
||||
return discord != null ? discord.clearTopVoterMessageId(top)
|
||||
: plugin.getServerData().setTopVoterMessageIdConfirmed(top, 0L);
|
||||
}
|
||||
|
||||
private void loadAdminVoteCommand() {
|
||||
plugin.setAdminVoteCommand(new ArrayList<>());
|
||||
|
||||
@@ -843,7 +865,7 @@ public class CommandLoader {
|
||||
@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(
|
||||
@@ -2947,9 +2969,7 @@ public class CommandLoader {
|
||||
public void execute(CommandSender sender, String[] args) {
|
||||
String topVoter = args[1];
|
||||
TopVoter top = TopVoter.getTopVoter(topVoter);
|
||||
plugin.getServerData().setTopVoterMessageId(top, 0);
|
||||
plugin.getDiscordHandler().getTopVoterMessageIds().put(top, 0L);
|
||||
sendMessage(sender, "&cCleared discord message ID for " + top.toString());
|
||||
clearDiscordMessageId(sender, top);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@ public class VoteTopVoter extends GUIHandler {
|
||||
String topVoter = top.getName();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin.getTopVoter(top)
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin.getTopVoterDisplay(top)
|
||||
.clone();
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
@@ -148,7 +148,7 @@ public class VoteTopVoter extends GUIHandler {
|
||||
String topVoter = top.getName();
|
||||
@SuppressWarnings("unchecked")
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin
|
||||
.getTopVoter(top).clone();
|
||||
.getTopVoterDisplay(top).clone();
|
||||
users = topVotes.entrySet();
|
||||
|
||||
ConfigurationSection customization = plugin.getGui().getChestVoteTopCustomization();
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ public class VoteTopVoterLastMonth extends GUIHandler {
|
||||
|
||||
@Override
|
||||
public void onDialog(Player player) {
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getLastMonthTopVoter().entrySet();
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getLastMonthTopVoterDisplay().entrySet();
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
.placeholder("player", user.getPlayerName())
|
||||
@@ -91,7 +91,7 @@ public class VoteTopVoterLastMonth extends GUIHandler {
|
||||
public void onChest(Player player) {
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = null;
|
||||
|
||||
users = plugin.getLastMonthTopVoter().entrySet();
|
||||
users = plugin.getLastMonthTopVoterDisplay().entrySet();
|
||||
|
||||
BInventory inv = new BInventory(plugin.getGui().getChestVoteTopName());
|
||||
inv.addPlaceholder("topvoter", "Last Month");
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ public class VoteTopVoterPreviousMonths extends GUIHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getPreviousMonthsTopVoters().get(yearMonth).entrySet();
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
.placeholder("player", user.getPlayerName())
|
||||
@@ -160,7 +160,7 @@ public class VoteTopVoterPreviousMonths extends GUIHandler {
|
||||
player.sendMessage(ChatColor.RED + "Can't open previous months, no data");
|
||||
return;
|
||||
}
|
||||
users = plugin.getPreviousMonthsTopVoters().get(yearMonth).entrySet();
|
||||
users = plugin.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
|
||||
|
||||
BInventory inv = new BInventory(plugin.getGui().getChestVoteTopName());
|
||||
inv.addPlaceholder("topvoter", yearMonth.toString());
|
||||
|
||||
@@ -809,6 +809,10 @@ public class Config extends YMLFile {
|
||||
@Getter
|
||||
private boolean topVoterAwardsTies = true;
|
||||
|
||||
@ConfigDataBoolean(path = "HideTopVoterIfNotPlayedBefore")
|
||||
@Getter
|
||||
private boolean hideTopVoterIfNotPlayedBefore = false;
|
||||
|
||||
@ConfigDataBoolean(path = "TopVoterIgnorePermission")
|
||||
@Getter
|
||||
private boolean topVoterIgnorePermission = false;
|
||||
|
||||
@@ -11,10 +11,13 @@ import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
import com.bencodez.advancedcore.api.rewards.Reward;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardOptions;
|
||||
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
@@ -36,12 +39,14 @@ public class CoolDownCheck implements Listener {
|
||||
private final VotingPluginMain plugin;
|
||||
|
||||
private final Map<UUID, ScheduledFuture<?>> perSiteTasks = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Object> perSiteGenerations = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, ScheduledFuture<?>> allSiteTasks = new ConcurrentHashMap<>();
|
||||
|
||||
@Getter
|
||||
private final ScheduledExecutorService timer;
|
||||
|
||||
private volatile boolean cooldownCheckEnabled = false;
|
||||
private volatile boolean stopped = false;
|
||||
|
||||
/**
|
||||
* Constructs a new cooldown check manager.
|
||||
@@ -74,6 +79,8 @@ public class CoolDownCheck implements Listener {
|
||||
* Shuts down the cooldown check timer.
|
||||
*/
|
||||
public void shutdown() {
|
||||
stopped = true;
|
||||
perSiteGenerations.clear();
|
||||
timer.shutdownNow();
|
||||
|
||||
for (ScheduledFuture<?> f : perSiteTasks.values()) {
|
||||
@@ -147,6 +154,7 @@ public class CoolDownCheck implements Listener {
|
||||
user.updateTempCacheWithColumns(cols);
|
||||
cols.clear(); // help GC (only useful if reused)
|
||||
}
|
||||
capturePlayerName(user);
|
||||
|
||||
checkPerSite(site, user);
|
||||
} finally {
|
||||
@@ -219,13 +227,20 @@ public class CoolDownCheck implements Listener {
|
||||
* Site-specific version used by batch checks / checkAllVoteSite.
|
||||
*/
|
||||
private void checkPerSite(VoteSite site, VotingPluginUser user) {
|
||||
checkPerSite(site, user, null);
|
||||
}
|
||||
|
||||
private HashMap<String, Boolean> checkPerSite(VoteSite site, VotingPluginUser user,
|
||||
HashMap<String, Boolean> coolDownChecks) {
|
||||
if (!plugin.getConfigFile().isPerSiteCoolDownEvents() || site == null) {
|
||||
return;
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
HashMap<String, Boolean> coolDownChecks = user.getCoolDownCheckSiteList();
|
||||
if (coolDownChecks == null) {
|
||||
coolDownChecks = user.getCoolDownCheckSiteList();
|
||||
}
|
||||
if (Boolean.TRUE.equals(coolDownChecks.get(site.getKey()))) {
|
||||
return;
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
if (user.canVoteSite(site)) {
|
||||
@@ -237,6 +252,7 @@ public class CoolDownCheck implements Listener {
|
||||
PlayerVoteSiteCoolDownEndEvent event = new PlayerVoteSiteCoolDownEndEvent(user, site);
|
||||
plugin.getServer().getPluginManager().callEvent(event);
|
||||
}
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,8 +283,8 @@ public class CoolDownCheck implements Listener {
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCoolDownEnd(PlayerVoteCoolDownEndEvent event) {
|
||||
plugin.getRewardHandler().giveReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
|
||||
"VoteCoolDownEndedReward", new RewardOptions());
|
||||
dispatchCooldownReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
|
||||
"VoteCoolDownEndedReward", "all", new RewardOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,11 +294,73 @@ public class CoolDownCheck implements Listener {
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCoolDownEnd(PlayerVoteSiteCoolDownEndEvent event) {
|
||||
plugin.getRewardHandler().giveReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
|
||||
new RewardOptions().addPlaceholder("sitename", event.getSite().getDisplayName())
|
||||
dispatchCooldownReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
|
||||
"site:" + event.getSite().getKey(), new RewardOptions()
|
||||
.addPlaceholder("sitename", event.getSite().getDisplayName())
|
||||
.addPlaceholder("url", event.getSite().getVoteURL(false)));
|
||||
}
|
||||
|
||||
private void dispatchCooldownReward(VotingPluginUser user, ConfigurationSection data, String path,
|
||||
String site, RewardOptions options) {
|
||||
boolean configured = false;
|
||||
try {
|
||||
configured = hasConfiguredCooldownReward(data, path);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Preserve legacy dispatch even when diagnostic configuration inspection fails.
|
||||
}
|
||||
if (!configured) {
|
||||
plugin.getRewardHandler().giveReward(user, data, path, options);
|
||||
return;
|
||||
}
|
||||
String occurrence = UUID.randomUUID().toString();
|
||||
String player = user.getUUID();
|
||||
String safeSite = String.valueOf(site).replaceAll("[\\r\\n\\p{Cntrl}]", "?");
|
||||
long time = System.currentTimeMillis();
|
||||
String context = " occurrence=" + occurrence + " player=" + player + " site=" + safeSite + " time=" + time;
|
||||
logCooldownDiagnostic(false, "Cooldown reward status=attempted" + context);
|
||||
options.setAsyncFailureObserver(failure -> logCooldownFailure(context, failure));
|
||||
try {
|
||||
plugin.getRewardHandler().giveReward(user, data, path, options);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
logCooldownFailure(context, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasConfiguredCooldownReward(ConfigurationSection data, String path) {
|
||||
if (data == null) return false;
|
||||
if (data instanceof FileConfiguration) {
|
||||
return plugin.getRewardHandler().hasRewards((FileConfiguration) data, path);
|
||||
}
|
||||
if (data.isList(path)) return !data.getList(path, new ArrayList<>()).isEmpty();
|
||||
if (data.isConfigurationSection(path)) return !data.getConfigurationSection(path).getKeys(false).isEmpty();
|
||||
return data.isString(path) && !data.getString(path, "").isEmpty();
|
||||
}
|
||||
|
||||
private void logCooldownFailure(String context, Throwable failure) {
|
||||
int completed = -1;
|
||||
Throwable current = failure;
|
||||
for (int depth = 0; current != null && depth < 32; depth++, current = current.getCause()) {
|
||||
if (current instanceof Reward.RewardReplayFailure) {
|
||||
completed = ((Reward.RewardReplayFailure) current).getCompletedInjectionCount();
|
||||
break;
|
||||
}
|
||||
}
|
||||
String error = failure == null ? "unknown" : failure.getClass().getSimpleName();
|
||||
logCooldownDiagnostic(true, "Cooldown reward outcome=unknown/partial" + context + " error=" + error
|
||||
+ " completedInjections=" + (completed < 0 ? "unknown" : completed)
|
||||
+ "; inspect effects before reissuing");
|
||||
}
|
||||
|
||||
private void logCooldownDiagnostic(boolean warning, String message) {
|
||||
try {
|
||||
if (warning) plugin.getLogger().warning(message);
|
||||
else plugin.getLogger().info(message);
|
||||
} catch (RuntimeException ignored) {
|
||||
// A broken logger must not suppress or retry the reward.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a cooldown check for a user.
|
||||
*
|
||||
@@ -324,7 +402,7 @@ public class CoolDownCheck implements Listener {
|
||||
* @param force whether to force the schedule
|
||||
*/
|
||||
public void schedulePerSite(VotingPluginUser user, boolean force) {
|
||||
if (!plugin.getConfigFile().isPerSiteCoolDownEvents()) {
|
||||
if (stopped || !plugin.getConfigFile().isPerSiteCoolDownEvents()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -333,6 +411,7 @@ public class CoolDownCheck implements Listener {
|
||||
}
|
||||
|
||||
final UUID uuid = user.getJavaUUID();
|
||||
final String scheduledName = user.getPlayerName();
|
||||
|
||||
final NextSite next = user.getNextSiteAvailable();
|
||||
|
||||
@@ -340,8 +419,13 @@ public class CoolDownCheck implements Listener {
|
||||
if (existing != null) {
|
||||
existing.cancel(false);
|
||||
}
|
||||
if (stopped) {
|
||||
perSiteGenerations.remove(uuid);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (next == null || next.getSite() == null || next.getSecondsUntilAvailable() <= 0) {
|
||||
perSiteGenerations.remove(uuid);
|
||||
plugin.extraDebug(user.getUUID() + "/" + user.getPlayerName() + " not scheduling per-site cooldown check");
|
||||
return null; // remove entry
|
||||
}
|
||||
@@ -349,21 +433,60 @@ public class CoolDownCheck implements Listener {
|
||||
plugin.devDebug("PerSiteCoolDownEvent schedule: site=" + next.getSite().getKey() + " time="
|
||||
+ next.getSecondsUntilAvailable() + " seconds");
|
||||
|
||||
return timer.schedule(() -> {
|
||||
if (!plugin.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check the next site; this will set the cooldown-end flag + fire event if ready
|
||||
checkPerSite(next.getSite(), user);
|
||||
|
||||
// Schedule the following site (if any)
|
||||
schedulePerSite(user, false);
|
||||
|
||||
}, next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS);
|
||||
Object generation = new Object();
|
||||
perSiteGenerations.put(uuid, generation);
|
||||
try {
|
||||
return timer.schedule(() -> runPerSiteTimer(uuid, scheduledName, next, generation, 0),
|
||||
next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
perSiteGenerations.remove(uuid, generation);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void runPerSiteTimer(UUID uuid, String scheduledName, NextSite next, Object generation, int retries) {
|
||||
if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return;
|
||||
UUID occurrence = UUID.randomUUID();
|
||||
boolean[] retried = { false };
|
||||
try {
|
||||
VotingPluginUser current;
|
||||
try {
|
||||
// The batch user's temporary row may have been cleared or replaced by a newer vote.
|
||||
current = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false);
|
||||
if (!current.isCached()) current.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
String storedName = current.getData().getString("PlayerName", current.getUserDataFetchMode());
|
||||
String name = storedName != null && !storedName.isBlank() ? storedName : scheduledName;
|
||||
if (name != null && !name.isBlank()) current.setPlayerName(name);
|
||||
} catch (RuntimeException refreshFailure) {
|
||||
if (retries < 3) {
|
||||
perSiteTasks.compute(uuid, (key, existing) -> {
|
||||
if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return existing;
|
||||
ScheduledFuture<?> retry = timer.schedule(
|
||||
() -> runPerSiteTimer(uuid, scheduledName, next, generation, retries + 1),
|
||||
30, TimeUnit.SECONDS);
|
||||
retried[0] = true;
|
||||
return retry;
|
||||
});
|
||||
if (retried[0]) return;
|
||||
}
|
||||
throw refreshFailure;
|
||||
}
|
||||
if (perSiteGenerations.get(uuid) != generation) return;
|
||||
checkPerSite(next.getSite(), current);
|
||||
if (perSiteGenerations.get(uuid) == generation) schedulePerSite(current, false);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
String siteKey = String.valueOf(next.getSite().getKey()).replaceAll("[\\r\\n\\p{Cntrl}]", "?");
|
||||
logCooldownDiagnostic(true, "Cooldown timer failed: occurrence=" + occurrence + " player=" + uuid
|
||||
+ " site=" + siteKey + " time=" + System.currentTimeMillis() + " error="
|
||||
+ failure.getClass().getSimpleName() + "; reward outcome is uncertain; inspect effects before reissuing");
|
||||
throw failure;
|
||||
} finally {
|
||||
if (!retried[0]) perSiteGenerations.remove(uuid, generation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a vote event for cooldown scheduling.
|
||||
*
|
||||
@@ -381,6 +504,14 @@ public class CoolDownCheck implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve the name needed by asynchronous rewards after the temporary row is cleared. */
|
||||
private static void capturePlayerName(VotingPluginUser user) {
|
||||
String name = user.getPlayerName();
|
||||
if (name != null && !name.isBlank()) {
|
||||
user.setPlayerName(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance-optimized: scan users ONCE, process all daily-delay sites.
|
||||
*/
|
||||
@@ -421,9 +552,11 @@ public class CoolDownCheck implements Listener {
|
||||
user.updateTempCacheWithColumns(cols);
|
||||
cols.clear();
|
||||
}
|
||||
capturePlayerName(user);
|
||||
|
||||
HashMap<String, Boolean> coolDownChecks = null;
|
||||
for (int i = 0; i < dailySites.size(); i++) {
|
||||
checkPerSite(dailySites.get(i), user);
|
||||
coolDownChecks = checkPerSite(dailySites.get(i), user, coolDownChecks);
|
||||
}
|
||||
|
||||
check(user);
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package com.bencodez.votingplugin.data;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import com.bencodez.advancedcore.api.time.TimeType;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
/**
|
||||
* Routes SDK publications through this generation's ownership lease. The SDK's
|
||||
* original file/configuration remains authoritative; no YAML snapshot is copied.
|
||||
*/
|
||||
public final class GenerationFencedServerDataFile extends com.bencodez.advancedcore.data.ServerData {
|
||||
private final com.bencodez.advancedcore.data.ServerData delegate;
|
||||
private final ServerDataPublicationFence fence;
|
||||
private final VotingPluginMain ownerPlugin;
|
||||
|
||||
public GenerationFencedServerDataFile(VotingPluginMain plugin,
|
||||
com.bencodez.advancedcore.data.ServerData delegate, ServerDataPublicationFence fence) {
|
||||
super(plugin);
|
||||
this.ownerPlugin = java.util.Objects.requireNonNull(plugin);
|
||||
this.delegate = java.util.Objects.requireNonNull(delegate);
|
||||
this.fence = java.util.Objects.requireNonNull(fence);
|
||||
}
|
||||
|
||||
public boolean wraps(com.bencodez.advancedcore.data.ServerData file) { return delegate == file; }
|
||||
|
||||
public boolean belongsTo(ServerDataPublicationFence owner) { return fence == owner; }
|
||||
|
||||
/** Checkpoint callers use the same monitor as native final publication. */
|
||||
@Override public Object publicationMonitor() {
|
||||
return java.util.Objects.requireNonNull(delegate.publicationMonitor(), "Server-data publication monitor");
|
||||
}
|
||||
|
||||
private void withOwner(Runnable operation) {
|
||||
try (var lease = fence.acquire()) {
|
||||
synchronized (publicationMonitor()) {
|
||||
try { operation.run(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { refreshVotePartyDisplay(); }
|
||||
catch (RuntimeException | Error observationFailure) {
|
||||
if (observationFailure != failure) failure.addSuppressed(observationFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
refreshVotePartyDisplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T withOwnedValue(Supplier<T> operation) {
|
||||
try (var lease = fence.acquire()) {
|
||||
synchronized (publicationMonitor()) { return operation.get(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public FileConfiguration getData() { return delegate.getData(); }
|
||||
@Override public File getdFile() { return delegate.getdFile(); }
|
||||
@Override public JavaPlugin getPlugin() { return delegate.getPlugin(); }
|
||||
@Override public boolean isJustCreated() { return delegate.isJustCreated(); }
|
||||
@Override public boolean isFailedToRead() { return delegate.isFailedToRead(); }
|
||||
@Override public boolean isIgnoreCase() { return delegate.isIgnoreCase(); }
|
||||
@Override public void setIgnoreCase(boolean value) {
|
||||
withOwner(() -> delegate.setIgnoreCase(value));
|
||||
}
|
||||
@Override public void createSection(String path) {
|
||||
mutateOrdinaryData(data -> data.createSection(path));
|
||||
}
|
||||
@Override public void setData(FileConfiguration data) {
|
||||
withOwner(() -> delegate.setData(data));
|
||||
}
|
||||
@Override public void setValue(String path, Object value) {
|
||||
mutateOrdinaryData(data -> data.set(path, value));
|
||||
}
|
||||
@Override public void onFileCreation() {
|
||||
withOwner(delegate::onFileCreation);
|
||||
}
|
||||
|
||||
@Override public void setup() {
|
||||
withOwner(delegate::setup);
|
||||
}
|
||||
@Override public void loadValues() {
|
||||
withOwner(delegate::loadValues);
|
||||
}
|
||||
@Override public void reloadData() {
|
||||
withOwner(delegate::reloadData);
|
||||
}
|
||||
private void refreshVotePartyDisplay() {
|
||||
var wrapper = ownerPlugin.getServerData();
|
||||
if (wrapper != null) {
|
||||
synchronized (publicationMonitor()) {
|
||||
if (delegate.getData() != null) wrapper.refreshVotePartyDisplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void saveData() {
|
||||
withOwner(() -> {
|
||||
refreshVotePartyDisplay();
|
||||
delegate.saveData();
|
||||
});
|
||||
}
|
||||
@Override public void saveDataChecked() {
|
||||
withOwner(() -> {
|
||||
refreshVotePartyDisplay();
|
||||
delegate.saveDataChecked();
|
||||
});
|
||||
}
|
||||
@Override protected void saveTimeChangeTransitionData() { saveDataChecked(); }
|
||||
|
||||
@Override public void mutateOrdinaryData(Consumer<FileConfiguration> mutation) {
|
||||
// Delegate admission, live mutation and saving to the actual SDK owner.
|
||||
// The projection is published inside that owner's callback before I/O.
|
||||
withOwner(() -> delegate.mutateOrdinaryData(data -> applyOwnedMutation(mutation, data)));
|
||||
}
|
||||
|
||||
private void refreshIfLiveMutation(FileConfiguration data) {
|
||||
var wrapper = ownerPlugin.getServerData();
|
||||
if (wrapper == null) return;
|
||||
synchronized (publicationMonitor()) {
|
||||
// Native final publication mutates a staged candidate first. Legacy
|
||||
// adapters may instead mutate the live owner before their save.
|
||||
if (data == delegate.getData()) wrapper.refreshVotePartyDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyOwnedMutation(Consumer<FileConfiguration> mutation, FileConfiguration data) {
|
||||
try { mutation.accept(data); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { refreshIfLiveMutation(data); }
|
||||
catch (RuntimeException | Error observationFailure) {
|
||||
if (observationFailure != failure) failure.addSuppressed(observationFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
refreshIfLiveMutation(data);
|
||||
}
|
||||
|
||||
private void finishFinalPublication(ServerDataPublicationFence.Lease lease,
|
||||
CompletableFuture<Void> result, Throwable failure) {
|
||||
Throwable outcome = failure;
|
||||
try {
|
||||
// Partial startup may have no display wrapper. In that case, do not
|
||||
// introduce a YAML-observation dependency into final-stage drain.
|
||||
refreshVotePartyDisplay();
|
||||
} catch (Throwable observationFailure) {
|
||||
if (outcome == null) outcome = observationFailure;
|
||||
else if (outcome != observationFailure) outcome.addSuppressed(observationFailure);
|
||||
}
|
||||
// Refresh observes only the actual live owner; it never acknowledges a
|
||||
// failed/cancelled publication or releases its retained generation.
|
||||
if (outcome != null) { result.completeExceptionally(outcome); return; }
|
||||
try { lease.close(); result.complete(null); }
|
||||
catch (Throwable releaseFailure) { result.completeExceptionally(releaseFailure); }
|
||||
}
|
||||
|
||||
/** Retain this handle only for retries of the same accepted mutation. */
|
||||
FinalPublication prepareFinalPublication(Consumer<FileConfiguration> mutation) {
|
||||
return new FinalPublication(mutation);
|
||||
}
|
||||
|
||||
final class FinalPublication {
|
||||
private final Consumer<FileConfiguration> mutation;
|
||||
private ServerDataPublicationFence.Lease lease;
|
||||
private CompletableFuture<Void> attempt;
|
||||
private boolean publicationConfirmed;
|
||||
|
||||
private FinalPublication(Consumer<FileConfiguration> mutation) { this.mutation = mutation; }
|
||||
|
||||
CompletionStage<Void> publish() {
|
||||
CompletableFuture<Void> result;
|
||||
ServerDataPublicationFence.Lease owner;
|
||||
boolean refreshOnly;
|
||||
synchronized (this) {
|
||||
if (attempt != null && !attempt.isCompletedExceptionally())
|
||||
return attempt.minimalCompletionStage();
|
||||
if (lease == null) {
|
||||
try { lease = fence.acquire(); }
|
||||
catch (Throwable failure) { return CompletableFuture.failedFuture(failure); }
|
||||
}
|
||||
owner = lease;
|
||||
result = new CompletableFuture<>();
|
||||
attempt = result;
|
||||
refreshOnly = publicationConfirmed;
|
||||
}
|
||||
// No operation lock is held across the actual owner's YAML or I/O.
|
||||
if (refreshOnly) finish(owner, result, null);
|
||||
else {
|
||||
try {
|
||||
java.util.Objects.requireNonNull(mutation, "mutation");
|
||||
java.util.Objects.requireNonNull(delegate.mutateFinalData(data -> applyOwnedMutation(mutation, data)))
|
||||
.whenComplete((ignored, failure) -> finish(owner, result, failure));
|
||||
} catch (Throwable failure) { finish(owner, result, failure); }
|
||||
}
|
||||
return result.minimalCompletionStage();
|
||||
}
|
||||
|
||||
private void finish(ServerDataPublicationFence.Lease owner,
|
||||
CompletableFuture<Void> result, Throwable failure) {
|
||||
if (failure == null) {
|
||||
synchronized (this) { publicationConfirmed = true; }
|
||||
}
|
||||
// A retry after a display-observation failure must not repeat a
|
||||
// confirmed mutation. Failed publication instead retains this one
|
||||
// lease until this same operation has a confirmed successful retry.
|
||||
finishFinalPublication(owner, result, failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public CompletionStage<Void> mutateFinalData(Consumer<FileConfiguration> mutation) {
|
||||
// Ordinary one-shot callers retain failed ownership conservatively.
|
||||
// A caller with an explicit retry contract retains the operation handle.
|
||||
return prepareFinalPublication(mutation).publish();
|
||||
}
|
||||
|
||||
@Override public void setData(String path, Object value) { mutateOrdinaryData(data -> data.set(path, value)); }
|
||||
@Override public void setIgnoreTime(boolean value) { withOwner(() -> delegate.setIgnoreTime(value)); }
|
||||
@Override public void setLastUpdated() { withOwner(delegate::setLastUpdated); }
|
||||
@Override public void setPluginVersion(Plugin plugin) { withOwner(() -> delegate.setPluginVersion(plugin)); }
|
||||
@Override public void setPrevDay(int value) { withOwner(() -> delegate.setPrevDay(value)); }
|
||||
@Override public void setPrevMonth(String value) { withOwner(() -> delegate.setPrevMonth(value)); }
|
||||
@Override public void setPrevWeekDay(int value) { withOwner(() -> delegate.setPrevWeekDay(value)); }
|
||||
|
||||
@Override public TimeChangeTransitionState beginTimeChangeTransition(TimeType type, String period, String marker) {
|
||||
return withOwnedValue(() -> delegate.beginTimeChangeTransition(type, period, marker));
|
||||
}
|
||||
@Override public TimeChangeTransitionState getPendingTimeChangeTransition(TimeType type) {
|
||||
return withOwnedValue(() -> delegate.getPendingTimeChangeTransition(type));
|
||||
}
|
||||
@Override public void completeTimeChangeTransition(TimeChangeTransitionState transition) {
|
||||
withOwner(() -> delegate.completeTimeChangeTransition(transition));
|
||||
}
|
||||
@Override public void failTimeChangeTransition(TimeChangeTransitionState transition) {
|
||||
withOwner(() -> delegate.failTimeChangeTransition(transition));
|
||||
}
|
||||
@Override public boolean isPendingTimeChangeTransition(TimeChangeTransitionState transition) {
|
||||
return withOwnedValue(() -> delegate.isPendingTimeChangeTransition(transition));
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,182 @@ import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
|
||||
public class ServerData {
|
||||
public class ServerData {
|
||||
|
||||
/** Immutable live display values, separate from durable publication acknowledgement. */
|
||||
public record VotePartyDisplay(int total, int extraRequired) {
|
||||
public int required(int configured) { return extraRequired > 0 ? configured + extraRequired : configured; }
|
||||
public int needed(int configured) { return required(configured) - total; }
|
||||
}
|
||||
|
||||
private volatile VotePartyDisplay votePartyDisplay = new VotePartyDisplay(0, 0);
|
||||
|
||||
/** Snapshot-only: never acquires a YAML owner or waits for filesystem publication. */
|
||||
public VotePartyDisplay getVotePartyDisplay() { return votePartyDisplay; }
|
||||
|
||||
/** Called during startup before placeholders are registered. */
|
||||
public void refreshVotePartyDisplay() { mutateData(this::captureVotePartyDisplay); }
|
||||
|
||||
private void captureVotePartyDisplay() {
|
||||
var data = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
votePartyDisplay = data == null ? new VotePartyDisplay(0, 0)
|
||||
: new VotePartyDisplay(data.getInt("VoteParty.Total"), data.getInt("VotePartyExtraRequired"));
|
||||
}
|
||||
|
||||
private VotingPluginMain plugin = VotingPluginMain.plugin;
|
||||
private volatile com.bencodez.advancedcore.data.ServerData generationFile;
|
||||
private final Object generationBinding = new Object();
|
||||
private volatile com.bencodez.votingplugin.util.ServerDataPublicationFence generationFence;
|
||||
|
||||
private com.bencodez.advancedcore.data.ServerData getFile() {
|
||||
var current = generationFile;
|
||||
if (current != null) return current;
|
||||
synchronized (generationBinding) {
|
||||
if (generationFile == null) {
|
||||
var owner = getPublicationFence();
|
||||
// Reject an old lazy wrapper before exposing successor mutable state.
|
||||
try (var lease = owner == null ? null : owner.acquire()) {
|
||||
var file = plugin.getServerDataFile();
|
||||
if (file instanceof GenerationFencedServerDataFile guarded && !guarded.belongsTo(owner))
|
||||
throw new IllegalStateException("Server-data file belongs to another generation");
|
||||
generationFile = file;
|
||||
}
|
||||
}
|
||||
return generationFile;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private record SignCoordinates(String world, int x, int y, int z) {
|
||||
static SignCoordinates capture(Location location) {
|
||||
return new SignCoordinates(location.getWorld().getName(), (int) location.getX(),
|
||||
(int) location.getY(), (int) location.getZ());
|
||||
}
|
||||
void store(ConfigurationSection data, String path) {
|
||||
data.set(path + ".World", world);
|
||||
data.set(path + ".X", x);
|
||||
data.set(path + ".Y", y);
|
||||
data.set(path + ".Z", z);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Detached shutdown payload: no platform object is accessed by its publisher. */
|
||||
public static final class SignSnapshot {
|
||||
private final String count;
|
||||
private final SignCoordinates location;
|
||||
private final SignCoordinates skull;
|
||||
private final String data;
|
||||
private final int position;
|
||||
|
||||
private SignSnapshot(String count, Location location, Location skull, String data, int position) {
|
||||
this.count = count;
|
||||
this.location = SignCoordinates.capture(location);
|
||||
this.skull = skull == null ? null : SignCoordinates.capture(skull);
|
||||
this.data = data;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
private void store(ConfigurationSection target) {
|
||||
location.store(target, "Signs." + count);
|
||||
target.set("Signs." + count + ".Data", data);
|
||||
target.set("Signs." + count + ".Position", position);
|
||||
if (skull != null) skull.store(target, "Signs." + count + ".Skull");
|
||||
}
|
||||
}
|
||||
|
||||
public static SignSnapshot captureSignSnapshot(String count, Location location, Location skull,
|
||||
String data, int position) {
|
||||
return new SignSnapshot(count, location, skull, data, position);
|
||||
}
|
||||
|
||||
private record ShutdownSignPublication(List<SignSnapshot> snapshot,
|
||||
java.util.function.Supplier<java.util.concurrent.CompletionStage<Void>> publisher) { }
|
||||
private final Object shutdownSignBinding = new Object();
|
||||
private ShutdownSignPublication shutdownSignPublication;
|
||||
|
||||
/** Worker-only: one accepted sign batch, with the same publication owner across retries. */
|
||||
public java.util.concurrent.CompletionStage<Void> publishShutdownSigns(List<SignSnapshot> signs) {
|
||||
var snapshot = List.copyOf(signs);
|
||||
ShutdownSignPublication publication;
|
||||
synchronized (shutdownSignBinding) {
|
||||
if (shutdownSignPublication == null) {
|
||||
var file = getFile();
|
||||
java.util.function.Consumer<org.bukkit.configuration.file.FileConfiguration> mutation = candidate -> {
|
||||
var target = candidate.getConfigurationSection("VotingPlugin");
|
||||
if (target == null) target = candidate.createSection("VotingPlugin");
|
||||
for (var sign : snapshot) sign.store(target);
|
||||
};
|
||||
java.util.function.Supplier<java.util.concurrent.CompletionStage<Void>> publisher;
|
||||
if (file instanceof GenerationFencedServerDataFile guarded) {
|
||||
var retained = guarded.prepareFinalPublication(mutation);
|
||||
publisher = retained::publish;
|
||||
} else publisher = () -> file.mutateFinalData(mutation);
|
||||
shutdownSignPublication = new ShutdownSignPublication(snapshot, publisher);
|
||||
} else if (!shutdownSignPublication.snapshot().equals(snapshot)) {
|
||||
return java.util.concurrent.CompletableFuture.failedFuture(
|
||||
new IllegalStateException("Shutdown sign publication already owns a different batch"));
|
||||
}
|
||||
publication = shutdownSignPublication;
|
||||
}
|
||||
return publication.publisher().get();
|
||||
}
|
||||
|
||||
/** Only YAML work belongs here; platform callbacks run outside the file owner. */
|
||||
private void mutateData(Runnable mutation) {
|
||||
var fence = getPublicationFence();
|
||||
try (var owner = fence == null ? null : fence.acquire()) {
|
||||
synchronized (publicationMonitor()) {
|
||||
mutation.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Admit the complete ordinary mutation before changing live YAML or its display. */
|
||||
private void mutateSavedData(Runnable mutation) {
|
||||
mutateData(() -> getFile().mutateOrdinaryData(ignored -> {
|
||||
try { mutation.run(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { captureVotePartyDisplay(); }
|
||||
catch (RuntimeException | Error observationFailure) {
|
||||
if (observationFailure != failure) failure.addSuppressed(observationFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
captureVotePartyDisplay();
|
||||
}));
|
||||
}
|
||||
|
||||
/** Store a scalar and save under the same YAML ownership as final SDK snapshots. */
|
||||
public void setInt(String path, int value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set(path, value);
|
||||
});
|
||||
}
|
||||
|
||||
/** Store a detached string list, preventing later caller mutation of live YAML. */
|
||||
public void setStringList(String path, List<String> values) {
|
||||
var snapshot = values == null ? null : new ArrayList<>(values);
|
||||
mutateSavedData(() -> {
|
||||
getData().set(path, snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
/** All mutations serialize with the actual SDK YAML owner. */
|
||||
private Object publicationMonitor() {
|
||||
var file = getFile();
|
||||
return java.util.Objects.requireNonNull(file.publicationMonitor(), "Server-data publication monitor");
|
||||
}
|
||||
|
||||
private com.bencodez.votingplugin.util.ServerDataPublicationFence getPublicationFence() {
|
||||
var current = generationFence;
|
||||
if (current != null) return current;
|
||||
synchronized (generationBinding) {
|
||||
if (generationFence == null) generationFence = plugin.getServerDataPublicationFence();
|
||||
return generationFence;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new ServerData.
|
||||
@@ -29,6 +202,7 @@ public class ServerData {
|
||||
*/
|
||||
public ServerData(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
this.generationFence = plugin.getServerDataPublicationFence();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,13 +210,15 @@ public class ServerData {
|
||||
*
|
||||
* @param placeholder the placeholder to add
|
||||
*/
|
||||
public void addAutoCachedPlaceholder(String placeholder) {
|
||||
List<String> p = getAutoCachedPlaceholder();
|
||||
|
||||
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
|
||||
p.add(placeholder);
|
||||
setAutoCachedPlaceholder(p);
|
||||
}
|
||||
public void addAutoCachedPlaceholder(String placeholder) {
|
||||
mutateData(() -> {
|
||||
List<String> p = getAutoCachedPlaceholder();
|
||||
|
||||
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
|
||||
p.add(placeholder);
|
||||
setAutoCachedPlaceholder(p);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,12 +226,14 @@ public class ServerData {
|
||||
*
|
||||
* @param site the service site to add
|
||||
*/
|
||||
public synchronized void addServiceSite(String site) {
|
||||
ArrayList<String> l = getServiceSites();
|
||||
if (!getServiceSites().contains(site)) {
|
||||
l.add(site);
|
||||
}
|
||||
setServiceSites(ArrayUtils.removeDuplicates(l));
|
||||
public void addServiceSite(String site) {
|
||||
mutateData(() -> {
|
||||
ArrayList<String> l = getServiceSites();
|
||||
if (!getServiceSites().contains(site)) {
|
||||
l.add(site);
|
||||
}
|
||||
setServiceSites(ArrayUtils.removeDuplicates(l));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,19 +243,20 @@ public class ServerData {
|
||||
* @param data the data
|
||||
* @param position the position
|
||||
*/
|
||||
public void addSign(Location location, String data, int position) {
|
||||
|
||||
int count = nextSignNumber();
|
||||
|
||||
getData().set("Signs." + count + ".World", location.getWorld().getName());
|
||||
getData().set("Signs." + count + ".X", location.getBlockX());
|
||||
getData().set("Signs." + count + ".Y", location.getBlockY());
|
||||
getData().set("Signs." + count + ".Z", location.getBlockZ());
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
saveData();
|
||||
plugin.getSigns().getSigns().add(new SignHandler(plugin, "" + count, getSignLocation("" + count),
|
||||
getSignSkullLocation("" + count), getSignData("" + count), getSignPosition("" + count)));
|
||||
public void addSign(Location location, String data, int position) {
|
||||
var coordinates = new SignCoordinates(location.getWorld().getName(), location.getBlockX(),
|
||||
location.getBlockY(), location.getBlockZ());
|
||||
int[] selected = new int[1];
|
||||
mutateSavedData(() -> {
|
||||
int count = nextSignNumber();
|
||||
selected[0] = count;
|
||||
coordinates.store(getData(), "Signs." + count);
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
});
|
||||
String count = Integer.toString(selected[0]);
|
||||
plugin.getSigns().getSigns().add(new SignHandler(plugin, count, getSignLocation(count),
|
||||
getSignSkullLocation(count), getSignData(count), getSignPosition(count)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,11 +265,13 @@ public class ServerData {
|
||||
* @param num the vote number
|
||||
* @param vote the vote time queue entry
|
||||
*/
|
||||
public void addTimeVoted(int num, VoteTimeQueue vote) {
|
||||
getData().set("TimedVoteCache." + num + ".Name", vote.getName());
|
||||
getData().set("TimedVoteCache." + num + ".Service", vote.getService());
|
||||
getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
|
||||
saveData();
|
||||
public void addTimeVoted(int num, VoteTimeQueue vote) {
|
||||
mutateData(() -> {
|
||||
getData().set("TimedVoteCache." + num + ".Name", vote.getName());
|
||||
getData().set("TimedVoteCache." + num + ".Service", vote.getService());
|
||||
getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,16 +279,20 @@ public class ServerData {
|
||||
*
|
||||
* @param ident the vote shop identifier
|
||||
*/
|
||||
public void addVoteShopPurchase(String ident) {
|
||||
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
|
||||
public void addVoteShopPurchase(String ident) {
|
||||
mutateData(() -> {
|
||||
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the timed vote cache.
|
||||
*/
|
||||
public void clearTimedVoteCache() {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveData();
|
||||
public void clearTimedVoteCache() {
|
||||
mutateData(() -> {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,13 +327,15 @@ public class ServerData {
|
||||
*
|
||||
* @return the data
|
||||
*/
|
||||
public ConfigurationSection getData() {
|
||||
ConfigurationSection data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
if (data == null) {
|
||||
plugin.getServerDataFile().getData().createSection("VotingPlugin");
|
||||
data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
}
|
||||
return data;
|
||||
public ConfigurationSection getData() {
|
||||
var fence = getPublicationFence();
|
||||
try (var owner = fence == null ? null : fence.acquire()) {
|
||||
synchronized (publicationMonitor()) {
|
||||
ConfigurationSection data = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
if (data == null) data = getFile().getData().createSection("VotingPlugin");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,9 +352,10 @@ public class ServerData {
|
||||
*
|
||||
* @return the service sites
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public ArrayList<String> getServiceSites() {
|
||||
return (ArrayList<String>) getData().getList("GottenServiceSites", new ArrayList<>());
|
||||
public ArrayList<String> getServiceSites() {
|
||||
synchronized (publicationMonitor()) {
|
||||
return new ArrayList<>(getData().getStringList("GottenServiceSites"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,9 +364,11 @@ public class ServerData {
|
||||
*
|
||||
* @return persisted service-site observations
|
||||
*/
|
||||
public synchronized List<String> getServiceSitesReadOnly() {
|
||||
ConfigurationSection root = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
|
||||
public List<String> getServiceSitesReadOnly() {
|
||||
synchronized (publicationMonitor()) {
|
||||
ConfigurationSection root = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,8 +529,11 @@ public class ServerData {
|
||||
/**
|
||||
* Reload data.
|
||||
*/
|
||||
public void reloadData() {
|
||||
plugin.getServerDataFile().reloadData();
|
||||
public void reloadData() {
|
||||
mutateData(() -> {
|
||||
getFile().reloadData();
|
||||
captureVotePartyDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,36 +552,164 @@ public class ServerData {
|
||||
* @param top the top voter type
|
||||
* @param messageId the message ID
|
||||
*/
|
||||
public void setTopVoterMessageId(TopVoter top, long messageId) {
|
||||
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
|
||||
saveData();
|
||||
public void setTopVoterMessageId(TopVoter top, long messageId) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Uses the existing writer and confirms this ID in the saved file. Legacy void saves may
|
||||
* swallow I/O failure; queued/custom writers may also remain unconfirmed at this point.
|
||||
* False is an uncertain outcome, never permission to delete a possibly recorded message.
|
||||
* Call only on the existing worker, under this facade's normal save ordering.
|
||||
*/
|
||||
public synchronized boolean setTopVoterMessageIdConfirmed(TopVoter top, long messageId) {
|
||||
setTopVoterMessageId(top, messageId);
|
||||
try {
|
||||
var saved = new org.bukkit.configuration.file.YamlConfiguration();
|
||||
saved.load(plugin.getServerDataFile().getdFile());
|
||||
return saved.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId." + top.toString(), 0) == messageId;
|
||||
} catch (java.io.IOException | org.bukkit.configuration.InvalidConfigurationException | RuntimeException failure) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the sign.
|
||||
*
|
||||
* @param sign the sign
|
||||
*/
|
||||
public void removeSign(String sign) {
|
||||
getData().set("Signs." + sign + ".World", null);
|
||||
getData().set("Signs." + sign + ".X", null);
|
||||
getData().set("Signs." + sign + ".Y", null);
|
||||
getData().set("Signs." + sign + ".Z", null);
|
||||
getData().set("Signs." + sign + ".Data", null);
|
||||
getData().set("Signs." + sign + ".Position", null);
|
||||
getData().set("Signs." + sign, null);
|
||||
getData().set("Signs." + sign + ".Skull.World", null);
|
||||
getData().set("Signs." + sign + ".Skull.X", null);
|
||||
getData().set("Signs." + sign + ".Skull.Y", null);
|
||||
getData().set("Signs." + sign + ".Skull.Z", null);
|
||||
saveData();
|
||||
public void removeSign(String sign) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("Signs." + sign + ".World", null);
|
||||
getData().set("Signs." + sign + ".X", null);
|
||||
getData().set("Signs." + sign + ".Y", null);
|
||||
getData().set("Signs." + sign + ".Z", null);
|
||||
getData().set("Signs." + sign + ".Data", null);
|
||||
getData().set("Signs." + sign + ".Position", null);
|
||||
getData().set("Signs." + sign, null);
|
||||
getData().set("Signs." + sign + ".Skull.World", null);
|
||||
getData().set("Signs." + sign + ".Skull.X", null);
|
||||
getData().set("Signs." + sign + ".Skull.Y", null);
|
||||
getData().set("Signs." + sign + ".Skull.Z", null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save data.
|
||||
*/
|
||||
public synchronized void saveData() {
|
||||
plugin.getServerDataFile().saveData();
|
||||
/** Checkpoint the pending suffix without retiring its live queue entries. */
|
||||
public void replaceTimedVoteCache(java.util.Collection<VoteTimeQueue> pending) {
|
||||
mutateData(() -> {
|
||||
Object previous = getData().get("TimedVoteCache");
|
||||
try {
|
||||
setTimedVoteCache(pending);
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set("TimedVoteCache", previous);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish the recovery suffix and conservative in-flight record in one file save. */
|
||||
public void prepareTimedVoteDispatch(UUID id, VoteTimeQueue vote,
|
||||
java.util.Collection<VoteTimeQueue> pending) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
Object previousCache = getData().get("TimedVoteCache");
|
||||
Object previousRecord = getData().get(path);
|
||||
try {
|
||||
setTimedVoteCache(pending);
|
||||
getData().set(path, null);
|
||||
getData().createSection(path, timedVoteFields(vote));
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set("TimedVoteCache", previousCache);
|
||||
getData().set(path, previousRecord);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Restore an undispatched occurrence and its pending suffix for ordinary restart recovery. */
|
||||
public void abortTimedVoteDispatch(UUID id, java.util.Collection<VoteTimeQueue> pending) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
Object previousCache = getData().get("TimedVoteCache");
|
||||
Object previousRecord = getData().get(path);
|
||||
try {
|
||||
setTimedVoteCache(pending);
|
||||
getData().set(path, null);
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set("TimedVoteCache", previousCache);
|
||||
getData().set(path, previousRecord);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear only a confirmed dispatch record; failed writes retain its live representation. */
|
||||
public void completeTimedVoteDispatch(UUID id) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
Object previous = getData().get(path);
|
||||
try {
|
||||
getData().set(path, null);
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set(path, previous);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setTimedVoteCache(java.util.Collection<VoteTimeQueue> pending) {
|
||||
java.util.Map<String, Object> rows = new java.util.LinkedHashMap<>();
|
||||
int index = 0;
|
||||
for (VoteTimeQueue vote : pending) rows.put(Integer.toString(index++), timedVoteFields(vote));
|
||||
getData().set("TimedVoteCache", null);
|
||||
getData().createSection("TimedVoteCache", rows);
|
||||
}
|
||||
|
||||
private static java.util.Map<String, Object> timedVoteFields(VoteTimeQueue vote) {
|
||||
java.util.Map<String, Object> fields = new java.util.LinkedHashMap<>();
|
||||
fields.put("Name", vote.getName());
|
||||
fields.put("Service", vote.getService());
|
||||
fields.put("Time", vote.getTime());
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Retain an ambiguous event delivery without making it eligible for automatic replay. */
|
||||
public void addUncertainTimedVote(java.util.UUID id, VoteTimeQueue vote) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
getData().set(path + ".Name", vote.getName());
|
||||
getData().set(path + ".Service", vote.getService());
|
||||
getData().set(path + ".Time", vote.getTime());
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
}
|
||||
|
||||
/** Timed votes must observe write failures before their in-memory queue is retired. */
|
||||
private void saveTimedVoteCache() {
|
||||
var fence = getPublicationFence();
|
||||
try (var owner = fence == null ? null : fence.acquire()) {
|
||||
var file = getFile();
|
||||
if (file instanceof GenerationFencedServerDataFile guarded) guarded.saveDataChecked();
|
||||
else file.getData().save(file.getdFile());
|
||||
} catch (java.io.IOException failure) {
|
||||
throw new java.io.UncheckedIOException("Failed to save timed vote cache", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves server data through the existing general-purpose file helper. */
|
||||
public void saveData() {
|
||||
mutateData(() -> {
|
||||
// Publish the complete live display projection before entering disk I/O.
|
||||
// A failed save still does not acknowledge durable storage.
|
||||
captureVotePartyDisplay();
|
||||
getFile().saveData();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,13 +717,10 @@ public class ServerData {
|
||||
*
|
||||
* @param disabledReminders the list of UUIDs with disabled reminders
|
||||
*/
|
||||
public void saveDisabledReminders(ArrayList<UUID> disabledReminders) {
|
||||
ArrayList<String> uuids = new ArrayList<>();
|
||||
for (UUID uuid : disabledReminders) {
|
||||
uuids.add(uuid.toString());
|
||||
}
|
||||
getData().set("DisabledReminders", uuids);
|
||||
saveData();
|
||||
public void saveDisabledReminders(ArrayList<UUID> disabledReminders) {
|
||||
ArrayList<String> uuids = new ArrayList<>();
|
||||
for (UUID uuid : disabledReminders) uuids.add(uuid.toString());
|
||||
setStringList("DisabledReminders", uuids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,9 +728,8 @@ public class ServerData {
|
||||
*
|
||||
* @param placeholders the list of placeholders
|
||||
*/
|
||||
public void setAutoCachedPlaceholder(List<String> placeholders) {
|
||||
getData().set("AutoCachePlaceholders", placeholders);
|
||||
saveData();
|
||||
public void setAutoCachedPlaceholder(List<String> placeholders) {
|
||||
setStringList("AutoCachePlaceholders", placeholders);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,9 +737,8 @@ public class ServerData {
|
||||
*
|
||||
* @param current the current vote count
|
||||
*/
|
||||
public void setBungeeVotePartyCurrent(int current) {
|
||||
getData().set("BungeeVotePartyCurrent", current);
|
||||
saveData();
|
||||
public void setBungeeVotePartyCurrent(int current) {
|
||||
setInt("BungeeVotePartyCurrent", current);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,9 +746,8 @@ public class ServerData {
|
||||
*
|
||||
* @param required the required vote count
|
||||
*/
|
||||
public void setBungeeVotePartyRequired(int required) {
|
||||
getData().set("BungeeVotePartyRequired", required);
|
||||
saveData();
|
||||
public void setBungeeVotePartyRequired(int required) {
|
||||
setInt("BungeeVotePartyRequired", required);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,9 +755,8 @@ public class ServerData {
|
||||
*
|
||||
* @param list the service sites list
|
||||
*/
|
||||
public void setServiceSites(ArrayList<String> list) {
|
||||
getData().set("GottenServiceSites", list);
|
||||
saveData();
|
||||
public void setServiceSites(ArrayList<String> list) {
|
||||
setStringList("GottenServiceSites", list);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,9 +764,10 @@ public class ServerData {
|
||||
*
|
||||
* @param value the converted status
|
||||
*/
|
||||
public void setShopConverted(boolean value) {
|
||||
getData().set("VoteShopConverted", value);
|
||||
saveData();
|
||||
public void setShopConverted(boolean value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VoteShopConverted", value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,21 +779,15 @@ public class ServerData {
|
||||
* @param data the sign data
|
||||
* @param position the sign position
|
||||
*/
|
||||
public void setSign(String count, Location location, Location skullLocation, String data, int position) {
|
||||
|
||||
getData().set("Signs." + count + ".World", location.getWorld().getName());
|
||||
getData().set("Signs." + count + ".X", (int) location.getX());
|
||||
getData().set("Signs." + count + ".Y", (int) location.getY());
|
||||
getData().set("Signs." + count + ".Z", (int) location.getZ());
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
if (skullLocation != null) {
|
||||
getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName());
|
||||
getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX());
|
||||
getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY());
|
||||
getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ());
|
||||
}
|
||||
saveData();
|
||||
public void setSign(String count, Location location, Location skullLocation, String data, int position) {
|
||||
var coordinates = SignCoordinates.capture(location);
|
||||
var skull = skullLocation == null ? null : SignCoordinates.capture(skullLocation);
|
||||
mutateSavedData(() -> {
|
||||
coordinates.store(getData(), "Signs." + count);
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
if (skull != null) skull.store(getData(), "Signs." + count + ".Skull");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,21 +796,20 @@ public class ServerData {
|
||||
* @param count the sign count
|
||||
* @param skullLocation the skull location
|
||||
*/
|
||||
public void setSkullLocation(String count, Location skullLocation) {
|
||||
if (skullLocation != null) {
|
||||
getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName());
|
||||
getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX());
|
||||
getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY());
|
||||
getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ());
|
||||
}
|
||||
public void setSkullLocation(String count, Location skullLocation) {
|
||||
if (skullLocation == null) return;
|
||||
var coordinates = SignCoordinates.capture(skullLocation);
|
||||
mutateData(() -> coordinates.store(getData(), "Signs." + count + ".Skull"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the version.
|
||||
*/
|
||||
public void setVersion() {
|
||||
getData().set("Version", Bukkit.getVersion());
|
||||
saveData();
|
||||
public void setVersion() {
|
||||
String version = Bukkit.getVersion();
|
||||
mutateSavedData(() -> {
|
||||
getData().set("Version", version);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,9 +817,10 @@ public class ServerData {
|
||||
*
|
||||
* @param value the extra required votes
|
||||
*/
|
||||
public void setVotePartyExtraRequired(int value) {
|
||||
getData().set("VotePartyExtraRequired", value);
|
||||
saveData();
|
||||
public void setVotePartyExtraRequired(int value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VotePartyExtraRequired", value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,42 +829,45 @@ public class ServerData {
|
||||
* @param ident the vote shop identifier
|
||||
* @param amount the purchase amount
|
||||
*/
|
||||
public void setVoteShopPurchases(String ident, int amount) {
|
||||
getData().set("VoteShopPurchases." + ident, amount);
|
||||
saveData();
|
||||
public void setVoteShopPurchases(String ident, int amount) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VoteShopPurchases." + ident, amount);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last vote party timestamp to today.
|
||||
*/
|
||||
public void updateLastVoteParty() {
|
||||
getData().set("LastVoteParty", plugin.getTimeChecker().getTime().getDayOfYear());
|
||||
saveData();
|
||||
public void updateLastVoteParty() {
|
||||
int day = plugin.getTimeChecker().getTime().getDayOfYear();
|
||||
setInt("LastVoteParty", day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last vote party week timestamp to this week.
|
||||
*/
|
||||
public void updateLastVotePartyWeek() {
|
||||
getData().set("LastVotePartyWeek",
|
||||
plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear()));
|
||||
saveData();
|
||||
public void updateLastVotePartyWeek() {
|
||||
int week = plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear());
|
||||
setInt("LastVotePartyWeek", week);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates placeholders to lowercase format.
|
||||
*/
|
||||
public void updatePlaceholders() {
|
||||
boolean data = getData().getBoolean("AutoCacheUpdated", false);
|
||||
if (!data) {
|
||||
List<String> placeholders = getAutoCachedPlaceholder();
|
||||
for (int i = 0; i < placeholders.size(); i++) {
|
||||
placeholders.set(i, placeholders.get(i).toLowerCase());
|
||||
}
|
||||
setAutoCachedPlaceholder(placeholders);
|
||||
getData().set("AutoCacheUpdated", true);
|
||||
saveData();
|
||||
}
|
||||
public void updatePlaceholders() {
|
||||
mutateData(() -> {
|
||||
boolean data = getData().getBoolean("AutoCacheUpdated", false);
|
||||
if (!data) {
|
||||
mutateSavedData(() -> {
|
||||
List<String> placeholders = getAutoCachedPlaceholder();
|
||||
for (int i = 0; i < placeholders.size(); i++) {
|
||||
placeholders.set(i, placeholders.get(i).toLowerCase());
|
||||
}
|
||||
getData().set("AutoCachePlaceholders", placeholders);
|
||||
getData().set("AutoCacheUpdated", true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,8 @@ package com.bencodez.votingplugin.discord;
|
||||
import java.awt.Color;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@@ -27,6 +25,7 @@ import github.scarsz.discordsrv.api.events.DiscordReadyEvent;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.EmbedBuilder;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.JDA;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.entities.TextChannel;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.entities.Message;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.requests.ErrorResponse;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.exceptions.ErrorResponseException;
|
||||
import github.scarsz.discordsrv.util.DiscordUtil;
|
||||
@@ -40,8 +39,45 @@ public class DiscordHandler {
|
||||
private final VotingPluginMain plugin;
|
||||
@Getter
|
||||
private final HashMap<TopVoter, Long> topVoterMessageIds = new HashMap<TopVoter, Long>();
|
||||
private final Set<TopVoter> recoveringTopVoters = new HashSet<>();
|
||||
/** Identity, rather than generation alone, owns every request and its callbacks. */
|
||||
private static final class Publication {
|
||||
final long generation;
|
||||
final boolean persistent;
|
||||
boolean sending, editing, recovering, again, completing, retired;
|
||||
long requestedGeneration;
|
||||
final AtomicBoolean sendCompleted = new AtomicBoolean(false);
|
||||
Publication(long generation, boolean persistent, boolean sending) {
|
||||
this.generation = generation; this.requestedGeneration = generation;
|
||||
this.persistent = persistent; this.sending = sending; this.editing = !sending;
|
||||
}
|
||||
}
|
||||
private static final int PENDING = 0, WRITING = 1, RETAINED = 2, DISCARDED = 3, DETACHED = 4;
|
||||
private static final int MAX_OWNED_RECEIPTS = 128;
|
||||
/** External success is owned here before any cancellable worker handoff. */
|
||||
private static final class SentReceipt {
|
||||
final TopVoter top;
|
||||
final Message message;
|
||||
final long id;
|
||||
final long generation;
|
||||
final AtomicBoolean cleanupRequested = new AtomicBoolean(false);
|
||||
int state = PENDING;
|
||||
boolean confirmed;
|
||||
SentReceipt(TopVoter top, Message message, long generation) {
|
||||
this.top = top; this.message = message; this.id = message.getIdLong(); this.generation = generation;
|
||||
}
|
||||
}
|
||||
private final java.util.EnumMap<TopVoter, Publication> publications = new java.util.EnumMap<>(TopVoter.class);
|
||||
/** Guarded by publications; includes uncertain saves until confirmed replacement or explicit detachment. */
|
||||
private final java.util.Set<SentReceipt> ownedReceipts = new java.util.HashSet<>();
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final AtomicBoolean discordReady = new AtomicBoolean(false);
|
||||
private static final class DisplayUpdate {
|
||||
private final long generation;
|
||||
private long requestedGeneration;
|
||||
private boolean again;
|
||||
private DisplayUpdate(long generation) { this.generation = generation; this.requestedGeneration = generation; }
|
||||
}
|
||||
private final java.util.EnumMap<TopVoter, DisplayUpdate> preparing = new java.util.EnumMap<>(TopVoter.class);
|
||||
|
||||
/**
|
||||
* Constructs a new Discord handler.
|
||||
@@ -149,14 +185,347 @@ public class DiscordHandler {
|
||||
* @param top the top voter
|
||||
*/
|
||||
public void updateTopVoterMessageId(TopVoter top) {
|
||||
plugin.extraDebug("Updating Discord Top Voter message for: " + top);
|
||||
prepareTopVoterMessage(top, plugin.getTopVoterDisplayGeneration());
|
||||
}
|
||||
|
||||
long channelId = plugin.getConfigFile().getDiscordSRVTopVoterChannel(top);
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVoters = plugin.getTopVoter(top);
|
||||
private void prepareTopVoterMessage(TopVoter top, long generation) {
|
||||
DisplayUpdate update;
|
||||
synchronized (preparing) {
|
||||
if (!currentGeneration(generation)) return;
|
||||
DisplayUpdate pending = preparing.get(top);
|
||||
if (pending != null) {
|
||||
pending.again = true;
|
||||
pending.requestedGeneration = generation;
|
||||
return;
|
||||
}
|
||||
update = new DisplayUpdate(generation);
|
||||
preparing.put(top, update);
|
||||
}
|
||||
try {
|
||||
plugin.getTopVoterDisplayAsync(top).whenComplete((snapshot, failure) -> {
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
if (!releasePreparation(top, update)) return;
|
||||
if (!currentGeneration(update.generation)) {
|
||||
retryRequestedPreparation(top, update);
|
||||
return;
|
||||
}
|
||||
// Joins and TTL/scope changes can revoke omissions while publication is queued.
|
||||
// Recheck current evidence without opening another asynchronous deadline.
|
||||
if (update.again || (failure == null && snapshot != null)) {
|
||||
try { publishTopVoterMessage(top, plugin.getTopVoterDisplay(top), update.generation); }
|
||||
catch (RuntimeException unavailable) { warnLeaderboardFailure("preparing a current display for " + top); }
|
||||
} else plugin.getLogger().warning("Unable to prepare Discord Top Voters: " + top);
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
if (releasePreparation(top, update)) {
|
||||
try { if (currentGeneration(update.generation)) warnLeaderboardFailure("preparation worker admission for " + top); }
|
||||
finally { retryRequestedPreparation(top, update); }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
if (releasePreparation(top, update)) {
|
||||
try { if (currentGeneration(update.generation)) warnLeaderboardFailure("preparing evidence for " + top); }
|
||||
finally { retryRequestedPreparation(top, update); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String title = plugin.getConfigFile().getDiscordSRVTopVoterTitle(top);
|
||||
private boolean releasePreparation(TopVoter top, DisplayUpdate update) {
|
||||
synchronized (preparing) { return preparing.remove(top, update); }
|
||||
}
|
||||
|
||||
private void retryRequestedPreparation(TopVoter top, DisplayUpdate update) {
|
||||
// The retired request's evidence never changes ownership to a replacement filter.
|
||||
if (update.again && update.requestedGeneration != update.generation)
|
||||
prepareTopVoterMessage(top, update.requestedGeneration);
|
||||
}
|
||||
|
||||
void publishTopVoterMessage(TopVoter top, LinkedHashMap<TopVoterPlayer, Integer> prepared) {
|
||||
publishTopVoterMessage(top, prepared, plugin.getTopVoterDisplayGeneration());
|
||||
}
|
||||
|
||||
void publishTopVoterMessage(TopVoter top, LinkedHashMap<TopVoterPlayer, Integer> prepared, long generation) {
|
||||
if (!currentGeneration(generation)) return;
|
||||
boolean newMessage = plugin.getConfigFile().isDiscordSRVTopVoterNewMessageOnUpdate(top);
|
||||
TextChannel channel;
|
||||
try { channel = leaderboardChannel(plugin.getConfigFile().getDiscordSRVTopVoterChannel(top)); }
|
||||
catch (RuntimeException unavailable) { warnLeaderboardFailure("channel lookup for " + top); return; }
|
||||
if (channel == null) { warnLeaderboardFailure("missing channel for " + top); return; }
|
||||
Publication publication;
|
||||
long existingId;
|
||||
synchronized (publications) {
|
||||
if (!currentGeneration(generation)) return;
|
||||
Publication pending = publications.get(top);
|
||||
if (pending != null && (pending.editing || (!pending.retired && pending.generation == generation
|
||||
&& (pending.sending || pending.completing)))) {
|
||||
pending.again = true;
|
||||
pending.requestedGeneration = generation;
|
||||
return;
|
||||
}
|
||||
existingId = topVoterMessageIds.getOrDefault(top, 0L);
|
||||
publication = new Publication(generation, !newMessage, existingId <= 0 || newMessage);
|
||||
publications.put(top, publication);
|
||||
}
|
||||
try {
|
||||
var embed = buildTopVoterEmbed(plugin.getConfigFile().getDiscordSRVTopVoterTitle(top), plugin.getTopVoterDisplay(top)).build();
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
if (publication.sending) send(top, channel, publication, embed);
|
||||
else channel.editMessageEmbedsById(existingId, embed).queue(
|
||||
message -> finishEdit(top, publication, existingId),
|
||||
error -> handleMessageUpdateFailure(top, channel, publication, error));
|
||||
} catch (RuntimeException unavailable) { failPublication(top, publication, "preparing or submitting a message"); }
|
||||
}
|
||||
|
||||
private boolean current(TopVoter top, Publication publication) {
|
||||
if (!currentGeneration(publication.generation)) return false;
|
||||
synchronized (publications) { return publications.get(top) == publication && !publication.retired; }
|
||||
}
|
||||
|
||||
/** Never hold the publication lock while waiting for the ServerData save monitor. */
|
||||
private boolean persist(TopVoter top, Publication publication, long id) {
|
||||
var data = plugin.getServerData();
|
||||
synchronized (data) {
|
||||
if (!current(top, publication)) return false;
|
||||
data.setTopVoterMessageId(top, id);
|
||||
}
|
||||
return current(top, publication);
|
||||
}
|
||||
|
||||
private void finish(TopVoter top, Publication publication) {
|
||||
boolean again, prepareAgain;
|
||||
long generation;
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) != publication) return;
|
||||
publications.remove(top);
|
||||
again = publication.again;
|
||||
generation = publication.requestedGeneration;
|
||||
prepareAgain = publication.retired || generation != publication.generation;
|
||||
}
|
||||
// A queued request is independent of whether the previous network operation succeeded.
|
||||
if (again) {
|
||||
if (prepareAgain) prepareTopVoterMessage(top, generation);
|
||||
else dispatchLeaderboard(generation,
|
||||
() -> publishTopVoterMessage(top, plugin.getTopVoterDisplay(top), generation), () -> { });
|
||||
}
|
||||
}
|
||||
|
||||
private void warnLeaderboardFailure(String stage) {
|
||||
// Provider exceptions can contain request URLs or message content. Report the phase only.
|
||||
plugin.getLogger().warning("Unable to publish Discord Top Voters (" + stage
|
||||
+ "). Check the Discord channel, permissions and configuration; a later update can retry.");
|
||||
}
|
||||
|
||||
private void failPublication(TopVoter top, Publication publication, String stage) {
|
||||
try { if (current(top, publication)) warnLeaderboardFailure(stage + " for " + top); }
|
||||
finally { finish(top, publication); }
|
||||
}
|
||||
|
||||
private void send(TopVoter top, TextChannel channel, Publication publication,
|
||||
github.scarsz.discordsrv.dependencies.jda.api.entities.MessageEmbed embed) {
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
channel.sendMessageEmbeds(embed).queue(message -> {
|
||||
if (!publication.sendCompleted.compareAndSet(false, true)) return;
|
||||
SentReceipt receipt = new SentReceipt(top, message, publication.generation);
|
||||
boolean admitted;
|
||||
synchronized (publications) {
|
||||
admitted = !closed.get() && (!publication.persistent || ownedReceipts.size() < MAX_OWNED_RECEIPTS);
|
||||
if (admitted && publication.persistent) ownedReceipts.add(receipt);
|
||||
publication.completing = true;
|
||||
}
|
||||
if (!admitted) {
|
||||
discardCreatedMessage(publication, receipt); finish(top, publication); return;
|
||||
}
|
||||
dispatchLeaderboard(publication.generation, () -> {
|
||||
try {
|
||||
if (!current(top, publication)) return;
|
||||
if (publication.persistent) { retainCreatedMessage(top, publication, receipt); return; }
|
||||
if (!current(top, publication)) return;
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) == publication) topVoterMessageIds.put(top, receipt.id);
|
||||
}
|
||||
} catch (RuntimeException failure) { warnLeaderboardFailure("storing a message ID for " + top); }
|
||||
finally { discardCreatedMessage(publication, receipt); finish(top, publication); }
|
||||
}, () -> { discardCreatedMessage(publication, receipt); finish(top, publication); });
|
||||
}, failure -> failPublication(top, publication, "sending a message"));
|
||||
}
|
||||
|
||||
/** Retry an uncertain ID save after a successful edit, without creating another external post. */
|
||||
private void finishEdit(TopVoter top, Publication publication, long id) {
|
||||
synchronized (publications) { publication.editing = false; publication.completing = true; }
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
SentReceipt retry;
|
||||
synchronized (publications) {
|
||||
retry = ownedReceipts.stream().filter(receipt -> receipt.top == top && receipt.id == id
|
||||
&& receipt.state == RETAINED && !receipt.confirmed).findFirst().orElse(null);
|
||||
}
|
||||
if (retry == null) { finish(top, publication); return; }
|
||||
dispatchLeaderboard(publication.generation, () -> {
|
||||
try { retainCreatedMessage(top, publication, retry); }
|
||||
finally { discardCreatedMessage(publication, retry); finish(top, publication); }
|
||||
}, () -> finish(top, publication));
|
||||
}
|
||||
|
||||
/** Never hold publication ownership while waiting for the existing save monitor or file I/O. */
|
||||
private void retainCreatedMessage(TopVoter top, Publication publication, SentReceipt receipt) {
|
||||
var obsolete = new java.util.ArrayList<SentReceipt>();
|
||||
var data = plugin.getServerData();
|
||||
boolean uncertain = false;
|
||||
synchronized (data) {
|
||||
if (!current(top, publication)) return;
|
||||
synchronized (publications) {
|
||||
if (!current(top, publication) || (receipt.state != PENDING && receipt.state != RETAINED)) return;
|
||||
receipt.state = WRITING;
|
||||
}
|
||||
try {
|
||||
boolean confirmed = data.setTopVoterMessageIdConfirmed(top, receipt.id);
|
||||
long actual = data.getTopVoterMessageId(top);
|
||||
synchronized (publications) {
|
||||
if (receipt.state == DETACHED) return;
|
||||
// A reentrant newer confirmed write already owns the file. Reconcile only this older post.
|
||||
if (actual != receipt.id && ownedReceipts.stream().anyMatch(other -> other != receipt
|
||||
&& other.top == top && other.id == actual && other.state == RETAINED && other.confirmed)) return;
|
||||
receipt.state = RETAINED;
|
||||
receipt.confirmed = confirmed && actual == receipt.id;
|
||||
uncertain = !receipt.confirmed;
|
||||
if (actual == receipt.id && (publications.get(top) == publication
|
||||
|| topVoterMessageIds.getOrDefault(top, 0L) <= 0)) topVoterMessageIds.put(top, receipt.id);
|
||||
if (receipt.confirmed) {
|
||||
var iterator = ownedReceipts.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
SentReceipt previous = iterator.next();
|
||||
if (previous != receipt && previous.top == top && previous.state == RETAINED) {
|
||||
previous.state = DISCARDED; iterator.remove(); obsolete.add(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
// The legacy writer may have mutated or saved before throwing; do not delete a possibly recorded post.
|
||||
synchronized (publications) {
|
||||
if (receipt.state == DETACHED) return;
|
||||
receipt.state = RETAINED; receipt.confirmed = false;
|
||||
if (publications.get(top) == publication && topVoterMessageIds.getOrDefault(top, 0L) <= 0)
|
||||
topVoterMessageIds.put(top, receipt.id);
|
||||
}
|
||||
uncertain = true;
|
||||
}
|
||||
}
|
||||
obsolete.forEach(this::removeCreatedMessage);
|
||||
if (uncertain) warnMessageStorage();
|
||||
}
|
||||
|
||||
private void discardCreatedMessage(Publication publication, SentReceipt receipt) {
|
||||
if (!publication.persistent) return; // Append mode deliberately keeps its posts.
|
||||
synchronized (publications) {
|
||||
if (receipt.state != PENDING && receipt.state != WRITING) return;
|
||||
receipt.state = DISCARDED; ownedReceipts.remove(receipt);
|
||||
}
|
||||
removeCreatedMessage(receipt);
|
||||
}
|
||||
|
||||
/** Detach the administrator's old posts; clearing an ID never authorizes deleting those posts. */
|
||||
public boolean clearTopVoterMessageId(TopVoter top) {
|
||||
var data = plugin.getServerData();
|
||||
synchronized (data) {
|
||||
synchronized (publications) {
|
||||
publications.remove(top);
|
||||
synchronized (preparing) { preparing.remove(top); }
|
||||
topVoterMessageIds.put(top, 0L);
|
||||
ownedReceipts.removeIf(receipt -> {
|
||||
if (receipt.top != top || (receipt.state != PENDING && receipt.state != RETAINED
|
||||
&& receipt.state != WRITING)) return false;
|
||||
receipt.state = DETACHED; return true;
|
||||
});
|
||||
}
|
||||
try { if (data.setTopVoterMessageIdConfirmed(top, 0L)) return true; }
|
||||
catch (RuntimeException failure) { /* The outgoing diagnostic remains redacted. */ }
|
||||
}
|
||||
warnMessageStorage(); return false;
|
||||
}
|
||||
|
||||
/** Retire invalidated generations before worker cancellation; close invalidates every generation. */
|
||||
public void retireLeaderboardPublications() {
|
||||
var abandoned = new java.util.ArrayList<SentReceipt>();
|
||||
var restart = new java.util.EnumMap<TopVoter, Long>(TopVoter.class);
|
||||
synchronized (publications) {
|
||||
// An admitted edit can still write on Discord after retirement. Keep its
|
||||
// bounded slot until its network callback rather than submitting overlapping edits.
|
||||
publications.entrySet().removeIf(entry -> {
|
||||
Publication publication = entry.getValue();
|
||||
if (currentGeneration(publication.generation) && !publication.retired) return false;
|
||||
boolean again = publication.again && currentGeneration(publication.requestedGeneration);
|
||||
if (!publication.editing) {
|
||||
if (again) restart.put(entry.getKey(), publication.requestedGeneration);
|
||||
return true;
|
||||
}
|
||||
publication.retired = true;
|
||||
publication.again = again;
|
||||
return false;
|
||||
});
|
||||
synchronized (preparing) {
|
||||
preparing.entrySet().removeIf(entry -> {
|
||||
DisplayUpdate update = entry.getValue();
|
||||
if (currentGeneration(update.generation)) return false;
|
||||
if (update.again && currentGeneration(update.requestedGeneration))
|
||||
restart.put(entry.getKey(), update.requestedGeneration);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
var iterator = ownedReceipts.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
SentReceipt receipt = iterator.next();
|
||||
if (receipt.state == PENDING && !currentGeneration(receipt.generation)) {
|
||||
receipt.state = DISCARDED; iterator.remove(); abandoned.add(receipt);
|
||||
}
|
||||
}
|
||||
}
|
||||
abandoned.forEach(this::removeCreatedMessage);
|
||||
// Old worker completions can be cancelled; transfer explicit requests now,
|
||||
// outside both ownership monitors and without waiting for those callbacks.
|
||||
restart.forEach(this::prepareTopVoterMessage);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
synchronized (publications) {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
}
|
||||
retireLeaderboardPublications();
|
||||
boolean uncertain;
|
||||
synchronized (publications) {
|
||||
uncertain = ownedReceipts.stream().anyMatch(receipt -> receipt.state == WRITING || !receipt.confirmed);
|
||||
}
|
||||
if (uncertain) warnMessageStorage();
|
||||
}
|
||||
|
||||
private void warnMessageStorage() {
|
||||
plugin.getLogger().warning("Unable to confirm Discord Top Voters message ID storage. "
|
||||
+ "The post remains tracked in memory; check storage before restarting and reconcile posts manually.");
|
||||
}
|
||||
|
||||
/** JDA owns asynchronous deletion; cleanup does not depend on the retiring plugin worker. */
|
||||
private void removeCreatedMessage(SentReceipt receipt) {
|
||||
if (!receipt.cleanupRequested.compareAndSet(false, true)) return;
|
||||
try {
|
||||
receipt.message.delete().queue(ignored -> { }, failure -> {
|
||||
if (!(failure instanceof ErrorResponseException response)
|
||||
|| response.getErrorResponse() != ErrorResponse.UNKNOWN_MESSAGE) warnMessageCleanup();
|
||||
});
|
||||
} catch (RuntimeException failure) { warnMessageCleanup(); }
|
||||
}
|
||||
|
||||
private void warnMessageCleanup() {
|
||||
plugin.getLogger().warning("Unable to remove an untracked Discord Top Voters message. "
|
||||
+ "Check Discord permissions and remove the unwanted post manually.");
|
||||
}
|
||||
|
||||
TextChannel leaderboardChannel(long channelId) {
|
||||
return DiscordUtil.getJda().getTextChannelById(channelId);
|
||||
}
|
||||
|
||||
/** Render an inspected display snapshot without accessing the Discord runtime. */
|
||||
static EmbedBuilder buildTopVoterEmbed(String title, LinkedHashMap<TopVoterPlayer, Integer> topVoters) {
|
||||
EmbedBuilder eb = new EmbedBuilder().setTitle(title).setColor(Color.CYAN).setTimestamp(Instant.now());
|
||||
|
||||
if (topVoters == null || topVoters.isEmpty()) {
|
||||
@@ -187,34 +556,7 @@ public class DiscordHandler {
|
||||
eb.setDescription(sb.toString()).setFooter("Top 10 • Updated");
|
||||
}
|
||||
|
||||
TextChannel channel = DiscordUtil.getJda().getTextChannelById(channelId);
|
||||
if (channel == null) {
|
||||
plugin.getLogger().warning("Discord channel ID " + channelId + " not found: " + top);
|
||||
return;
|
||||
}
|
||||
|
||||
long existingId;
|
||||
synchronized (recoveringTopVoters) {
|
||||
if (recoveringTopVoters.contains(top)) {
|
||||
plugin.debug("Skipping Discord Top Voter update while recovery is in progress: " + top);
|
||||
return;
|
||||
}
|
||||
existingId = topVoterMessageIds.getOrDefault(top, 0L);
|
||||
}
|
||||
|
||||
if (existingId <= 0 || newMessage) {
|
||||
channel.sendMessageEmbeds(eb.build()).queue(msg -> {
|
||||
long newId = msg.getIdLong();
|
||||
topVoterMessageIds.put(top, newId);
|
||||
if (!newMessage)
|
||||
plugin.getServerData().setTopVoterMessageId(top, newId);
|
||||
plugin.debug("Posted new Top Voters " + top + " (ID: " + newId + ")");
|
||||
}, err -> plugin.getLogger().warning("Error sending Top Voters " + top + ": " + err.getMessage()));
|
||||
} else {
|
||||
channel.editMessageEmbedsById(existingId, eb.build()).queue(
|
||||
m -> plugin.debug("Edited Top Voters " + top + " (ID: " + existingId + ")"),
|
||||
err -> handleMessageUpdateFailure(top, channel, eb, existingId, err));
|
||||
}
|
||||
return eb;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,41 +566,65 @@ public class DiscordHandler {
|
||||
* condition as recoverable so one deleted message cannot permanently stop
|
||||
* leaderboard updates.
|
||||
*/
|
||||
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, EmbedBuilder eb, long existingId,
|
||||
Throwable error) {
|
||||
if (plugin.getConfigFile().isDiscordSRVTopVoterAutoRecoverMessageOnFailure()
|
||||
&& error instanceof ErrorResponseException
|
||||
&& ((ErrorResponseException) error).getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) {
|
||||
synchronized (recoveringTopVoters) {
|
||||
if (topVoterMessageIds.getOrDefault(top, 0L) != existingId || !recoveringTopVoters.add(top)) {
|
||||
plugin.debug("Skipping stale Discord Top Voter recovery because another update handled: " + top);
|
||||
return;
|
||||
private boolean currentGeneration(long generation) {
|
||||
return !closed.get() && plugin.isEnabled() && plugin.getTopVoterDisplayGeneration() == generation;
|
||||
}
|
||||
|
||||
private void dispatchLeaderboard(long generation, Runnable action, Runnable discarded) {
|
||||
if (!currentGeneration(generation)) { discarded.run(); return; }
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
if (currentGeneration(generation)) action.run(); else discarded.run();
|
||||
} catch (RuntimeException failure) {
|
||||
try { if (currentGeneration(generation)) warnLeaderboardFailure("asynchronous publication"); }
|
||||
finally { discarded.run(); }
|
||||
}
|
||||
}
|
||||
plugin.getLogger().warning("Discord Top Voters " + top + " message " + existingId
|
||||
+ " no longer exists; clearing the stored ID and posting a replacement.");
|
||||
synchronized (recoveringTopVoters) {
|
||||
topVoterMessageIds.put(top, 0L);
|
||||
}
|
||||
plugin.getServerData().setTopVoterMessageId(top, 0L);
|
||||
channel.sendMessageEmbeds(eb.build()).queue(msg -> {
|
||||
long newId = msg.getIdLong();
|
||||
synchronized (recoveringTopVoters) {
|
||||
topVoterMessageIds.put(top, newId);
|
||||
plugin.getServerData().setTopVoterMessageId(top, newId);
|
||||
recoveringTopVoters.remove(top);
|
||||
}
|
||||
plugin.debug("Recovered Top Voters " + top + " with replacement message (ID: " + newId + ")");
|
||||
}, sendError -> {
|
||||
synchronized (recoveringTopVoters) {
|
||||
recoveringTopVoters.remove(top);
|
||||
}
|
||||
plugin.getLogger().warning("Error recovering Top Voters " + top + ": " + sendError.getMessage());
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
try { if (currentGeneration(generation)) warnLeaderboardFailure("worker admission"); }
|
||||
finally { discarded.run(); }
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, Publication publication,
|
||||
Throwable error) {
|
||||
synchronized (publications) { publication.editing = false; }
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
if (!plugin.getConfigFile().isDiscordSRVTopVoterAutoRecoverMessageOnFailure()
|
||||
|| !(error instanceof ErrorResponseException response)
|
||||
|| response.getErrorResponse() != ErrorResponse.UNKNOWN_MESSAGE) {
|
||||
failPublication(top, publication, "editing a message");
|
||||
return;
|
||||
}
|
||||
|
||||
plugin.getLogger().warning("Error editing Top Voters " + top + ": " + error.getMessage());
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) != publication || publication.recovering) return;
|
||||
publication.recovering = true;
|
||||
publication.sending = true;
|
||||
}
|
||||
dispatchLeaderboard(publication.generation, () -> {
|
||||
try {
|
||||
if (!persist(top, publication, 0L)) { finish(top, publication); return; }
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) != publication) return;
|
||||
topVoterMessageIds.put(top, 0L);
|
||||
}
|
||||
// Persistence can block while a join, TTL, reload or disable revokes the earlier frame.
|
||||
var replacement = buildTopVoterEmbed(plugin.getConfigFile().getDiscordSRVTopVoterTitle(top),
|
||||
plugin.getTopVoterDisplay(top)).build();
|
||||
send(top, channel, publication, replacement);
|
||||
} catch (RuntimeException failure) { failPublication(top, publication, "recovering a deleted message"); }
|
||||
}, () -> {
|
||||
// Rejected admission has no effects; the same failed edit may be retried.
|
||||
boolean again = false;
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) == publication) {
|
||||
publication.recovering = false; publication.sending = false;
|
||||
again = publication.again;
|
||||
}
|
||||
}
|
||||
if (again) finish(top, publication);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.bencodez.votingplugin.listeners;
|
||||
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -135,9 +134,8 @@ public class VotiferEvent implements Listener {
|
||||
plugin.debug("VoteSite: " + voteSite);
|
||||
plugin.debug("IP: " + IP);
|
||||
|
||||
try {
|
||||
plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
if (!com.bencodez.votingplugin.util.VoteTaskAdmission.trySubmit(plugin.getVoteTimer(),
|
||||
() -> processVote(voteSite, voteUsername))) {
|
||||
VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue();
|
||||
if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) {
|
||||
plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for "
|
||||
|
||||
+240
-33
@@ -67,6 +67,8 @@ public class PlaceHolders {
|
||||
private record PlatformUpdateKey(UUID uuid, PlaceHolder<VotingPluginUser> placeholder) { }
|
||||
private final ConcurrentHashMap<PlatformUpdateKey, Long> platformUpdateGenerations = new ConcurrentHashMap<>();
|
||||
private boolean userDataChangeListenerRegistered;
|
||||
private final PointPlaceholderFence pointFence = new PointPlaceholderFence();
|
||||
private final ConcurrentHashMap<UUID, Object> pointRecoveryWorkers = new ConcurrentHashMap<>();
|
||||
|
||||
@Getter
|
||||
private PlaceholderCacheLevel cacheLevel;
|
||||
@@ -185,6 +187,103 @@ public class PlaceHolders {
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot-only point balance for direct API users on Bukkit/Folia threads.
|
||||
* A pending/uncertain debit or incomplete publication is unavailable, not
|
||||
* a genuine zero. This shares the same mutation fence as point placeholders.
|
||||
*
|
||||
* @param user the requested user
|
||||
* @return a published point balance, or empty until it is safe to read
|
||||
*/
|
||||
public java.util.OptionalInt getPointBalanceIfReady(VotingPluginUser user) {
|
||||
if (user == null || !plugin.isEnabled() || plugin.getPlaceholders() != this) {
|
||||
return java.util.OptionalInt.empty();
|
||||
}
|
||||
UUID uuid = user.getJavaUUID();
|
||||
var generation = pointFence.generation(uuid);
|
||||
try {
|
||||
if (!pointFence.readable(uuid, generation)) {
|
||||
if (pointFence.recoverable(uuid)) schedulePointRecovery(user);
|
||||
return java.util.OptionalInt.empty();
|
||||
}
|
||||
var snapshot = publishedPointSnapshot(user);
|
||||
if (snapshot == null) {
|
||||
schedulePointRecovery(user);
|
||||
return java.util.OptionalInt.empty();
|
||||
}
|
||||
Integer amount = pointBalance(snapshot, user.getPointsPath());
|
||||
if (amount == null || !pointFence.readable(uuid, generation)) {
|
||||
return java.util.OptionalInt.empty();
|
||||
}
|
||||
return java.util.OptionalInt.of(amount);
|
||||
} finally {
|
||||
pointFence.release(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.Map<String, com.bencodez.simpleapi.sql.data.DataValue> 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<String, com.bencodez.simpleapi.sql.data.DataValue> 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);
|
||||
@@ -205,6 +304,12 @@ public class PlaceHolders {
|
||||
return "no player";
|
||||
}
|
||||
|
||||
// These ranks use bounded snapshot projections; persistent result caches outlive presence evidence.
|
||||
if (plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore() && switch (identifier.toLowerCase(Locale.ROOT)) {
|
||||
case "top_all_position", "top_month_position", "top_lastmonth_position", "top_week_position",
|
||||
"top_daily_position" -> true;
|
||||
default -> false;
|
||||
}) useCache = false;
|
||||
VotingPluginUser user = resolvePlaceholderUser(p);
|
||||
if (useCache) {
|
||||
if (!cachedPlaceholders.contains(identifier)) {
|
||||
@@ -492,7 +597,8 @@ public class PlaceHolders {
|
||||
|
||||
@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"));
|
||||
|
||||
@@ -501,7 +607,8 @@ public class PlaceHolders {
|
||||
@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"));
|
||||
|
||||
@@ -609,7 +716,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -627,7 +734,9 @@ public class PlaceHolders {
|
||||
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";
|
||||
@@ -705,7 +814,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -724,7 +833,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -740,7 +849,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -757,7 +866,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -775,7 +884,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -791,7 +900,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -812,7 +921,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -828,7 +937,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -844,7 +953,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -863,7 +972,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -879,7 +988,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -895,7 +1004,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -914,7 +1023,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -938,7 +1047,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -954,7 +1063,7 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getVoteParty().getTotalVotes());
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay().total());
|
||||
}
|
||||
}.withDescription("Current amount of voteparty votes"));
|
||||
|
||||
@@ -962,7 +1071,8 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getVoteParty().getNeededVotes());
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay()
|
||||
.needed(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()));
|
||||
}
|
||||
}.withDescription("Voteparty votes needed"));
|
||||
|
||||
@@ -970,7 +1080,8 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getVoteParty().getVotesRequired());
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay()
|
||||
.required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()));
|
||||
}
|
||||
}.withDescription("Amount of votes needed for voteparty"));
|
||||
|
||||
@@ -1407,6 +1518,13 @@ public class PlaceHolders {
|
||||
@SuppressWarnings("unchecked")
|
||||
private void updateCachedPlaceholder(PlaceHolder<VotingPluginUser> 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<UUID, String> values = placeholder.getCache().get(ident);
|
||||
if (values == null) continue;
|
||||
@@ -1427,7 +1545,11 @@ public class PlaceHolders {
|
||||
}
|
||||
} 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;
|
||||
@@ -1436,6 +1558,9 @@ public class PlaceHolders {
|
||||
+ matchingKey(placeholder, keys) + " with " + value);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pointFence.release(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private void publishCachedValue(PlaceHolder<VotingPluginUser> placeholder, VotingPluginUser user, UUID uuid,
|
||||
@@ -1460,6 +1585,52 @@ public class PlaceHolders {
|
||||
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<Boolean> 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<VotingPluginUser> 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<Boolean> 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
|
||||
@@ -1505,11 +1676,12 @@ public class PlaceHolders {
|
||||
/**
|
||||
* Update placeholders for specific user.
|
||||
* @param user the voting plugin user
|
||||
* @param login whether this is a login update
|
||||
* @param login whether to include uncached entries, such as a login or worker warmup
|
||||
*/
|
||||
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<PlaceHolder<VotingPluginUser>> platformUpdates = new ArrayList<>();
|
||||
@@ -1551,6 +1723,8 @@ public class PlaceHolders {
|
||||
* Reload placeholders.
|
||||
*/
|
||||
public void reload() {
|
||||
pointFence.invalidateLifecycle();
|
||||
pointRecoveryWorkers.clear();
|
||||
platformUpdateGenerations.clear();
|
||||
plugin.refreshPlaceholderPlayerPresence();
|
||||
cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel();
|
||||
@@ -1584,14 +1758,47 @@ public class PlaceHolders {
|
||||
* 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();
|
||||
onUpdate(user, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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<UUID, State> 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();
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -123,8 +123,7 @@ public class ServiceSiteHandler {
|
||||
|
||||
public void saveToServerData() {
|
||||
List<String> out = toListFormat(sitesRef.get());
|
||||
plugin.getServerData().getData().set(SERVERDATA_PATH, out);
|
||||
plugin.getServerData().saveData();
|
||||
plugin.getServerData().setStringList(SERVERDATA_PATH, out);
|
||||
}
|
||||
|
||||
public void refreshFromWebOnce(boolean logMore) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import org.bukkit.block.Skull;
|
||||
import org.bukkit.block.data.Directional;
|
||||
import org.bukkit.inventory.meta.SkullMeta;
|
||||
|
||||
import com.bencodez.advancedcore.api.misc.MiscUtils;
|
||||
import com.bencodez.simpleapi.array.ArrayUtils;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
@@ -25,14 +24,14 @@ public class SignHandler {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private String data;
|
||||
private volatile String data;
|
||||
|
||||
/** The lines. */
|
||||
private ArrayList<String> lines;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private Location location;
|
||||
private volatile Location location;
|
||||
|
||||
private String playerName;
|
||||
|
||||
@@ -48,7 +47,7 @@ public class SignHandler {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private Location skullLocation;
|
||||
private volatile Location skullLocation;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -56,6 +55,88 @@ public class SignHandler {
|
||||
|
||||
private int votes;
|
||||
|
||||
private record Render(ArrayList<String> lines, String name, UUID identity, int votes) { }
|
||||
private record DisplayUpdate(long generation, Location location, String data) { }
|
||||
private DisplayUpdate preparing;
|
||||
private long skullSequence;
|
||||
private static final int MAX_DISCOVERED_SKULLS = 28;
|
||||
private final java.util.Set<Location> displayedSkulls = new java.util.LinkedHashSet<>();
|
||||
private SkullFrame lastSkullFrame;
|
||||
private record SkullFrame(long sequence, long generation, Location origin, Location selected, String data,
|
||||
String name, UUID identity) { }
|
||||
|
||||
private synchronized SkullFrame skullFrame(String name, UUID identity) {
|
||||
var frame = new SkullFrame(++skullSequence, plugin.getTopVoterDisplayGeneration(), location.clone(),
|
||||
skullLocation == null ? null : skullLocation.clone(), data, name, identity);
|
||||
if (lastSkullFrame == null || lastSkullFrame.generation() != frame.generation()
|
||||
|| !java.util.Objects.equals(lastSkullFrame.origin(), frame.origin())
|
||||
|| !java.util.Objects.equals(lastSkullFrame.selected(), frame.selected())
|
||||
|| !java.util.Objects.equals(lastSkullFrame.data(), frame.data())) displayedSkulls.clear();
|
||||
lastSkullFrame = frame;
|
||||
return frame;
|
||||
}
|
||||
|
||||
/** Remember successful automatic publications, never arbitrary neighboring skulls. */
|
||||
private synchronized void rememberSkull(SkullFrame frame, Location target) {
|
||||
if (lastSkullFrame == frame && frame.selected() == null && displayedSkulls.size() < MAX_DISCOVERED_SKULLS)
|
||||
displayedSkulls.add(target.clone());
|
||||
}
|
||||
|
||||
private synchronized void forgetSkull(SkullFrame frame, Location target) {
|
||||
if (lastSkullFrame == frame) displayedSkulls.remove(target);
|
||||
}
|
||||
|
||||
private synchronized boolean assignedSkull(SkullFrame frame, Location target) {
|
||||
return lastSkullFrame == frame && (java.util.Objects.equals(frame.selected(), target)
|
||||
|| displayedSkulls.contains(target));
|
||||
}
|
||||
|
||||
private synchronized ArrayList<Location> assignedSkulls(SkullFrame frame) {
|
||||
if (lastSkullFrame != frame) return new ArrayList<>();
|
||||
return new ArrayList<>(frame.selected() == null ? displayedSkulls : java.util.List.of(frame.selected()));
|
||||
}
|
||||
|
||||
private boolean current(SkullFrame frame) {
|
||||
if (!plugin.isEnabled() || plugin.getTopVoterDisplayGeneration() != frame.generation()) return false;
|
||||
synchronized (this) {
|
||||
return skullSequence == frame.sequence() && java.util.Objects.equals(location, frame.origin())
|
||||
&& java.util.Objects.equals(skullLocation, frame.selected())
|
||||
&& java.util.Objects.equals(data, frame.data());
|
||||
}
|
||||
}
|
||||
|
||||
/** Only coordinates cross regions; every block access occurs inside its own location task. */
|
||||
private void onOwner(SkullFrame frame, Location target, Runnable action) {
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
if (current(frame)) action.run();
|
||||
}, target);
|
||||
} catch (RuntimeException rejected) { plugin.debug("Unable to schedule skull display"); }
|
||||
}
|
||||
|
||||
private void updateSkull(SkullFrame frame, Location target, Runnable absent) {
|
||||
onOwner(frame, target, () -> {
|
||||
Block block = target.getBlock();
|
||||
if (!(block.getState() instanceof Skull)) {
|
||||
forgetSkull(frame, target);
|
||||
if (absent != null) absent.run();
|
||||
return;
|
||||
}
|
||||
String name = frame.name(); UUID identity = frame.identity();
|
||||
// Queued region work may outlive the evidence used by the sign render.
|
||||
TopVoter period = displayPeriod();
|
||||
if (period != null && position > 0 && plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
var rows = new ArrayList<>(plugin.getTopVoterDisplay(period).keySet());
|
||||
if (rows.size() >= position) {
|
||||
name = rows.get(position - 1).getPlayerName(); identity = rows.get(position - 1).getUuid();
|
||||
} else { name = "No Player"; identity = null; }
|
||||
}
|
||||
if (!current(frame)) return;
|
||||
if (!checkSkull(block, target, name, identity, frame) && absent != null) absent.run();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new SignHandler.
|
||||
*
|
||||
@@ -81,19 +162,28 @@ public class SignHandler {
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private boolean checkSkull(Block block) {
|
||||
if (block.getState() instanceof Skull) {
|
||||
if (!playerName.equals("No Player")) {
|
||||
Skull skull = (Skull) block.getState();
|
||||
private boolean checkSkull(Block block, Location target, String name, UUID identity, SkullFrame frame) {
|
||||
if (block.getState() instanceof Skull skull) {
|
||||
if (name.isEmpty() || name.equalsIgnoreCase("No Player")) {
|
||||
if (plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
if (!current(frame) || !assignedSkull(frame, target)) return true;
|
||||
skull.setOwnerProfile(null);
|
||||
if (current(frame) && skull.update(true, false)) forgetSkull(frame, target);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!name.equals("No Player")) {
|
||||
try {
|
||||
SkullMeta meta = (SkullMeta) plugin.getSkullCacheHandler().getSkull(uuid, playerName).getItemMeta();
|
||||
skull.setOwnerProfile(meta.getOwnerProfile());
|
||||
skull.update(true, false);
|
||||
SkullMeta meta = (SkullMeta) plugin.getSkullCacheHandler().getSkull(identity, name).getItemMeta();
|
||||
var profile = java.util.Objects.requireNonNull(meta.getOwnerProfile(), "Skull profile unavailable");
|
||||
if (!current(frame)) return true;
|
||||
skull.setOwnerProfile(profile);
|
||||
if (current(frame) && skull.update(true, false)) rememberSkull(frame, target);
|
||||
} catch (Exception e) {
|
||||
plugin.debug("Failed to set skull for " + playerName);
|
||||
plugin.debug("Failed to set skull for " + name);
|
||||
plugin.debug(e);
|
||||
skull.setOwner(playerName);
|
||||
skull.update(true, false);
|
||||
if (!current(frame)) return true;
|
||||
if (skull.setOwner(name) && current(frame) && skull.update(true, false)) rememberSkull(frame, target);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -106,18 +196,24 @@ public class SignHandler {
|
||||
* Check skulls.
|
||||
*/
|
||||
public void checkSkulls() {
|
||||
if (noNameAvailable()) {
|
||||
return;
|
||||
}
|
||||
if (skullLocation != null) {
|
||||
checkSkull(skullLocation.getBlock());
|
||||
return;
|
||||
}
|
||||
Render rendered = currentRender();
|
||||
checkSkulls(rendered.name(), rendered.identity());
|
||||
}
|
||||
|
||||
Location loc = location;
|
||||
private void checkSkulls(String name, UUID identity) {
|
||||
if ((name.equalsIgnoreCase("No Player") || name.isEmpty())
|
||||
&& !plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
return;
|
||||
}
|
||||
SkullFrame frame = skullFrame(name, identity);
|
||||
if (frame.selected() != null) {
|
||||
updateSkull(frame, frame.selected(), null);
|
||||
return;
|
||||
}
|
||||
Location loc = frame.origin();
|
||||
Location loc1 = new Location(loc.getWorld(), loc.getBlockX() - 1, loc.getBlockY() - 1, loc.getBlockZ() - 1);
|
||||
Location loc2 = new Location(loc.getWorld(), loc.getBlockX() + 1, loc.getBlockY() + 1, loc.getBlockZ() + 1);
|
||||
updateSkulls(loc1, loc2);
|
||||
updateSkulls(loc1, loc2, frame);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +286,12 @@ public class SignHandler {
|
||||
/**
|
||||
* Store sign.
|
||||
*/
|
||||
/** Capture platform state on the caller before shutdown transfers publication ownership. */
|
||||
public com.bencodez.votingplugin.data.ServerData.SignSnapshot captureShutdownSnapshot() {
|
||||
return com.bencodez.votingplugin.data.ServerData.captureSignSnapshot(
|
||||
sign, location, skullLocation, data, position);
|
||||
}
|
||||
|
||||
public void storeSign() {
|
||||
plugin.getServerData().setSign(sign, location, skullLocation, data, position);
|
||||
}
|
||||
@@ -198,95 +300,131 @@ public class SignHandler {
|
||||
* Update lines.
|
||||
*/
|
||||
public void updateLines() {
|
||||
lines = new ArrayList<>();
|
||||
checkValidSign();
|
||||
if (position != 0) {
|
||||
String line1 = plugin.getConfigFile().getFormatSignTopVoterSignLine1();
|
||||
String line2 = plugin.getConfigFile().getFormatSignTopVoterSignLine2();
|
||||
String line3 = plugin.getConfigFile().getFormatSignTopVoterSignLine3();
|
||||
String line4 = plugin.getConfigFile().getFormatSignTopVoterSignLine4();
|
||||
lines.add(line1);
|
||||
lines.add(line2);
|
||||
lines.add(line3);
|
||||
lines.add(line4);
|
||||
|
||||
ArrayList<TopVoterPlayer> users = null;
|
||||
if (data.equalsIgnoreCase("all")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.AllTime).keySet());
|
||||
} else if (data.equalsIgnoreCase("monthly")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.Monthly).keySet());
|
||||
} else if (data.equalsIgnoreCase("weekly")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.Weekly).keySet());
|
||||
} else if (data.equalsIgnoreCase("daily")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.Daily).keySet());
|
||||
}
|
||||
|
||||
if (users != null && users.size() >= position) {
|
||||
TopVoterPlayer user = users.get(position - 1);
|
||||
playerName = user.getPlayerName();
|
||||
uuid = user.getUuid();
|
||||
|
||||
votes = 0;
|
||||
if (data.equalsIgnoreCase("all")) {
|
||||
votes = plugin.getTopVoter(TopVoter.AllTime).get(user);
|
||||
} else if (data.equalsIgnoreCase("monthly")) {
|
||||
votes = plugin.getTopVoter(TopVoter.Monthly).get(user);
|
||||
} else if (data.equalsIgnoreCase("weekly")) {
|
||||
votes = plugin.getTopVoter(TopVoter.Weekly).get(user);
|
||||
} else if (data.equalsIgnoreCase("daily")) {
|
||||
votes = plugin.getTopVoter(TopVoter.Daily).get(user);
|
||||
}
|
||||
|
||||
for (int j = 0; j < lines.size(); j++) {
|
||||
lines.set(j, lines.get(j).replace("%votes%", "" + votes).replace("%player%", playerName));
|
||||
}
|
||||
} else {
|
||||
playerName = "No Player";
|
||||
votes = 0;
|
||||
for (int j = 0; j < lines.size(); j++) {
|
||||
lines.set(j, lines.get(j).replace("%votes%", "" + votes).replace("%player%", playerName));
|
||||
}
|
||||
}
|
||||
|
||||
for (int j = 0; j < lines.size(); j++) {
|
||||
lines.set(j, lines.get(j).replace("%SiteName%", data).replace("%position%", "" + position));
|
||||
}
|
||||
|
||||
lines = ArrayUtils.colorize(lines);
|
||||
|
||||
}
|
||||
|
||||
Render rendered = renderLines();
|
||||
synchronized (this) { applyRender(rendered); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sign.
|
||||
*
|
||||
* @param delay the delay
|
||||
*/
|
||||
private TopVoter displayPeriod() {
|
||||
if ("all".equalsIgnoreCase(data)) return TopVoter.AllTime;
|
||||
if ("monthly".equalsIgnoreCase(data)) return TopVoter.Monthly;
|
||||
if ("weekly".equalsIgnoreCase(data)) return TopVoter.Weekly;
|
||||
if ("daily".equalsIgnoreCase(data)) return TopVoter.Daily;
|
||||
return null;
|
||||
}
|
||||
|
||||
private Render renderLines() {
|
||||
var rendered = new ArrayList<String>();
|
||||
checkValidSign();
|
||||
if (position == 0) {
|
||||
synchronized (this) { return new Render(rendered, playerName, uuid, votes); }
|
||||
}
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine1());
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine2());
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine3());
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine4());
|
||||
TopVoter period = displayPeriod();
|
||||
java.util.Map<TopVoterPlayer, Integer> board = period == null ? java.util.Map.of()
|
||||
: plugin.getTopVoterDisplay(period);
|
||||
var users = new ArrayList<>(board.keySet());
|
||||
String name = "No Player";
|
||||
UUID identity = null;
|
||||
int amount = 0;
|
||||
if (users.size() >= position) {
|
||||
TopVoterPlayer row = users.get(position - 1);
|
||||
name = row.getPlayerName(); identity = row.getUuid(); amount = board.get(row);
|
||||
}
|
||||
for (int index = 0; index < rendered.size(); index++) {
|
||||
rendered.set(index, rendered.get(index).replace("%votes%", Integer.toString(amount))
|
||||
.replace("%player%", name).replace("%SiteName%", data)
|
||||
.replace("%position%", Integer.toString(position)));
|
||||
}
|
||||
return new Render(ArrayUtils.colorize(rendered), name, identity, amount);
|
||||
}
|
||||
|
||||
/** Called under this monitor; never call storage/provider APIs from this scope. */
|
||||
private void applyRender(Render rendered) {
|
||||
lines = rendered.lines(); playerName = rendered.name(); uuid = rendered.identity(); votes = rendered.votes();
|
||||
}
|
||||
|
||||
private Render currentRender() {
|
||||
synchronized (this) { return new Render(new ArrayList<>(lines), playerName, uuid, votes); }
|
||||
}
|
||||
|
||||
/** Update the sign on its owning scheduler, waiting once for admitted presence evidence when enabled. */
|
||||
public void updateSign(int delay) {
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, new Runnable() {
|
||||
TopVoter period = displayPeriod();
|
||||
if (position != 0 && period != null && plugin.getConfigFile() != null
|
||||
&& plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
prepareDisplay(period, delay);
|
||||
return;
|
||||
}
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, () -> writeSign(currentRender(), getLocation()),
|
||||
delay, getLocation());
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public void run() {
|
||||
private void prepareDisplay(TopVoter period, int delay) {
|
||||
long generation = plugin.getTopVoterDisplayGeneration();
|
||||
Location target = getLocation().clone();
|
||||
DisplayUpdate update;
|
||||
synchronized (this) {
|
||||
if (preparing != null && preparing.generation() == generation
|
||||
&& preparing.location().equals(target) && java.util.Objects.equals(preparing.data(), data)) return;
|
||||
update = new DisplayUpdate(generation, target, data);
|
||||
preparing = update;
|
||||
}
|
||||
try {
|
||||
var ready = plugin.getTopVoterDisplayAsync(period);
|
||||
if (ready == null) schedulePreparedDisplay(update, delay);
|
||||
else ready.whenComplete((ignored, failure) -> schedulePreparedDisplay(update, delay));
|
||||
} catch (RuntimeException unavailable) {
|
||||
schedulePreparedDisplay(update, delay);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean current(DisplayUpdate update) {
|
||||
return plugin.isEnabled() && plugin.getTopVoterDisplayGeneration() == update.generation()
|
||||
&& java.util.Objects.equals(update.location(), getLocation())
|
||||
&& java.util.Objects.equals(update.data(), data);
|
||||
}
|
||||
|
||||
private void schedulePreparedDisplay(DisplayUpdate update, int delay) {
|
||||
synchronized (this) { if (preparing != update) return; }
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, () -> {
|
||||
try {
|
||||
BlockState state = getLocation().getBlock().getState();
|
||||
if (state instanceof Sign) {
|
||||
Sign s = (Sign) state;
|
||||
|
||||
for (int j = 0; j < lines.size() && j < 4; j++) {
|
||||
s.setLine(j, lines.get(j));
|
||||
}
|
||||
s.update();
|
||||
checkSkulls();
|
||||
synchronized (SignHandler.this) { if (preparing != update) return; }
|
||||
if (!current(update)) return;
|
||||
// Resolve current evidence outside the sign monitor. Completion may reenter from the SDK worker.
|
||||
Render rendered = renderLines();
|
||||
if (!current(update)) return;
|
||||
synchronized (SignHandler.this) {
|
||||
if (preparing != update) return;
|
||||
applyRender(rendered);
|
||||
}
|
||||
|
||||
} catch (Exception ex) {
|
||||
plugin.debug("Failed to update sign at " + getLocation().toString());
|
||||
VotingPluginMain.plugin.debug(ex);
|
||||
writeSign(rendered, update.location());
|
||||
} finally {
|
||||
synchronized (SignHandler.this) { if (preparing == update) preparing = null; }
|
||||
}
|
||||
}, delay, update.location());
|
||||
} catch (RuntimeException rejected) {
|
||||
synchronized (this) { if (preparing == update) preparing = null; }
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private void writeSign(Render rendered, Location target) {
|
||||
try {
|
||||
BlockState state = target.getBlock().getState();
|
||||
if (state instanceof Sign signState) {
|
||||
for (int index = 0; index < rendered.lines().size() && index < 4; index++)
|
||||
signState.setLine(index, rendered.lines().get(index));
|
||||
signState.update();
|
||||
checkSkulls(rendered.name(), rendered.identity());
|
||||
}
|
||||
}, delay, getLocation());
|
||||
} catch (Exception failure) {
|
||||
plugin.debug("Failed to update sign display");
|
||||
plugin.debug(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,19 +434,38 @@ public class SignHandler {
|
||||
* @param loc2 the second location
|
||||
*/
|
||||
public void updateSkulls(Location loc1, Location loc2) {
|
||||
BlockState state = getLocation().getBlock().getState();
|
||||
if (state instanceof Sign && state.getBlockData() instanceof Directional) {
|
||||
Directional s = (Directional) state.getBlockData();
|
||||
Block b = location.getBlock().getRelative(s.getFacing());
|
||||
Block above = b.getRelative(BlockFace.UP);
|
||||
if (checkSkull(above)) {
|
||||
return;
|
||||
}
|
||||
Render rendered = currentRender();
|
||||
updateSkulls(loc1, loc2, rendered.name(), rendered.identity());
|
||||
}
|
||||
|
||||
}
|
||||
private void updateSkulls(Location loc1, Location loc2, String name, UUID identity) {
|
||||
updateSkulls(loc1, loc2, skullFrame(name, identity));
|
||||
}
|
||||
|
||||
for (Block block : MiscUtils.getInstance().getRegionBlocks(location.getWorld(), loc1, loc2)) {
|
||||
checkSkull(block);
|
||||
private void updateSkulls(Location loc1, Location loc2, SkullFrame frame) {
|
||||
if (frame.name().isEmpty() || frame.name().equalsIgnoreCase("No Player")) {
|
||||
if (plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore())
|
||||
for (Location target : assignedSkulls(frame)) updateSkull(frame, target, null);
|
||||
return;
|
||||
}
|
||||
Runnable nearby = () -> {
|
||||
for (int x = Math.min(loc1.getBlockX(), loc2.getBlockX()); x <= Math.max(loc1.getBlockX(), loc2.getBlockX()); x++)
|
||||
for (int y = Math.min(loc1.getBlockY(), loc2.getBlockY()); y <= Math.max(loc1.getBlockY(), loc2.getBlockY()); y++)
|
||||
for (int z = Math.min(loc1.getBlockZ(), loc2.getBlockZ()); z <= Math.max(loc1.getBlockZ(), loc2.getBlockZ()); z++) {
|
||||
if (!current(frame)) return;
|
||||
updateSkull(frame, new Location(frame.origin().getWorld(), x, y, z), null);
|
||||
}
|
||||
};
|
||||
onOwner(frame, frame.origin(), () -> {
|
||||
BlockState state = frame.origin().getBlock().getState();
|
||||
if (state instanceof Sign && state.getBlockData() instanceof Directional direction) {
|
||||
BlockFace facing = direction.getFacing();
|
||||
Location preferred = new Location(frame.origin().getWorld(),
|
||||
frame.origin().getBlockX() + facing.getModX(),
|
||||
frame.origin().getBlockY() + facing.getModY() + 1,
|
||||
frame.origin().getBlockZ() + facing.getModZ());
|
||||
updateSkull(frame, preferred, nearby);
|
||||
} else nearby.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,69 @@ public class Signs {
|
||||
}
|
||||
}
|
||||
|
||||
private final Object shutdownLock = new Object();
|
||||
private ShutdownSigns shutdownSigns;
|
||||
private java.util.concurrent.CompletableFuture<Void> shutdownAttempt;
|
||||
|
||||
private record ShutdownSigns(com.bencodez.votingplugin.data.ServerData data,
|
||||
java.util.List<com.bencodez.votingplugin.data.ServerData.SignSnapshot> snapshot,
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.Lease lease) { }
|
||||
|
||||
/** No YAML lock or disk wait on the lifecycle caller; failures retain the original payload. */
|
||||
public java.util.concurrent.CompletionStage<Void> storeSignsForShutdown() {
|
||||
synchronized (shutdownLock) {
|
||||
if (shutdownAttempt != null && !shutdownAttempt.isCompletedExceptionally())
|
||||
return shutdownAttempt.minimalCompletionStage();
|
||||
if (shutdownSigns == null) {
|
||||
var snapshot = new java.util.ArrayList<com.bencodez.votingplugin.data.ServerData.SignSnapshot>();
|
||||
for (var sign : java.util.List.copyOf(getSigns())) snapshot.add(sign.captureShutdownSnapshot());
|
||||
if (snapshot.isEmpty()) {
|
||||
shutdownAttempt = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
return shutdownAttempt.minimalCompletionStage();
|
||||
}
|
||||
var data = java.util.Objects.requireNonNull(plugin.getServerData());
|
||||
var fence = java.util.Objects.requireNonNull(plugin.getServerDataPublicationFence());
|
||||
shutdownSigns = new ShutdownSigns(data, java.util.List.copyOf(snapshot), fence.acquire());
|
||||
}
|
||||
var publication = shutdownSigns;
|
||||
var attempt = new java.util.concurrent.CompletableFuture<Void>();
|
||||
shutdownAttempt = attempt;
|
||||
try {
|
||||
startShutdownWorker(() -> {
|
||||
try {
|
||||
java.util.Objects.requireNonNull(publication.data().publishShutdownSigns(publication.snapshot()))
|
||||
.whenComplete((ignored, failure) -> finishShutdownSigns(publication, attempt, failure));
|
||||
} catch (Throwable failure) { finishShutdownSigns(publication, attempt, failure); }
|
||||
});
|
||||
} catch (RuntimeException | Error failure) {
|
||||
finishShutdownSigns(publication, attempt, failure);
|
||||
throw failure;
|
||||
}
|
||||
return attempt.minimalCompletionStage();
|
||||
}
|
||||
}
|
||||
|
||||
private void finishShutdownSigns(ShutdownSigns publication,
|
||||
java.util.concurrent.CompletableFuture<Void> attempt, Throwable failure) {
|
||||
if (failure == null) {
|
||||
try { publication.lease().close(); attempt.complete(null); }
|
||||
catch (Throwable releaseFailure) { attempt.completeExceptionally(releaseFailure); }
|
||||
} else {
|
||||
// Neither rejected admission nor failed I/O proves durability or writer drain.
|
||||
// Keep the same detached payload and lease for an explicit repeated-unload attempt.
|
||||
try { plugin.getLogger().warning("Sign shutdown persistence failed; its payload remains owned. Recovery is not confirmed."); }
|
||||
catch (RuntimeException | Error ignored) { }
|
||||
attempt.completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** Admission seam for deterministic rejection/cancellation tests. */
|
||||
protected void startShutdownWorker(Runnable task) {
|
||||
var worker = new Thread(task, "VotingPlugin-sign-shutdown");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update signs.
|
||||
*/
|
||||
|
||||
+3
-2
@@ -146,8 +146,9 @@ public class VoteParty implements Listener {
|
||||
public void commandVoteParty(CommandSender sender) {
|
||||
if (plugin.getSpecialRewardsConfig().isVotePartyEnabled()) {
|
||||
ArrayList<String> msg = plugin.getConfigFile().getFormatCommandsVoteParty();
|
||||
int votesRequired = getVotesRequired();
|
||||
int votes = getTotalVotes();
|
||||
var display = plugin.getServerData().getVotePartyDisplay();
|
||||
int votesRequired = display.required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired());
|
||||
int votes = display.total();
|
||||
int neededVotes = votesRequired - votes;
|
||||
HashMap<String, String> placeholders = new HashMap<>();
|
||||
placeholders.put("votesrequired", "" + votesRequired);
|
||||
|
||||
+2
-4
@@ -24,8 +24,7 @@ public final class VotePartyState {
|
||||
}
|
||||
|
||||
public void setTotalVotes(int value) {
|
||||
plugin.getServerData().getData().set(TOTAL_PATH, value);
|
||||
plugin.getServerData().saveData();
|
||||
plugin.getServerData().setInt(TOTAL_PATH, value);
|
||||
}
|
||||
|
||||
public List<String> getVotedUsers() {
|
||||
@@ -34,8 +33,7 @@ public final class VotePartyState {
|
||||
}
|
||||
|
||||
public void setVotedUsers(List<String> users) {
|
||||
plugin.getServerData().getData().set(VOTED_PATH, users);
|
||||
plugin.getServerData().saveData();
|
||||
plugin.getServerData().setStringList(VOTED_PATH, users);
|
||||
}
|
||||
|
||||
public int getVotesRequired() {
|
||||
|
||||
+254
-31
@@ -3,10 +3,15 @@ package com.bencodez.votingplugin.timequeue;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.event.EventHandler;
|
||||
@@ -24,11 +29,24 @@ import lombok.Getter;
|
||||
* The TimeQueueHandler class manages time-based vote queue processing.
|
||||
*/
|
||||
public class TimeQueueHandler implements Listener {
|
||||
private static final long TICKS_PER_SECOND = 20L;
|
||||
@Getter
|
||||
private Queue<VoteTimeQueue> timeChangeQueue = new ConcurrentLinkedQueue<>();
|
||||
|
||||
private VotingPluginMain plugin;
|
||||
private final Object persistenceLock = new Object();
|
||||
private volatile boolean stopping;
|
||||
private final AtomicReference<FutureTask<Void>> shutdownSave = new AtomicReference<>();
|
||||
// A failed task is retryable, but cannot release its unsaved suffix to a successor.
|
||||
private final AtomicReference<ShutdownPublication> shutdownPublicationOwner = new AtomicReference<>();
|
||||
private record ShutdownPublication(com.bencodez.votingplugin.util.ServerDataPublicationFence.Lease lease,
|
||||
java.util.concurrent.ScheduledExecutorService producers,
|
||||
java.util.concurrent.ScheduledExecutorService[] coreWriters) { }
|
||||
private final AtomicBoolean processing = new AtomicBoolean();
|
||||
private VoteTimeQueue dispatchingVote;
|
||||
private UUID dispatchId;
|
||||
private boolean dispatchEnded;
|
||||
private boolean dispatchStarted;
|
||||
private int persistedShutdownVotes;
|
||||
private final AtomicBoolean retryPending = new AtomicBoolean();
|
||||
private final AtomicInteger retryAttempts = new AtomicInteger();
|
||||
|
||||
@@ -49,8 +67,13 @@ public class TimeQueueHandler implements Listener {
|
||||
* @param voteSiteName the vote site name
|
||||
*/
|
||||
public void addVote(String voteUsername, String voteSiteName) {
|
||||
timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName,
|
||||
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
|
||||
synchronized (persistenceLock) {
|
||||
timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName,
|
||||
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
|
||||
// A vote task can outlive shutdownNow(). Persist its late admission
|
||||
// through the same owner rather than appending to an already-saved queue.
|
||||
if (stopping) persistPendingVotes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,17 +99,11 @@ public class TimeQueueHandler implements Listener {
|
||||
}
|
||||
|
||||
private void scheduleQueueProcessing(long delay, TimeUnit unit) {
|
||||
boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), () -> {
|
||||
// Clear only after the bounded executor has admitted the task. If it is
|
||||
// rejected, shutdown persistence can still recover the in-memory queue.
|
||||
plugin.getServerData().clearTimedVoteCache();
|
||||
processQueue();
|
||||
}, delay, unit);
|
||||
synchronized (persistenceLock) { if (stopping) return; }
|
||||
boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), this::processQueue, delay, unit);
|
||||
if (!admitted) {
|
||||
plugin.getLogger().warning("Unable to schedule time-queue processing because vote processing is busy; queued votes were retained.");
|
||||
scheduleRetry();
|
||||
} else {
|
||||
retryAttempts.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,15 +111,14 @@ public class TimeQueueHandler implements Listener {
|
||||
if (timeChangeQueue.isEmpty() || !retryPending.compareAndSet(false, true)) return;
|
||||
int attempt = Math.min(6, retryAttempts.getAndIncrement());
|
||||
long delaySeconds = Math.min(60L, 1L << attempt);
|
||||
long delayTicks = delaySeconds * TICKS_PER_SECOND;
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTaskLaterAsynchronously(plugin, () -> {
|
||||
retryPending.set(false);
|
||||
if (!timeChangeQueue.isEmpty()) scheduleQueueProcessing(0, TimeUnit.SECONDS);
|
||||
}, delayTicks);
|
||||
}, delaySeconds);
|
||||
} catch (RuntimeException rejected) {
|
||||
retryPending.set(false);
|
||||
plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain persisted for recovery.");
|
||||
plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain owned for recovery.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,32 +126,239 @@ public class TimeQueueHandler implements Listener {
|
||||
* Processes all votes in the queue.
|
||||
*/
|
||||
public void processQueue() {
|
||||
while (getTimeChangeQueue().size() > 0) {
|
||||
VoteTimeQueue vote = getTimeChangeQueue().remove();
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(),
|
||||
vote.getService(), true);
|
||||
voteEvent.setTime(vote.getTime());
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
|
||||
if (voteEvent.isCancelled()) {
|
||||
plugin.debug("Vote cancelled");
|
||||
continue;
|
||||
if (!processing.compareAndSet(false, true)) return;
|
||||
boolean retry = false;
|
||||
try {
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping) return;
|
||||
if (dispatchingVote != null) {
|
||||
if (!dispatchEnded) return;
|
||||
retainUncertainDispatch();
|
||||
clearDispatch();
|
||||
}
|
||||
persistedShutdownVotes = 0;
|
||||
}
|
||||
while (true) {
|
||||
VoteTimeQueue vote;
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping || dispatchingVote != null) return;
|
||||
vote = timeChangeQueue.peek();
|
||||
}
|
||||
if (vote == null) return;
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(),
|
||||
vote.getService(), true);
|
||||
voteEvent.setTime(vote.getTime());
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping) return;
|
||||
if (timeChangeQueue.peek() != vote) continue;
|
||||
dispatchingVote = vote;
|
||||
if (vote.getVoteId() == null) vote.setVoteId(UUID.randomUUID());
|
||||
dispatchId = vote.getVoteId();
|
||||
dispatchEnded = false;
|
||||
try {
|
||||
plugin.getServerData().prepareTimedVoteDispatch(dispatchId, vote,
|
||||
timeChangeQueue.stream().filter(pending -> pending != vote).toList());
|
||||
} catch (RuntimeException | Error failure) {
|
||||
clearDispatch();
|
||||
throw failure;
|
||||
}
|
||||
// A preparation resumed after stop is still eligible for normal recovery.
|
||||
if (stopping) return;
|
||||
dispatchStarted = true;
|
||||
}
|
||||
try {
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
synchronized (persistenceLock) {
|
||||
dispatchEnded = true;
|
||||
try {
|
||||
retainUncertainDispatch();
|
||||
clearDispatch();
|
||||
if (!stopping) checkpointPendingVotes();
|
||||
} catch (RuntimeException | Error persistenceFailure) {
|
||||
if (failure != persistenceFailure) failure.addSuppressed(persistenceFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
synchronized (persistenceLock) {
|
||||
dispatchEnded = true;
|
||||
if (!stopping) plugin.getServerData().completeTimedVoteDispatch(dispatchId);
|
||||
timeChangeQueue.remove(vote);
|
||||
clearDispatch();
|
||||
if (voteEvent.isCancelled() && !stopping) checkpointPendingVotes();
|
||||
}
|
||||
if (voteEvent.isCancelled()) {
|
||||
plugin.debug("Vote cancelled");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
retry = !stopping;
|
||||
throw failure;
|
||||
} finally {
|
||||
processing.set(false);
|
||||
if (retry) scheduleRetry();
|
||||
else retryAttempts.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearDispatch() {
|
||||
dispatchingVote = null;
|
||||
dispatchId = null;
|
||||
dispatchEnded = false;
|
||||
dispatchStarted = false;
|
||||
}
|
||||
|
||||
private void checkpointPendingVotes() {
|
||||
plugin.getServerData().replaceTimedVoteCache(
|
||||
timeChangeQueue.stream().filter(vote -> vote != dispatchingVote).toList());
|
||||
persistedShutdownVotes = 0;
|
||||
}
|
||||
|
||||
/** Uncertain event delivery is retained for review, never automatically replayed. */
|
||||
private void retainUncertainDispatch() {
|
||||
if (dispatchingVote == null || !timeChangeQueue.contains(dispatchingVote)) return;
|
||||
plugin.getServerData().addUncertainTimedVote(dispatchId, dispatchingVote);
|
||||
timeChangeQueue.remove(dispatchingVote);
|
||||
plugin.getLogger().warning("Timed vote event delivery is uncertain; retained in ServerData.yml VotingPlugin.UncertainTimedVotes for manual review.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves pending votes to server data.
|
||||
*/
|
||||
public void save() {
|
||||
if (!timeChangeQueue.isEmpty()) {
|
||||
int num = 0;
|
||||
for (VoteTimeQueue vote : timeChangeQueue) {
|
||||
plugin.getServerData().addTimeVoted(num, vote);
|
||||
num++;
|
||||
if (stopping) {
|
||||
saveForShutdown();
|
||||
return;
|
||||
}
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping) persistPendingVotes();
|
||||
else checkpointPendingVotes();
|
||||
}
|
||||
}
|
||||
|
||||
/** Seal processing and retain one persistence owner without an unbounded lifecycle wait. */
|
||||
public void saveForShutdown() {
|
||||
stopping = true;
|
||||
FutureTask<Void> save;
|
||||
while (true) {
|
||||
save = shutdownSave.get();
|
||||
if (save != null) {
|
||||
if (!save.isDone()) break;
|
||||
shutdownSave.compareAndSet(save, null);
|
||||
continue;
|
||||
}
|
||||
FutureTask<Void> candidate = new FutureTask<Void>(() -> {
|
||||
try {
|
||||
var publication = java.util.Objects.requireNonNull(shutdownPublicationOwner.get());
|
||||
try { synchronized (persistenceLock) { persistPendingVotes(); } }
|
||||
finally {
|
||||
// Publication failure does not terminate previously admitted producers.
|
||||
awaitRetiringProducers(publication.producers());
|
||||
if (publication.coreWriters() != null)
|
||||
for (var writer : publication.coreWriters()) awaitWriterTermination(writer);
|
||||
}
|
||||
synchronized (persistenceLock) { persistPendingVotes(); }
|
||||
if (publication.lease() != null) publication.lease().close();
|
||||
shutdownPublicationOwner.compareAndSet(publication, null);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try {
|
||||
plugin.getLogger().warning("Timed vote shutdown persistence failed; pending votes remain owned for retry. Recovery is not confirmed.");
|
||||
} catch (RuntimeException | Error ignored) { }
|
||||
throw failure;
|
||||
}
|
||||
return null;
|
||||
}) {
|
||||
@Override protected void done() {
|
||||
// Late admission failures must be retryable after this owner completes.
|
||||
shutdownSave.compareAndSet(this, null);
|
||||
}
|
||||
};
|
||||
FutureTask<Void> existing = shutdownSave.compareAndExchange(null, candidate);
|
||||
if (existing == null) {
|
||||
save = candidate;
|
||||
try {
|
||||
if (shutdownPublicationOwner.get() == null) {
|
||||
// Unload can clear these fields even while their original work survives.
|
||||
// Keep the snapshot with its lease through every rejected or failed attempt.
|
||||
var producers = plugin.getVoteTimer();
|
||||
var coreWriters = plugin.getServerDataWriterExecutors();
|
||||
var retainedWriters = coreWriters == null ? null : coreWriters.clone();
|
||||
var fence = plugin.getServerDataPublicationFence();
|
||||
shutdownPublicationOwner.set(new ShutdownPublication(
|
||||
fence == null ? null : fence.acquire(), producers, retainedWriters));
|
||||
}
|
||||
startShutdownWorker(candidate);
|
||||
}
|
||||
catch (RuntimeException | Error failure) {
|
||||
// Even a rejected worker retains the queue owner for an explicit retry.
|
||||
candidate.cancel(false);
|
||||
shutdownSave.compareAndSet(candidate, null);
|
||||
throw failure;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
save.get(1, TimeUnit.SECONDS);
|
||||
shutdownSave.compareAndSet(save, null);
|
||||
} catch (TimeoutException pending) {
|
||||
plugin.getLogger().warning("Timed vote shutdown persistence is still pending; its worker retains ownership. Recovery is not yet confirmed.");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
plugin.getLogger().warning("Timed vote shutdown persistence wait was interrupted; its worker retains ownership.");
|
||||
} catch (ExecutionException failure) {
|
||||
shutdownSave.compareAndSet(save, null);
|
||||
Throwable cause = failure.getCause();
|
||||
if (cause instanceof Error error) throw error;
|
||||
if (cause instanceof RuntimeException runtime) throw runtime;
|
||||
throw new IllegalStateException("Timed vote shutdown persistence failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
/** Kept separate so worker-admission failure can be exercised without replacing persistence. */
|
||||
protected void startShutdownWorker(Runnable task) {
|
||||
Thread worker = new Thread(task, "VotingPlugin-timed-vote-shutdown");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void awaitRetiringProducers(java.util.concurrent.ScheduledExecutorService producers) {
|
||||
if (producers == null || !producers.isShutdown()) return;
|
||||
awaitWriterTermination(producers);
|
||||
}
|
||||
|
||||
private void awaitWriterTermination(java.util.concurrent.ScheduledExecutorService producers) {
|
||||
if (producers == null) return;
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
while (!producers.isTerminated()) {
|
||||
try { producers.awaitTermination(100, TimeUnit.MILLISECONDS); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
} finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
}
|
||||
|
||||
/** Remove only a successfully saved prefix; failures retain the same next slot for retry. */
|
||||
private void persistPendingVotes() {
|
||||
VoteTimeQueue vote;
|
||||
while ((vote = timeChangeQueue.peek()) != null) {
|
||||
if (vote == dispatchingVote) {
|
||||
if (dispatchStarted) retainUncertainDispatch();
|
||||
else {
|
||||
var pending = timeChangeQueue.stream().toList();
|
||||
plugin.getServerData().abortTimedVoteDispatch(dispatchId, pending);
|
||||
timeChangeQueue.removeAll(pending);
|
||||
persistedShutdownVotes = pending.size();
|
||||
clearDispatch();
|
||||
}
|
||||
} else {
|
||||
plugin.getServerData().addTimeVoted(persistedShutdownVotes, vote);
|
||||
persistedShutdownVotes++;
|
||||
timeChangeQueue.remove(vote);
|
||||
}
|
||||
}
|
||||
timeChangeQueue.clear();
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
package com.bencodez.votingplugin.topvoter;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.LongSupplier;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
/** Bounded display snapshots only; raw rankings and award inputs stay untouched. */
|
||||
public final class TopVoterDisplayFilter implements AutoCloseable, Listener {
|
||||
private static final int MAX_PENDING = 128;
|
||||
private static final int MAX_CACHED = 8192;
|
||||
private static final long MAX_AGE = java.util.concurrent.TimeUnit.SECONDS.toNanos(30);
|
||||
private record Identity(UUID uuid, String name) { }
|
||||
private static final class Evidence {
|
||||
private PlayerPresence value = PlayerPresence.UNKNOWN;
|
||||
private long completed;
|
||||
private boolean pending = true;
|
||||
private Set<UUID> identities = Set.of();
|
||||
private long joinGeneration;
|
||||
private TopVoterPlayer snapshot;
|
||||
private List<UUID> aliases;
|
||||
private final CompletableFuture<Void> settled = new CompletableFuture<>();
|
||||
}
|
||||
private final Function<TopVoterPlayer, List<UUID>> identities;
|
||||
private final BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup;
|
||||
private final LongSupplier clock;
|
||||
private final LongSupplier rankingRowBudget;
|
||||
private volatile int cacheLimit = MAX_CACHED;
|
||||
private final LinkedHashMap<Identity, Evidence> cache = new LinkedHashMap<>(16, 0.75f, true);
|
||||
private int pending;
|
||||
private boolean closed;
|
||||
private long joinGeneration;
|
||||
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, CompletionStage<PlayerPresence>> lookup) {
|
||||
this(lookup, System::nanoTime);
|
||||
}
|
||||
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, CompletionStage<PlayerPresence>> lookup, LongSupplier clock) {
|
||||
this(player -> List.of(player.getUuid()), (player, aliases) -> lookup.apply(player), clock);
|
||||
Objects.requireNonNull(lookup);
|
||||
}
|
||||
|
||||
/** Check the captured cache-only scope asynchronously; revalidate it before using negative evidence. */
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, List<UUID>> identities,
|
||||
BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup) {
|
||||
this(identities, lookup, System::nanoTime);
|
||||
}
|
||||
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, List<UUID>> identities,
|
||||
BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup, LongSupplier clock) {
|
||||
this(identities, lookup, clock, () -> 0L);
|
||||
}
|
||||
|
||||
/** The owner supplies the row count already retained in current and historical ranking maps. */
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, List<UUID>> identities,
|
||||
BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup, LongSupplier clock,
|
||||
LongSupplier rankingRowBudget) {
|
||||
this.identities = Objects.requireNonNull(identities);
|
||||
this.lookup = Objects.requireNonNull(lookup);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.rankingRowBudget = Objects.requireNonNull(rankingRowBudget);
|
||||
}
|
||||
|
||||
/** Pending, failed, missing or stale evidence remains visible until a checked result arrives. */
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> project(Map<TopVoterPlayer, Integer> rankings, boolean enabled) {
|
||||
if (enabled) reserveRankingRows(rankings.size());
|
||||
var visible = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
for (var entry : rankings.entrySet()) {
|
||||
if (!enabled || evidence(entry.getKey()) != PlayerPresence.NEVER_JOINED)
|
||||
visible.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private void reserveRankingRows(int rows) {
|
||||
long requested = rows;
|
||||
// Read owner state outside the evidence monitor. A racing replacement may leave
|
||||
// a temporary unknown budget; keep the previous bound until the next refresh.
|
||||
try { requested = Math.max(requested, rankingRowBudget.getAsLong()); }
|
||||
catch (RuntimeException unavailable) { requested = Math.max(requested, cacheLimit); }
|
||||
synchronized (cache) {
|
||||
cacheLimit = (int) Math.min(Integer.MAX_VALUE, Math.max(MAX_CACHED, requested));
|
||||
trimCompletedEvidence(cacheLimit);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called under cache; in-flight requests retain their bounded admission until completion. */
|
||||
private void trimCompletedEvidence(int limit) {
|
||||
var oldest = cache.entrySet().iterator();
|
||||
while (cache.size() > limit && oldest.hasNext()) {
|
||||
if (!oldest.next().getValue().pending) oldest.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Await admitted checks without blocking their worker; unavailable rows stay visible. */
|
||||
public CompletionStage<LinkedHashMap<TopVoterPlayer, Integer>> projectAsync(
|
||||
Map<TopVoterPlayer, Integer> rankings, boolean enabled) {
|
||||
var snapshot = new LinkedHashMap<>(rankings);
|
||||
if (!enabled) return CompletableFuture.completedFuture(snapshot);
|
||||
project(snapshot, true);
|
||||
var captured = new LinkedHashMap<Identity, Evidence>();
|
||||
var waiting = new java.util.ArrayList<CompletableFuture<Void>>();
|
||||
synchronized (cache) {
|
||||
for (TopVoterPlayer player : snapshot.keySet()) {
|
||||
if (player == null || player.getUuid() == null) continue;
|
||||
Identity key = new Identity(player.getUuid(), player.getPlayerName());
|
||||
Evidence record = cache.get(key);
|
||||
if (record != null) {
|
||||
captured.put(key, record);
|
||||
if (record.pending) waiting.add(record.settled);
|
||||
}
|
||||
}
|
||||
}
|
||||
return CompletableFuture.allOf(waiting.toArray(CompletableFuture[]::new))
|
||||
.completeOnTimeout(null, 5, java.util.concurrent.TimeUnit.SECONDS).thenApply(ignored -> {
|
||||
var visible = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
for (var entry : snapshot.entrySet()) {
|
||||
TopVoterPlayer player = entry.getKey();
|
||||
Identity key = player == null ? null : new Identity(player.getUuid(), player.getPlayerName());
|
||||
Evidence record = captured.get(key);
|
||||
if (record == null || cachedValue(key, record) != PlayerPresence.NEVER_JOINED)
|
||||
visible.put(player, entry.getValue());
|
||||
}
|
||||
return visible;
|
||||
});
|
||||
}
|
||||
|
||||
private PlayerPresence evidence(TopVoterPlayer player) {
|
||||
if (player == null || player.getUuid() == null) return PlayerPresence.UNKNOWN;
|
||||
Identity key = new Identity(player.getUuid(), player.getPlayerName());
|
||||
Evidence record;
|
||||
boolean reuse;
|
||||
synchronized (cache) {
|
||||
if (closed) return PlayerPresence.UNKNOWN;
|
||||
record = cache.get(key);
|
||||
reuse = record != null && (record.pending || clock.getAsLong() - record.completed < MAX_AGE);
|
||||
if (!reuse) {
|
||||
if (pending >= MAX_PENDING) return PlayerPresence.UNKNOWN;
|
||||
if (record != null) cache.remove(key);
|
||||
if (cache.size() >= cacheLimit) trimCompletedEvidence(cacheLimit - 1);
|
||||
if (cache.size() >= cacheLimit) return PlayerPresence.UNKNOWN;
|
||||
record = new Evidence(); cache.put(key, record); pending++;
|
||||
record.joinGeneration = joinGeneration;
|
||||
record.snapshot = new TopVoterPlayer(key.uuid(), key.name(), player.getLastVoteTime());
|
||||
}
|
||||
}
|
||||
if (reuse) return cachedValue(key, record);
|
||||
Evidence admitted = record;
|
||||
try {
|
||||
List<UUID> aliases = resolveScope(admitted.snapshot);
|
||||
if (aliases == null) {
|
||||
finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
return PlayerPresence.UNKNOWN;
|
||||
}
|
||||
var checked = new java.util.HashSet<UUID>();
|
||||
for (UUID alias : aliases) if (alias != null) checked.add(alias);
|
||||
boolean valid;
|
||||
synchronized (cache) {
|
||||
valid = !closed && cache.get(key) == admitted && admitted.joinGeneration == joinGeneration;
|
||||
if (valid) {
|
||||
admitted.identities = Set.copyOf(checked);
|
||||
admitted.aliases = aliases;
|
||||
} else cache.remove(key, admitted);
|
||||
}
|
||||
if (!valid) {
|
||||
finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
return PlayerPresence.UNKNOWN;
|
||||
}
|
||||
CompletionStage<PlayerPresence> request = lookup.apply(admitted.snapshot, aliases);
|
||||
if (request == null) finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
else request.whenComplete((result, failure) -> finish(key, admitted,
|
||||
failure == null && result != null ? result : PlayerPresence.UNKNOWN));
|
||||
} catch (RuntimeException unavailable) {
|
||||
finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
}
|
||||
return cachedValue(key, admitted);
|
||||
}
|
||||
|
||||
private List<UUID> resolveScope(TopVoterPlayer snapshot) {
|
||||
List<UUID> resolved = identities.apply(snapshot);
|
||||
if (resolved == null || resolved.isEmpty() || resolved.size() > 8) return null;
|
||||
// Preserve null uncertainty markers and detach the provider from mutable cache data.
|
||||
return java.util.Collections.unmodifiableList(new java.util.ArrayList<>(resolved));
|
||||
}
|
||||
|
||||
private boolean matchesScope(Evidence record) {
|
||||
try {
|
||||
return record.aliases != null && record.aliases.equals(resolveScope(record.snapshot));
|
||||
} catch (RuntimeException unavailable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerPresence cachedValue(Identity key, Evidence record) {
|
||||
PlayerPresence value;
|
||||
synchronized (cache) {
|
||||
if (closed || cache.get(key) != record || record.pending
|
||||
|| clock.getAsLong() - record.completed >= MAX_AGE) return PlayerPresence.UNKNOWN;
|
||||
value = record.value;
|
||||
}
|
||||
// The resolver is cache-only, but must never run under the evidence-cache monitor.
|
||||
if (value == PlayerPresence.NEVER_JOINED && !matchesScope(record)) {
|
||||
synchronized (cache) { cache.remove(key, record); }
|
||||
return PlayerPresence.UNKNOWN;
|
||||
}
|
||||
synchronized (cache) {
|
||||
return !closed && cache.get(key) == record && !record.pending
|
||||
&& clock.getAsLong() - record.completed < MAX_AGE ? record.value : PlayerPresence.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(Identity key, Evidence record, PlayerPresence result) {
|
||||
boolean currentScope = result != PlayerPresence.NEVER_JOINED || matchesScope(record);
|
||||
synchronized (cache) {
|
||||
if (!record.pending) return;
|
||||
record.pending = false; pending--;
|
||||
if (!closed && cache.get(key) == record) {
|
||||
if (!currentScope) cache.remove(key, record);
|
||||
else { record.value = result; record.completed = clock.getAsLong(); }
|
||||
}
|
||||
trimCompletedEvidence(cacheLimit);
|
||||
}
|
||||
// Completion may invoke projection callbacks, which resolve aliases outside this monitor.
|
||||
record.settled.complete(null);
|
||||
}
|
||||
|
||||
/** A newly observed join revokes matching negative snapshots and pending stale completions. */
|
||||
public void joined(UUID uuid, String name) {
|
||||
var invalidated = new java.util.ArrayList<Evidence>();
|
||||
synchronized (cache) {
|
||||
joinGeneration++;
|
||||
cache.entrySet().removeIf(entry -> {
|
||||
boolean matches = Objects.equals(entry.getKey().uuid(), uuid)
|
||||
|| (uuid != null && entry.getValue().identities.contains(uuid))
|
||||
|| (name != null && name.equalsIgnoreCase(entry.getKey().name()));
|
||||
if (matches) invalidated.add(entry.getValue());
|
||||
return matches;
|
||||
});
|
||||
}
|
||||
for (Evidence record : invalidated) record.settled.complete(null);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
joined(event.getPlayer().getUniqueId(), event.getPlayer().getName());
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
List<Evidence> retiring;
|
||||
synchronized (cache) {
|
||||
closed = true;
|
||||
retiring = new java.util.ArrayList<>(cache.values());
|
||||
cache.clear();
|
||||
}
|
||||
for (Evidence record : retiring) record.settled.complete(null);
|
||||
}
|
||||
}
|
||||
+28
-18
@@ -113,11 +113,12 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] getTopVotersWeekly() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(plugin.getTopVoter(TopVoter.Weekly).keySet());
|
||||
var board = plugin.getTopVoterDisplay(TopVoter.Weekly);
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(board.keySet());
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine().replace("%num%", "" + (i + 1))
|
||||
.replace("%player%", users.get(i).getPlayerName())
|
||||
.replace("%votes%", "" + plugin.getTopVoter(TopVoter.Weekly).get(users.get(i)));
|
||||
.replace("%votes%", "" + board.get(users.get(i)));
|
||||
msg.add(line);
|
||||
}
|
||||
msg = ArrayUtils.colorize(msg);
|
||||
@@ -638,11 +639,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return the string[]
|
||||
*/
|
||||
public String[] topVoterAllTime(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -662,7 +664,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterAllTime());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -676,11 +679,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterDaily(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -700,7 +704,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterDaily());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -714,11 +719,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterMonthly(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -738,7 +744,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterMonthly());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -752,7 +759,7 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersAllTime() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoter(TopVoter.AllTime).entrySet());
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet());
|
||||
int i = 0;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : list) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
@@ -778,7 +785,8 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersDaily() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(plugin.getTopVoter(TopVoter.Daily).keySet());
|
||||
var board = plugin.getTopVoterDisplay(TopVoter.Daily);
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(board.keySet());
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
line = line.replace("%num%", "" + (i + 1));
|
||||
@@ -787,7 +795,7 @@ public class TopVoterHandler implements Listener {
|
||||
} catch (Exception ex) {
|
||||
VotingPluginMain.plugin.debug(ex);
|
||||
}
|
||||
line = line.replace("%votes%", "" + plugin.getTopVoter(TopVoter.Monthly).get(users.get(i)));
|
||||
line = line.replace("%votes%", "" + board.get(users.get(i)));
|
||||
msg.add(line);
|
||||
}
|
||||
|
||||
@@ -801,7 +809,7 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersMonthly() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoter(TopVoter.Monthly).entrySet());
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet());
|
||||
int i = 0;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : list) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
@@ -827,11 +835,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterWeekly(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -851,7 +860,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterWeekly());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.bencodez.votingplugin.topvoter;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** UUID-only evidence scopes; never resolves a profile or loads plugin/server storage. */
|
||||
public final class TopVoterPresenceIdentity {
|
||||
private TopVoterPresenceIdentity() { }
|
||||
|
||||
/** A null alias explicitly prevents uncertain migration evidence becoming negative. */
|
||||
public static List<UUID> identities(TopVoterPlayer row, boolean onlineMode, Function<String, String> cachedUuid) {
|
||||
var identities = new ArrayList<UUID>();
|
||||
if (row == null || row.getUuid() == null) { identities.add(null); return identities; }
|
||||
identities.add(row.getUuid());
|
||||
String name = row.getPlayerName();
|
||||
if (name == null || name.isBlank() || name.length() > 64 || !name.equals(name.trim())) {
|
||||
identities.add(null); return identities;
|
||||
}
|
||||
UUID offline = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
|
||||
if (!identities.contains(offline)) identities.add(offline);
|
||||
UUID current = null;
|
||||
try {
|
||||
String known = cachedUuid.apply(name);
|
||||
if (known != null && !known.isBlank()) {
|
||||
current = UUID.fromString(known);
|
||||
if (!current.toString().equalsIgnoreCase(known)) { identities.add(null); return identities; }
|
||||
}
|
||||
} catch (RuntimeException unavailable) { identities.add(null); return identities; }
|
||||
if (current != null && !identities.contains(current)) identities.add(current);
|
||||
// This validates known aliases; online historical completeness is checked
|
||||
// separately against retained server history by the asynchronous provider.
|
||||
boolean resolvable = onlineMode ? row.getUuid().version() == 4 && row.getUuid().variant() == 2
|
||||
&& (current == null || current.equals(row.getUuid()))
|
||||
: offline.equals(row.getUuid()) || offline.equals(current);
|
||||
if (!resolvable) identities.add(null);
|
||||
return identities;
|
||||
}
|
||||
}
|
||||
+38
-4
@@ -93,10 +93,20 @@ public final class SharedMysqlCacheReconciler {
|
||||
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<Boolean> 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 final class SharedMysqlCacheReconciler {
|
||||
* 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<Boolean> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+179
-73
@@ -185,16 +185,29 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
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 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
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 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
boolean set(VotingPluginUser user, int value, boolean async) {
|
||||
String pointsColumn = user.getPointsPath();
|
||||
return run(() -> setAbsolute(user, value, pointsColumn), async);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
Consumer<Boolean> 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 @@ final class SharedMysqlPointMutator {
|
||||
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 @@ final class SharedMysqlPointMutator {
|
||||
// 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 @@ final class SharedMysqlPointMutator {
|
||||
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 @@ final class SharedMysqlPointMutator {
|
||||
} 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 @@ final class SharedMysqlPointMutator {
|
||||
int debitAmount,
|
||||
Consumer<PointTransferResult> 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 @@ final class SharedMysqlPointMutator {
|
||||
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<PointTransferResult> 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 @@ final class SharedMysqlPointMutator {
|
||||
// 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 @@ final class SharedMysqlPointMutator {
|
||||
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<PointTransferResult> 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 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
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 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
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 @@ final class SharedMysqlPointMutator {
|
||||
* 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());
|
||||
}
|
||||
|
||||
+101
-74
@@ -28,6 +28,14 @@ import com.bencodez.simpleapi.sql.mysql.DbType;
|
||||
* settlement is a second short, idempotent transaction.</p>
|
||||
*/
|
||||
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 @@ final class SharedPointTransferJournal {
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
throw failure;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,42 +302,50 @@ final class SharedPointTransferJournal {
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, REFUNDED);
|
||||
}
|
||||
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);
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +364,7 @@ final class SharedPointTransferJournal {
|
||||
+ " + ? 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 @@ final class SharedPointTransferJournal {
|
||||
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 @@ final class SharedPointTransferJournal {
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, REFUNDED);
|
||||
} catch (SQLException failure) {
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,18 @@ public class UserManager {
|
||||
* Gets a VotingPluginUser by UUID.
|
||||
* @param uuid the player UUID
|
||||
* @return the VotingPluginUser
|
||||
* @deprecated UUID-only lookups can be ambiguous in offline mode because Minecraft
|
||||
* preserves name capitalization, while VotingPlugin derives its
|
||||
* offline UUID from the player name lowercased with Locale.ROOT.
|
||||
* Prefer {@link #getVotingPluginUser(UUID, String)} when both UUID
|
||||
* and player name are available; supplying the name allows the
|
||||
* existing offline UUID normalization. Use
|
||||
* {@link #getVotingPluginUser(Player)} for an online player,
|
||||
* {@link #getVotingPluginUser(OfflinePlayer)} when an offline
|
||||
* player object with a known name is available, or
|
||||
* {@link #getVotingPluginUser(String)} when only the name is known.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public VotingPluginUser getVotingPluginUser(UUID uuid) {
|
||||
LiveIdentity identity = offlineLiveIdentity(uuid);
|
||||
@@ -280,7 +291,21 @@ public class UserManager {
|
||||
* @param uuid the player UUID
|
||||
* @param loadName whether to load the player name
|
||||
* @return the VotingPluginUser
|
||||
* @deprecated UUID-only lookups can be ambiguous in offline mode because Minecraft
|
||||
* preserves name capitalization, while VotingPlugin derives its
|
||||
* offline UUID from the player name lowercased with Locale.ROOT.
|
||||
* Prefer {@link #getVotingPluginUser(UUID, String)} when both UUID
|
||||
* and player name are available; supplying the name allows the
|
||||
* existing offline UUID normalization. Use
|
||||
* {@link #getVotingPluginUser(Player)} for an online player,
|
||||
* {@link #getVotingPluginUser(OfflinePlayer)} when an offline
|
||||
* player object with a known name is available, or
|
||||
* {@link #getVotingPluginUser(String)} when only the name is known.
|
||||
* Retain this overload when the exact stored UUID and explicit
|
||||
* name-loading behavior are required; name-aware alternatives
|
||||
* may normalize the supplied identity in offline mode.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public VotingPluginUser getVotingPluginUser(UUID uuid, boolean loadName) {
|
||||
return new VotingPluginUser(plugin, uuid, loadName);
|
||||
|
||||
+2108
-1964
File diff suppressed because it is too large.
Load diff
+217
@@ -0,0 +1,217 @@
|
||||
package com.bencodez.votingplugin.util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/** Memory-only admission for participating plugin generations sharing one server-data path in a JVM. */
|
||||
public final class ServerDataPublicationFence {
|
||||
private static final String PREFIX = "com.bencodez.votingplugin.ServerDataPublicationFence.owner.";
|
||||
private static final int MAX_OWNERS = 1024;
|
||||
private final Properties registry;
|
||||
private final String key;
|
||||
private final String generation;
|
||||
private final AtomicBoolean retirementStarted = new AtomicBoolean();
|
||||
private final java.util.concurrent.CompletableFuture<Void> retirementCompleted = new java.util.concurrent.CompletableFuture<>();
|
||||
|
||||
private final CompletableFuture<Long> processShutdownStarted = new CompletableFuture<>();
|
||||
private final CompletableFuture<Void> processExitCompletion = new CompletableFuture<>();
|
||||
private final AtomicBoolean processHookRemoved = new AtomicBoolean();
|
||||
private volatile Runtime processRuntime;
|
||||
private volatile Thread processHook;
|
||||
|
||||
/** Opt in during startup, before the JVM can begin invoking shutdown hooks. */
|
||||
public synchronized void retainForProcessExit() {
|
||||
if (processHook != null) return;
|
||||
var runtime = Runtime.getRuntime();
|
||||
var hook = new Thread(this::awaitProcessExit, "VotingPlugin-ServerData-Exit");
|
||||
runtime.addShutdownHook(hook);
|
||||
processRuntime = runtime;
|
||||
processHook = hook;
|
||||
processExitCompletion.whenComplete((ignored, failure) -> removeProcessHook());
|
||||
}
|
||||
|
||||
/** The first disable entry fixes the deadline; retries cannot renew it. No caller wait. */
|
||||
public void beginShutdown(long startedNanos) {
|
||||
if (processShutdownStarted.complete(startedNanos) && processHook != null) {
|
||||
CompletableFuture.delayedExecutor(remainingProcessWait(startedNanos), TimeUnit.NANOSECONDS)
|
||||
.execute(this::removeProcessHook);
|
||||
}
|
||||
}
|
||||
|
||||
private static long remainingProcessWait(long startedNanos) {
|
||||
long elapsed = Math.max(0L, System.nanoTime() - startedNanos);
|
||||
return Math.max(0L, TimeUnit.SECONDS.toNanos(15) - elapsed);
|
||||
}
|
||||
|
||||
private void awaitProcessExit() {
|
||||
long exitStarted = System.nanoTime();
|
||||
try {
|
||||
long disableStarted = processShutdownStarted.get(remainingProcessWait(exitStarted), TimeUnit.NANOSECONDS);
|
||||
long remaining = Math.min(remainingProcessWait(exitStarted), remainingProcessWait(disableStarted));
|
||||
if (remaining > 0) processExitCompletion.get(remaining, TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ExecutionException | TimeoutException pendingOrExpired) {
|
||||
// Process exit is bounded. Timeout does not acknowledge publication or release a lease.
|
||||
}
|
||||
}
|
||||
|
||||
private void removeProcessHook() {
|
||||
var hook = processHook;
|
||||
if (hook == null || !processHookRemoved.compareAndSet(false, true)) return;
|
||||
try { processRuntime.removeShutdownHook(hook); }
|
||||
catch (IllegalStateException stopping) { /* The JVM is already invoking its hooks. */ }
|
||||
}
|
||||
|
||||
private ServerDataPublicationFence(Properties registry, String key, String generation) {
|
||||
this.registry = registry;
|
||||
this.key = key;
|
||||
this.generation = generation;
|
||||
}
|
||||
|
||||
/** Runs before platform initialization and performs no filesystem operation or wait for storage. */
|
||||
public static ServerDataPublicationFence beginGeneration(Path file) {
|
||||
String identity = file.toAbsolutePath().normalize().toString();
|
||||
if (DurableFiles.isWindowsName(System.getProperty("os.name", ""))) identity = identity.toLowerCase(Locale.ROOT);
|
||||
String key;
|
||||
try {
|
||||
key = PREFIX + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(identity.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException unavailable) { throw new IllegalStateException(unavailable); }
|
||||
Properties registry = System.getProperties();
|
||||
synchronized (registry) {
|
||||
Object value = registry.get(key);
|
||||
if (value != null) {
|
||||
State previous = state(value);
|
||||
if (previous.users != 0 || !previous.retiring)
|
||||
throw new IllegalStateException("Previous server-data generation still owns storage work; replacement was not started");
|
||||
} else if (registry.keySet().stream().filter(k -> k instanceof String s && s.startsWith(PREFIX)).count() >= MAX_OWNERS) {
|
||||
throw new IllegalStateException("Server-data publication owner capacity is unavailable");
|
||||
}
|
||||
String generation = UUID.randomUUID().toString();
|
||||
registry.setProperty(key, generation + ":0:false");
|
||||
return new ServerDataPublicationFence(registry, key, generation);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hold through the entire operation, including a blocked write or admitted shutdown handoff. */
|
||||
public Lease acquire() {
|
||||
synchronized (registry) {
|
||||
State current = current();
|
||||
if (current.users >= MAX_OWNERS) throw new IllegalStateException("Server-data publication admission is unavailable");
|
||||
registry.setProperty(key, generation + ":" + (current.users + 1) + ":" + current.retiring);
|
||||
return new Lease(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing operations/retries retain ownership; a successor requires zero outstanding leases. */
|
||||
public void retire() {
|
||||
boolean drained = false;
|
||||
synchronized (registry) {
|
||||
State current = state(registry.get(key));
|
||||
if (generation.equals(current.generation)) {
|
||||
registry.setProperty(key, generation + ":" + current.users + ":true");
|
||||
drained = current.users == 0;
|
||||
}
|
||||
}
|
||||
// Completion callbacks may remove a JVM hook; never run them under the registry monitor.
|
||||
if (drained) processExitCompletion.complete(null);
|
||||
}
|
||||
|
||||
/** Reserve before asynchronous admission; a failed stage never proves drain. */
|
||||
public void retainUntilSuccessfulPublication(java.util.concurrent.CompletionStage<Void> publication) {
|
||||
java.util.Objects.requireNonNull(publication);
|
||||
Lease lease = acquire();
|
||||
publication.whenComplete((ignored, failure) -> {
|
||||
if (failure == null) lease.close();
|
||||
});
|
||||
}
|
||||
|
||||
/** Reserve before disable and release only after existing SDK writers actually terminate. */
|
||||
public java.util.concurrent.CompletionStage<Void> retainUntilTermination(java.util.concurrent.ScheduledExecutorService[] writers) {
|
||||
var snapshot = writers.clone();
|
||||
if (!retirementStarted.compareAndSet(false, true)) return retirementCompleted.minimalCompletionStage();
|
||||
Lease lease;
|
||||
try { lease = acquire(); }
|
||||
catch (RuntimeException | Error failure) { retirementStarted.set(false); throw failure; }
|
||||
try {
|
||||
Thread guardian = new Thread(() -> {
|
||||
boolean interrupted = false;
|
||||
try (lease) {
|
||||
for (var writer : snapshot) {
|
||||
if (writer == null) continue;
|
||||
while (!writer.isTerminated()) {
|
||||
try { writer.awaitTermination(100, java.util.concurrent.TimeUnit.MILLISECONDS); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
retirementCompleted.completeExceptionally(failure);
|
||||
return;
|
||||
} finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
retirementCompleted.complete(null);
|
||||
}, "VotingPlugin-server-data-retirement");
|
||||
guardian.setDaemon(true);
|
||||
guardian.start();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try { lease.close(); }
|
||||
catch (RuntimeException | Error cleanupFailure) { if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); }
|
||||
finally { retirementStarted.set(false); }
|
||||
throw failure;
|
||||
}
|
||||
return retirementCompleted.minimalCompletionStage();
|
||||
}
|
||||
|
||||
private State current() {
|
||||
State current = state(registry.get(key));
|
||||
if (registry != System.getProperties() || !generation.equals(current.generation))
|
||||
throw new IllegalStateException("Server-data publication belongs to another generation");
|
||||
return current;
|
||||
}
|
||||
|
||||
private static State state(Object value) {
|
||||
if (!(value instanceof String text) || text.length() > 64)
|
||||
throw new IllegalStateException("Server-data publication ownership is invalid");
|
||||
String[] fields = text.split(":", -1);
|
||||
try {
|
||||
if (fields.length != 3 || !UUID.fromString(fields[0]).toString().equals(fields[0])
|
||||
|| !(fields[2].equals("true") || fields[2].equals("false"))) throw new IllegalArgumentException();
|
||||
int users = Integer.parseInt(fields[1]);
|
||||
if (users < 0 || users > MAX_OWNERS || !Integer.toString(users).equals(fields[1])) throw new IllegalArgumentException();
|
||||
return new State(fields[0], users, Boolean.parseBoolean(fields[2]));
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IllegalStateException("Server-data publication ownership is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private record State(String generation, int users, boolean retiring) { }
|
||||
|
||||
public static final class Lease implements AutoCloseable {
|
||||
private final ServerDataPublicationFence owner;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private Lease(ServerDataPublicationFence owner) { this.owner = owner; }
|
||||
@Override public void close() {
|
||||
boolean drained;
|
||||
synchronized (owner.registry) {
|
||||
if (closed.get()) return;
|
||||
State current = owner.current();
|
||||
if (current.users <= 0) throw new IllegalStateException("Server-data publication ownership is invalid");
|
||||
owner.registry.setProperty(owner.key, owner.generation + ":" + (current.users - 1) + ":" + current.retiring);
|
||||
closed.set(true);
|
||||
drained = current.retiring && current.users == 1;
|
||||
}
|
||||
if (drained) owner.processExitCompletion.complete(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,12 @@ public final class VoteTaskAdmission {
|
||||
/**
|
||||
* Attempts to submit a one-shot vote task without running it on the caller.
|
||||
*
|
||||
* @param executor bounded vote executor
|
||||
* @param executor bounded vote executor, or null before startup/after shutdown
|
||||
* @param task task to submit
|
||||
* @return true when the task was admitted
|
||||
*/
|
||||
public static boolean trySubmit(ScheduledExecutorService executor, Runnable task) {
|
||||
if (executor == null) return false;
|
||||
try {
|
||||
executor.submit(task);
|
||||
return true;
|
||||
@@ -29,13 +30,14 @@ public final class VoteTaskAdmission {
|
||||
/**
|
||||
* Attempts to schedule a one-shot vote task without blocking the caller.
|
||||
*
|
||||
* @param executor bounded vote executor
|
||||
* @param executor bounded vote executor, or null before startup/after shutdown
|
||||
* @param task task to schedule
|
||||
* @param delay delay before execution
|
||||
* @param unit delay unit
|
||||
* @return true when the task was admitted
|
||||
*/
|
||||
public static boolean trySchedule(ScheduledExecutorService executor, Runnable task, long delay, TimeUnit unit) {
|
||||
if (executor == null) return false;
|
||||
try {
|
||||
executor.schedule(task, delay, unit);
|
||||
return true;
|
||||
|
||||
+153
-121
@@ -29,6 +29,14 @@ import com.bencodez.simpleapi.sql.mysql.DbType;
|
||||
* arbitrary, non-idempotent side effects.</p>
|
||||
*/
|
||||
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 @@ final class SharedMysqlPurchaseJournal {
|
||||
/** 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;
|
||||
}
|
||||
return commitAndConfirm(connection, purchaseId, PENDING);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
throw failure;
|
||||
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);
|
||||
}
|
||||
} 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 @@ final class SharedMysqlPurchaseJournal {
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
refund(connection, uuid, pointsColumn, limitColumn, cost,
|
||||
shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 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 @@ final class SharedMysqlPurchaseJournal {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-36
@@ -186,15 +186,24 @@ public class VoteShopPurchaseService {
|
||||
// or entity task runs could pair an old debit with a newly loaded reward.
|
||||
FileConfiguration shopData = plugin.getShopFile().getData();
|
||||
HashMap<String, String> placeholders = purchasePlaceholders(item);
|
||||
var pointPlaceholders = plugin.getPlaceholders();
|
||||
Consumer<Boolean> 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 class VoteShopPurchaseService {
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException persistenceRejected) {
|
||||
if (admission != null) admission.accept(false);
|
||||
plugin.debug(persistenceRejected);
|
||||
completeFailedPurchase(player, completion);
|
||||
}
|
||||
@@ -479,20 +489,29 @@ public class VoteShopPurchaseService {
|
||||
}
|
||||
|
||||
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 @@ public class VoteShopPurchaseService {
|
||||
|
||||
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.
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn,
|
||||
limitColumn);
|
||||
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);
|
||||
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. */
|
||||
|
||||
@@ -451,6 +451,14 @@ VoteLogging:
|
||||
# Tracks and displays top voters by time period
|
||||
###########################################
|
||||
|
||||
# Hide only confirmed never-joined identities from top voter displays.
|
||||
# Missing, unreadable or ambiguous history stays visible. Awards are unchanged.
|
||||
# Presence checks are asynchronous; pending snapshots stay visible until refreshed.
|
||||
# Evidence expires after 30 seconds. GUI, chat and placeholder views can show
|
||||
# previously hidden rows while fresh checks are pending; stale negatives are not reused.
|
||||
# At most 128 checks run at once, so a large expired board may need several refreshes.
|
||||
HideTopVoterIfNotPlayedBefore: false
|
||||
|
||||
# Top voter blacklist
|
||||
# Hide these names from top voter lists
|
||||
BlackList:
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class DeferredServerDataOwnerPublicationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void deferredNativePublicationSerializesCheckpointsAndRetainsItsActualAcknowledgement() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var ownerWorker = Executors.newSingleThreadExecutor();
|
||||
var checkpointWorker = Executors.newSingleThreadExecutor();
|
||||
var mutationEntered = new CountDownLatch(1);
|
||||
var releaseMutation = new CountDownLatch(1);
|
||||
var nativePublished = new CountDownLatch(1);
|
||||
var releaseAcknowledgement = new CountDownLatch(1);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public CompletionStage<Void> mutateFinalData(Consumer<FileConfiguration> mutation) {
|
||||
return CompletableFuture.supplyAsync(() -> super.mutateFinalData(mutation), ownerWorker)
|
||||
.thenCompose(stage -> stage.thenRun(() -> {
|
||||
assertFalse(Thread.holdsLock(this), "acknowledgement must run outside YAML ownership");
|
||||
nativePublished.countDown();
|
||||
await(releaseAcknowledgement);
|
||||
}));
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("Adjacent.Value", "preserved");
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var inner = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, inner, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
data.getData();
|
||||
var checkpointThread = new AtomicReference<Thread>();
|
||||
var checkpointStarted = new CountDownLatch(1);
|
||||
try {
|
||||
// This returns a genuinely deferred owner stage, not a mocked future.
|
||||
var observation = facade.mutateFinalData(snapshot -> {
|
||||
assertTrue(Thread.holdsLock(backing));
|
||||
assertEquals("preserved", snapshot.getString("Adjacent.Value"));
|
||||
mutationEntered.countDown();
|
||||
await(releaseMutation);
|
||||
snapshot.set("Final.Value", "confirmed");
|
||||
}).toCompletableFuture();
|
||||
assertTrue(mutationEntered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(observation.isDone());
|
||||
var checkpoint = checkpointWorker.submit(() -> {
|
||||
checkpointThread.set(Thread.currentThread());
|
||||
checkpointStarted.countDown();
|
||||
data.setInt("VoteParty.Total", 7);
|
||||
});
|
||||
assertTrue(checkpointStarted.await(5, TimeUnit.SECONDS));
|
||||
var threads = ManagementFactory.getThreadMXBean();
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
boolean blockedByOwner = false;
|
||||
while (!checkpoint.isDone() && System.nanoTime() < deadline) {
|
||||
var info = threads.getThreadInfo(checkpointThread.get().threadId());
|
||||
if (info != null && info.getThreadState() == Thread.State.BLOCKED
|
||||
&& info.getLockInfo() != null
|
||||
&& info.getLockInfo().getIdentityHashCode() == System.identityHashCode(backing)) {
|
||||
blockedByOwner = true;
|
||||
break;
|
||||
}
|
||||
Thread.yield();
|
||||
}
|
||||
assertTrue(blockedByOwner, "ordinary checkpoints must wait for the deferred native YAML owner");
|
||||
releaseMutation.countDown();
|
||||
assertTrue(nativePublished.await(5, TimeUnit.SECONDS));
|
||||
checkpoint.get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals("preserved", disk.getString("Adjacent.Value"));
|
||||
assertEquals(7, disk.getInt("VotingPlugin.VoteParty.Total"));
|
||||
fence.retire();
|
||||
assertFalse(observation.isDone(), "visible native bytes alone do not settle the owner's stage");
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
releaseAcknowledgement.countDown();
|
||||
observation.get(5, TimeUnit.SECONDS);
|
||||
var successor = ServerDataPublicationFence.beginGeneration(target);
|
||||
successor.retire();
|
||||
} finally {
|
||||
releaseMutation.countDown();
|
||||
releaseAcknowledgement.countDown();
|
||||
ownerWorker.shutdownNow();
|
||||
checkpointWorker.shutdownNow();
|
||||
assertTrue(ownerWorker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(checkpointWorker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try { assertTrue(latch.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.streaks.VoteStreaksMenu;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterPlayer;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DisplayUnloadIntegrationTest {
|
||||
private void field(Object target, String name, Object value) throws Exception {
|
||||
var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(target, value);
|
||||
}
|
||||
private ScheduledExecutorService stoppedExecutor() throws Exception {
|
||||
var executor = mock(ScheduledExecutorService.class);
|
||||
when(executor.awaitTermination(anyLong(), any(TimeUnit.class))).thenReturn(true);
|
||||
return executor;
|
||||
}
|
||||
@Test void unloadSettlesPendingLeaderboardEvidenceAndRejectsLaterStreakMenuOpens() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
field(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class);
|
||||
field(plugin, "discordHandler", discord);
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
var generation = new AtomicLong();
|
||||
field(plugin, "topVoterDisplayGeneration", generation);
|
||||
field(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
field(plugin, "backendHostedControlLifecycle", stoppedExecutor());
|
||||
field(plugin, "backendHostedControlManagers", new HashSet<>());
|
||||
field(plugin, "backendControlConnectorLifecycle", stoppedExecutor());
|
||||
field(plugin, "voteTimer", stoppedExecutor());
|
||||
var signs = mock(Signs.class); doReturn(signs).when(plugin).getSigns();
|
||||
when(signs.storeSignsForShutdown()).thenReturn(CompletableFuture.completedFuture(null));
|
||||
field(plugin, "signs", signs);
|
||||
var pending = new CompletableFuture<PlayerPresence>();
|
||||
var filter = new TopVoterDisplayFilter(row -> pending);
|
||||
field(plugin, "topVoterDisplayFilter", filter);
|
||||
var menu = new VoteStreaksMenu(plugin); field(plugin, "voteStreaksMenu", menu);
|
||||
var rankings = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
rankings.put(new TopVoterPlayer(UUID.randomUUID(), "Example", 1L), 10);
|
||||
var display = filter.projectAsync(rankings, true).toCompletableFuture();
|
||||
assertFalse(display.isDone());
|
||||
var player = mock(Player.class);
|
||||
try (var handlers = mockStatic(HandlerList.class); var scheduler = mockStatic(BukkitCompletionScheduler.class)) {
|
||||
scheduler.when(() -> BukkitCompletionScheduler.run(eq(plugin), eq(player), any(Runnable.class), any(Runnable.class), any(Runnable.class)))
|
||||
.thenAnswer(call -> { call.getArgument(2, Runnable.class).run(); return null; });
|
||||
plugin.onUnLoad();
|
||||
verify(discord).close();
|
||||
verify(discord).retireLeaderboardPublications();
|
||||
assertTrue(display.isDone(), "Unload must settle the retired filter's pending display wait");
|
||||
assertEquals(rankings, display.getNow(null));
|
||||
pending.complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals(rankings, filter.project(rankings, true), "Late history results cannot hide rows after retirement");
|
||||
assertEquals(1, generation.get());
|
||||
menu.open(player);
|
||||
verifyNoInteractions(player);
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class GenerationFencedServerDataFinalPublicationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void ordinaryServiceProducerCannotRaceTheActualSDKListSnapshot() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var snapshotEntered = new java.util.concurrent.CountDownLatch(1);
|
||||
var releaseSnapshot = new java.util.concurrent.CountDownLatch(1);
|
||||
var producerStarted = new java.util.concurrent.CountDownLatch(1);
|
||||
var mutatedLiveList = new java.util.concurrent.CountDownLatch(1);
|
||||
var firstSnapshot = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var sites = new java.util.ArrayList<String>() {
|
||||
@Override public boolean add(String value) {
|
||||
boolean added = super.add(value);
|
||||
if (value.equals("SyntheticLateSite")) mutatedLiveList.countDown();
|
||||
return added;
|
||||
}
|
||||
@Override public java.util.stream.Stream<String> stream() {
|
||||
var stream = super.stream();
|
||||
if (!firstSnapshot.compareAndSet(true, false)) return stream;
|
||||
return stream.peek(value -> {
|
||||
snapshotEntered.countDown();
|
||||
try { assertTrue(releaseSnapshot.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
}
|
||||
};
|
||||
sites.add("SyntheticExistingSite");
|
||||
backing.getData().set("VotingPlugin.GottenServiceSites", sites);
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
var workers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var publication = workers.submit(() -> facade.mutateFinalData(snapshot ->
|
||||
snapshot.set("Final.Value", "confirmed")).toCompletableFuture().join());
|
||||
assertTrue(snapshotEntered.await(5, TimeUnit.SECONDS));
|
||||
var producer = workers.submit(() -> {
|
||||
producerStarted.countDown();
|
||||
data.addServiceSite("SyntheticLateSite");
|
||||
});
|
||||
assertTrue(producerStarted.await(5, TimeUnit.SECONDS));
|
||||
boolean overlapped = mutatedLiveList.await(200, TimeUnit.MILLISECONDS);
|
||||
releaseSnapshot.countDown();
|
||||
assertDoesNotThrow(() -> publication.get(5, TimeUnit.SECONDS),
|
||||
"ordinary producers must not invalidate the real SDK final snapshot");
|
||||
producer.get(5, TimeUnit.SECONDS);
|
||||
assertFalse(overlapped, "live service history changed while the SDK owned its snapshot");
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals(java.util.Set.of("SyntheticExistingSite", "SyntheticLateSite"),
|
||||
new java.util.HashSet<>(disk.getStringList("VotingPlugin.GottenServiceSites")));
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
} finally {
|
||||
releaseSnapshot.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryWriterFamiliesMutateOnlyUnderTheActualYamlOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
var yaml = new YamlConfiguration() {
|
||||
@Override public void set(String path, Object value) {
|
||||
assertTrue(Thread.holdsLock(backing), "YAML mutation escaped its actual owner: " + path);
|
||||
super.set(path, value);
|
||||
}
|
||||
};
|
||||
backing.setData(yaml);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
try {
|
||||
data.addServiceSite("SyntheticSite");
|
||||
data.addAutoCachedPlaceholder("SyntheticPlaceholder");
|
||||
data.addVoteShopPurchase("SyntheticItem");
|
||||
data.setBungeeVotePartyCurrent(3);
|
||||
data.setBungeeVotePartyRequired(9);
|
||||
data.setVotePartyExtraRequired(2);
|
||||
data.setShopConverted(true);
|
||||
data.saveDisabledReminders(new java.util.ArrayList<>());
|
||||
data.updatePlaceholders();
|
||||
var party = new com.bencodez.votingplugin.specialrewards.voteparty.VotePartyState(plugin);
|
||||
party.setTotalVotes(4);
|
||||
var users = new java.util.ArrayList<>(java.util.List.of("SyntheticParticipant"));
|
||||
party.setVotedUsers(users);
|
||||
users.add("UnpublishedParticipant");
|
||||
assertEquals(java.util.List.of("SyntheticParticipant"), party.getVotedUsers());
|
||||
// Suppress web refresh only; exercise the real handler and its actual YAML writer.
|
||||
var services = new com.bencodez.votingplugin.servicesites.ServiceSiteHandler(plugin) {
|
||||
@Override public void refreshFromWebOnce(boolean logMore) { }
|
||||
};
|
||||
services.saveToServerData();
|
||||
var world = mock(org.bukkit.World.class);
|
||||
when(world.getName()).thenAnswer(call -> {
|
||||
assertFalse(Thread.holdsLock(backing), "platform lookup ran under YAML ownership");
|
||||
return "SyntheticWorld";
|
||||
});
|
||||
var location = new org.bukkit.Location(world, -1.5, 64, 2.5);
|
||||
data.setSign("0", location, location, "SyntheticSign", 1);
|
||||
data.setSkullLocation("0", location);
|
||||
assertEquals(-1, data.getData().getInt("Signs.0.X"), "retain legacy truncation");
|
||||
data.removeSign("0");
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertEquals(4, disk.getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(java.util.List.of("SyntheticParticipant"), disk.getStringList("VotingPlugin.VoteParty.Voted"));
|
||||
assertEquals(1, disk.getInt("VotingPlugin.VoteShopPurchases.SyntheticItem"));
|
||||
assertFalse(disk.contains("VotingPlugin.Signs.0"));
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryMutationFamiliesWaitBeforeChangingAnOwnedSnapshot() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
data.getData(); // Section already exists: test mutation, not lazy section creation.
|
||||
var party = new com.bencodez.votingplugin.specialrewards.voteparty.VotePartyState(plugin);
|
||||
var services = new com.bencodez.votingplugin.servicesites.ServiceSiteHandler(plugin) {
|
||||
@Override public void refreshFromWebOnce(boolean logMore) { }
|
||||
};
|
||||
var operations = java.util.List.<Runnable>of(
|
||||
() -> data.setBungeeVotePartyCurrent(3),
|
||||
() -> data.addServiceSite("SyntheticSite"),
|
||||
() -> data.setServiceSites(new java.util.ArrayList<>(java.util.List.of("SyntheticReplacement"))),
|
||||
() -> data.addVoteShopPurchase("SyntheticItem"),
|
||||
() -> data.addAutoCachedPlaceholder("SyntheticPlaceholder"),
|
||||
() -> data.updatePlaceholders(),
|
||||
() -> data.removeSign("0"),
|
||||
() -> party.setTotalVotes(4),
|
||||
() -> party.setVotedUsers(java.util.List.of("SyntheticParticipant")),
|
||||
services::saveToServerData);
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
for (Runnable operation : operations) {
|
||||
var started = new java.util.concurrent.CountDownLatch(1);
|
||||
java.util.concurrent.Future<?> future;
|
||||
synchronized (backing) {
|
||||
String before = backing.getData().saveToString();
|
||||
future = worker.submit(() -> { started.countDown(); operation.run(); });
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
assertThrows(java.util.concurrent.TimeoutException.class,
|
||||
() -> future.get(100, TimeUnit.MILLISECONDS));
|
||||
assertEquals(before, backing.getData().saveToString(),
|
||||
"ordinary mutation changed YAML before acquiring the actual snapshot owner");
|
||||
}
|
||||
future.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void serviceListInputsAndOutputsCannotMutatePublishedYamlByAlias() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
try {
|
||||
var supplied = new java.util.ArrayList<>(java.util.List.of("SyntheticExistingSite"));
|
||||
data.setServiceSites(supplied);
|
||||
supplied.add("SyntheticInputAlias");
|
||||
data.getServiceSites().add("SyntheticOutputAlias");
|
||||
assertEquals(java.util.List.of("SyntheticExistingSite"), data.getServiceSitesReadOnly());
|
||||
data.saveData();
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertEquals(java.util.List.of("SyntheticExistingSite"), disk.getStringList("VotingPlugin.GottenServiceSites"));
|
||||
data.setServiceSites(null);
|
||||
assertTrue(data.getServiceSites().isEmpty(), "retain legacy null-as-clear setter behavior");
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void finalMutationConfirmsTheActualSDKOwnersPublication() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
checkFinalMutation();
|
||||
}
|
||||
}
|
||||
|
||||
private void checkFinalMutation() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("Adjacent.Value", "preserved");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
try {
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
facade.mutateFinalData(data -> data.set("Final.Value", "confirmed"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals("preserved", disk.getString("Adjacent.Value"));
|
||||
} finally {
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void asynchronousOwnerRetainsAdmissionUntilItsActualCompletion() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
var published = new CompletableFuture<Void>();
|
||||
when(backing.mutateFinalData(any())).thenReturn(published);
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var observation = facade.mutateFinalData(data -> {}).toCompletableFuture();
|
||||
fence.retire();
|
||||
assertFalse(observation.isDone());
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
observation.cancel(false);
|
||||
assertFalse(published.isCancelled());
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
published.complete(null);
|
||||
var successor = ServerDataPublicationFence.beginGeneration(target);
|
||||
successor.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedOrCancelledPublicationCannotReleaseAdmission() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
for (boolean cancelled : new boolean[] { false, true }) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
var published = new CompletableFuture<Void>();
|
||||
when(backing.mutateFinalData(any())).thenReturn(published);
|
||||
var target = directory.resolve(cancelled ? "Cancelled.yml" : "Failed.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var observation = facade.mutateFinalData(data -> {}).toCompletableFuture();
|
||||
fence.retire();
|
||||
if (cancelled) published.cancel(false);
|
||||
else published.completeExceptionally(new java.io.IOException("synthetic publication failure"));
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> observation.get(5, TimeUnit.SECONDS));
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryMutatorsAndCheckedTransitionsUseTheSameDelegateOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
try {
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
assertSame(backing, facade.publicationMonitor());
|
||||
facade.setPrevDay(12);
|
||||
facade.setPrevMonth("OCTOBER");
|
||||
facade.setPrevWeekDay(41);
|
||||
facade.setIgnoreTime(true);
|
||||
facade.setData("Adjacent.Value", "ordinary");
|
||||
var transition = facade.beginTimeChangeTransition(
|
||||
com.bencodez.advancedcore.api.time.TimeType.DAY, "synthetic-day", "13");
|
||||
assertEquals(transition, backing.getPendingTimeChangeTransition(
|
||||
com.bencodez.advancedcore.api.time.TimeType.DAY));
|
||||
facade.mutateFinalData(data -> data.set("Final.Value", "confirmed"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
facade.completeTimeChangeTransition(transition);
|
||||
assertFalse(backing.isPendingTimeChangeTransition(transition));
|
||||
assertEquals(13, backing.getPrevDay());
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("ordinary", disk.getString("Adjacent.Value"));
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals(13, disk.getInt("PrevDay"));
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void timedCheckpointCannotOvertakeTheNativeFinalSnapshot() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var checkpointStarted = new java.util.concurrent.CountDownLatch(1);
|
||||
var checkpointFinished = new java.util.concurrent.CountDownLatch(1);
|
||||
var workers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var publication = workers.submit(() -> facade.mutateFinalData(snapshot -> {
|
||||
snapshot.set("Final.Value", "confirmed");
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}).toCompletableFuture().join());
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var checkpoint = workers.submit(() -> {
|
||||
checkpointStarted.countDown();
|
||||
data.addTimeVoted(0, new com.bencodez.votingplugin.timequeue.VoteTimeQueue(
|
||||
"Synthetic", "site", 123L));
|
||||
checkpointFinished.countDown();
|
||||
});
|
||||
assertTrue(checkpointStarted.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(checkpointFinished.await(150, TimeUnit.MILLISECONDS));
|
||||
release.countDown();
|
||||
publication.get(5, TimeUnit.SECONDS);
|
||||
checkpoint.get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals("Synthetic", disk.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals(123L, disk.getLong("VotingPlugin.TimedVoteCache.0.Time"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class GenerationFencedShutdownStageTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private VotingPluginMain plugin(ServerDataPublicationFence fence) throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
// Mockito bypasses constructors; initialize the real SDK lifecycle lock.
|
||||
var lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock");
|
||||
lock.setAccessible(true);
|
||||
lock.set(plugin, new Object());
|
||||
var owner = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence");
|
||||
owner.setAccessible(true);
|
||||
owner.set(plugin, fence);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
@Test void disableReservesBeforeTheSDKAdmitsItsFinalStageAndRepeatedDisableDoesNotReleaseIt() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var finalFiles = new CompletableFuture<Void>();
|
||||
// This fixture controls only SDK hook admission; it does not claim a
|
||||
// full runtime cleanup. Adapter tests exercise the real file publisher.
|
||||
try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class)) {
|
||||
plugin.onDisable();
|
||||
fence.retainUntilTermination(plugin.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
plugin.onShutdownComplete(finalFiles);
|
||||
plugin.onDisable();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
finalFiles.complete(null);
|
||||
var successor = ServerDataPublicationFence.beginGeneration(target);
|
||||
successor.retire();
|
||||
plugin.onDisable();
|
||||
assertThrows(IllegalStateException.class, fence::acquire);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disableStartsTheProcessDrainBeforeSdkCleanupAndNeverRenewsIt() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var plugin = plugin(fence);
|
||||
var startedField = ServerDataPublicationFence.class.getDeclaredField("processShutdownStarted");
|
||||
startedField.setAccessible(true);
|
||||
var started = (CompletableFuture<?>) startedField.get(fence);
|
||||
try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
assertTrue(started.isDone(), "The consumer deadline must precede SDK cleanup");
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
plugin.onDisable();
|
||||
var original = started.get(5, TimeUnit.SECONDS);
|
||||
plugin.onDisable();
|
||||
assertEquals(original, started.get());
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedFinalStageRetainsTheGenerationAfterAllCapturedExecutorsTerminate() throws Exception {
|
||||
checkFailure(false);
|
||||
}
|
||||
|
||||
@Test void cancelledFinalStageRetainsTheGenerationAfterAllCapturedExecutorsTerminate() throws Exception {
|
||||
checkFailure(true);
|
||||
}
|
||||
|
||||
private void checkFailure(boolean cancelled) throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var finalFiles = new CompletableFuture<Void>();
|
||||
try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class)) {
|
||||
plugin.onDisable();
|
||||
fence.retainUntilTermination(plugin.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
plugin.onShutdownComplete(finalFiles);
|
||||
if (cancelled) finalFiles.cancel(false);
|
||||
else finalFiles.completeExceptionally(new java.io.IOException("synthetic final-stage failure"));
|
||||
plugin.onDisable();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
// A later duplicate hook cannot replace the failed outcome.
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.api.item.FullInventoryHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class SDKServerDataOwnerLockTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void nativeInventoryOwnerExcludesOrdinaryPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(false, false);
|
||||
}
|
||||
|
||||
@Test void nativeInventoryOwnerExcludesFinalPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(false, true);
|
||||
}
|
||||
|
||||
@Test void wrappedInventoryOwnerExcludesOrdinaryPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(true, false);
|
||||
}
|
||||
|
||||
@Test void wrappedInventoryOwnerExcludesFinalPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(true, true);
|
||||
}
|
||||
|
||||
@Test void composedInventoryOwnerExcludesOrdinaryPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(2, false);
|
||||
}
|
||||
|
||||
@Test void composedInventoryOwnerExcludesFinalPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(2, true);
|
||||
}
|
||||
|
||||
@Test void composedFacadeAdvertisesTheInnermostYamlOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
try {
|
||||
var inner = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var outer = new GenerationFencedServerDataFile(plugin, inner, fence);
|
||||
assertSame(backing, outer.publicationMonitor(), "the delegate can itself be an adapter");
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
private void assertInventoryReplacementIsAtomic(boolean wrapped, boolean finalPublication) throws Exception {
|
||||
assertInventoryReplacementIsAtomic(wrapped ? 1 : 0, finalPublication);
|
||||
}
|
||||
|
||||
private void assertInventoryReplacementIsAtomic(int facadeDepth, boolean finalPublication) throws Exception {
|
||||
org.bukkit.configuration.serialization.ConfigurationSerialization.registerClass(PendingItem.class);
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
when(plugin.getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class));
|
||||
// Exercise the public SDK writer. Startup has no optional file yet;
|
||||
// install the real owner before admitting either writer below.
|
||||
var inventory = new FullInventoryHandler(plugin);
|
||||
inventory.getTimer().shutdownNow();
|
||||
var cleared = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var armed = new AtomicBoolean();
|
||||
var observedIntermediate = new AtomicBoolean();
|
||||
var yaml = new YamlConfiguration() {
|
||||
@Override public void set(String path, Object value) {
|
||||
super.set(path, value);
|
||||
if (path.equals("FullInventory") && value == null && armed.compareAndSet(true, false)) {
|
||||
cleared.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public String saveToString() {
|
||||
String text = super.saveToString();
|
||||
if (cleared.getCount() == 0 && release.getCount() != 0 && !contains("FullInventory")) {
|
||||
observedIntermediate.set(true);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
};
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void saveDataChecked() {
|
||||
// New SDK owners mutate an isolated candidate, so clearing
|
||||
// the live YAML is no longer a guaranteed observable seam.
|
||||
// Pause the actual native publication instead. The legacy
|
||||
// clear hook above still exercises its temporary-live gap.
|
||||
if (armed.compareAndSet(true, false)) {
|
||||
assertTrue(Thread.holdsLock(this),
|
||||
"native snapshot publication must own the backing monitor");
|
||||
cleared.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
super.saveDataChecked();
|
||||
}
|
||||
};
|
||||
backing.setData(yaml);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
com.bencodez.advancedcore.data.ServerData composed = backing;
|
||||
for (int depth = 0; depth < facadeDepth; depth++) {
|
||||
composed = new GenerationFencedServerDataFile(plugin, composed, fence);
|
||||
}
|
||||
var exposed = composed;
|
||||
when(plugin.getServerDataFile()).thenReturn(exposed);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
data.getData();
|
||||
UUID previous = new UUID(0, 2);
|
||||
inventory.getItems().put(previous, new ArrayList<>(java.util.List.of(new PendingItem(1))));
|
||||
assertTrue(inventory.saveDurably());
|
||||
var initialDisk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertInstanceOf(PendingItem.class, initialDisk.get("FullInventory." + previous + ".Items.0"));
|
||||
assertEquals(1, initialDisk.getItemStack("FullInventory." + previous + ".Items.0").getAmount());
|
||||
inventory.getItems().clear();
|
||||
UUID replacement = new UUID(0, 1);
|
||||
inventory.getItems().put(replacement, new ArrayList<>(java.util.List.of(new PendingItem(2))));
|
||||
var workers = Executors.newFixedThreadPool(2);
|
||||
var inventoryThread = new AtomicReference<Thread>();
|
||||
var competingThread = new AtomicReference<Thread>();
|
||||
var competingStarted = new CountDownLatch(1);
|
||||
try {
|
||||
armed.set(true);
|
||||
var saving = workers.submit(() -> {
|
||||
inventoryThread.set(Thread.currentThread());
|
||||
return inventory.saveDurably();
|
||||
});
|
||||
assertTrue(cleared.await(5, TimeUnit.SECONDS));
|
||||
var competing = workers.submit(() -> {
|
||||
competingThread.set(Thread.currentThread());
|
||||
competingStarted.countDown();
|
||||
if (finalPublication) {
|
||||
exposed.mutateFinalData(snapshot -> {
|
||||
if (release.getCount() != 0 && !snapshot.contains("FullInventory")) {
|
||||
observedIntermediate.set(true);
|
||||
}
|
||||
snapshot.set("SyntheticFinal.Value", "confirmed");
|
||||
}).toCompletableFuture().join();
|
||||
} else {
|
||||
data.setInt("VoteParty.Total", 7);
|
||||
}
|
||||
});
|
||||
assertTrue(competingStarted.await(5, TimeUnit.SECONDS));
|
||||
var threads = ManagementFactory.getThreadMXBean();
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
boolean blockedByOwner = false;
|
||||
while (!observedIntermediate.get() && !competing.isDone() && System.nanoTime() < deadline) {
|
||||
var info = threads.getThreadInfo(competingThread.get().threadId());
|
||||
if (info != null && info.getThreadState() == Thread.State.BLOCKED
|
||||
&& info.getLockOwnerId() == inventoryThread.get().threadId()
|
||||
&& info.getLockInfo() != null
|
||||
&& info.getLockInfo().getIdentityHashCode() == System.identityHashCode(backing)) {
|
||||
blockedByOwner = true;
|
||||
break;
|
||||
}
|
||||
Thread.yield();
|
||||
}
|
||||
assertTrue(blockedByOwner || observedIntermediate.get() || competing.isDone(),
|
||||
"competing writer never reached the ownership or serialization boundary");
|
||||
// Confirm the defective publication before releasing the SDK
|
||||
// replacement, rather than racing two unsynchronized YAML edits.
|
||||
if (observedIntermediate.get()) competing.get(5, TimeUnit.SECONDS);
|
||||
boolean publishedIntermediate = observedIntermediate.get();
|
||||
release.countDown();
|
||||
assertTrue(saving.get(5, TimeUnit.SECONDS));
|
||||
competing.get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertTrue(disk.contains("FullInventory." + replacement + ".Time"));
|
||||
assertEquals(2, disk.getItemStack("FullInventory." + replacement + ".Items.0").getAmount());
|
||||
assertFalse(disk.contains("FullInventory." + previous));
|
||||
if (finalPublication) assertEquals("confirmed", disk.getString("SyntheticFinal.Value"));
|
||||
else assertEquals(7, disk.getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertFalse(publishedIntermediate,
|
||||
"a successful publication serialized the SDK's temporarily cleared recovery section");
|
||||
assertTrue(blockedByOwner, "the competing writer must share the actual SDK transaction monitor");
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
inventory.getTimer().shutdownNow();
|
||||
assertTrue(inventory.getTimer().awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
} finally {
|
||||
org.bukkit.configuration.serialization.ConfigurationSerialization.unregisterClass(PendingItem.class);
|
||||
}
|
||||
}
|
||||
|
||||
/** Headless item serialization keeps the actual SDK writer and recovery YAML in the test. */
|
||||
@org.bukkit.configuration.serialization.SerializableAs("VPServerDataMonitorTestItem")
|
||||
public static final class PendingItem extends org.bukkit.inventory.ItemStack {
|
||||
public PendingItem(int amount) { super(org.bukkit.Material.STONE, amount); }
|
||||
@Override public java.util.Map<String, Object> serialize() { return java.util.Map.of("amount", getAmount()); }
|
||||
public static PendingItem deserialize(java.util.Map<String, Object> values) {
|
||||
return new PendingItem(((Number) values.get("amount")).intValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
import com.bencodez.advancedcore.api.time.TimeType;
|
||||
import com.bencodez.advancedcore.data.ServerData;
|
||||
import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
|
||||
class SDKServerDataPublicationTest {
|
||||
@TempDir Path directory;
|
||||
private static final class StartupReached extends IllegalStateException { }
|
||||
|
||||
private VotingPluginMain plugin() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
// Mockito bypasses constructors; use the SDK's actual lifecycle lock.
|
||||
var lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock");
|
||||
lock.setAccessible(true);
|
||||
lock.set(plugin, new Object());
|
||||
doNothing().when(plugin).registerEvents(any());
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doReturn("VotingPlugin").when(plugin).getName();
|
||||
doReturn(new PluginDescriptionFile("VotingPlugin", "1", "synthetic.Main")).when(plugin).getDescription();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static org.mockito.MockedConstruction<AdvancedCoreLifecycle> completedCleanup() {
|
||||
// Control only unrelated platform cleanup. Real SDK startup admission,
|
||||
// file publishers and VotingPlugin generation ownership stay in use.
|
||||
// These fixtures do not claim full platform shutdown validation.
|
||||
return mockConstruction(AdvancedCoreLifecycle.class, (cleanup, context) -> {
|
||||
var plugin = (VotingPluginMain) context.arguments().get(0);
|
||||
doAnswer(call -> {
|
||||
var completed = CompletableFuture.<Void>completedFuture(null);
|
||||
plugin.onShutdownComplete(completed);
|
||||
plugin.releaseServerDataOwnershipAfter(completed);
|
||||
return null;
|
||||
}).when(cleanup).shutdown(anyLong());
|
||||
});
|
||||
}
|
||||
|
||||
private static void awaitCapturedRetirement(VotingPluginMain plugin) throws Exception {
|
||||
plugin.getServerDataPublicationFence().retainUntilTermination(plugin.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static void installSDKFile(VotingPluginMain plugin, ServerData file) throws Exception {
|
||||
var field = AdvancedCorePlugin.class.getDeclaredField("serverDataFile");
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, file);
|
||||
}
|
||||
|
||||
private static ScheduledExecutorService terminatedExecutor() {
|
||||
var executor = mock(ScheduledExecutorService.class);
|
||||
when(executor.isTerminated()).thenReturn(true);
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Test void rejectedSDKAdmissionRetiresOnlyItsUnusedPluginGenerationAndCanRetryAfterRelease() throws Exception {
|
||||
var plugin = plugin();
|
||||
var delayedRelease = new CompletableFuture<Void>();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
plugin.releaseServerDataOwnershipAfter(delayedRelease);
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
awaitCapturedRetirement(plugin);
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
var failure = assertThrows(IllegalStateException.class, plugin::onEnable);
|
||||
assertEquals("Previous ServerData shutdown has not finished", failure.getMessage());
|
||||
plugin.onDisable();
|
||||
}
|
||||
delayedRelease.complete(null);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
awaitCapturedRetirement(plugin);
|
||||
} finally {
|
||||
delayedRelease.complete(null);
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
if (plugin.getServerDataPublicationFence() != null) plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void admittedSDKInitializationFailureStillRetainsItsPendingFinalPublication() throws Exception {
|
||||
var plugin = plugin();
|
||||
var pendingPublication = new CompletableFuture<Void>();
|
||||
var initializationFailure = new IllegalStateException("synthetic executor initialization failure");
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
plugin.onShutdownComplete(pendingPublication);
|
||||
plugin.releaseServerDataOwnershipAfter(pendingPublication);
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any()))
|
||||
.thenThrow(initializationFailure).thenReturn(runtime);
|
||||
assertSame(initializationFailure, assertThrows(IllegalStateException.class, plugin::onEnable));
|
||||
verify(plugin, never()).onPreLoad();
|
||||
assertFalse(pendingPublication.isDone());
|
||||
assertThrows(IllegalStateException.class, plugin::onEnable);
|
||||
pendingPublication.complete(null);
|
||||
awaitCapturedRetirement(plugin);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
awaitCapturedRetirement(plugin);
|
||||
} finally {
|
||||
pendingPublication.complete(null);
|
||||
if (plugin.getServerDataPublicationFence() != null) plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualSDKVersionCallbackRetainsOwnershipOutsideCapturedExecutors() throws Exception {
|
||||
var old = plugin(); var next = plugin();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var scheduler = mock(BukkitScheduler.class);
|
||||
var callback = new AtomicReference<Runnable>();
|
||||
doAnswer(call -> { callback.set(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTaskAsynchronously(any(), any());
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(scheduler,
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService externalWriter = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var cleanup = completedCleanup();
|
||||
var constructors = mockConstruction(BukkitScheduler.class)) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
var writingReference = new AtomicReference<Future<?>>();
|
||||
doAnswer(startup -> {
|
||||
var backing = new ServerData(old);
|
||||
backing.setData(yaml); backing.setPluginVersion(old);
|
||||
installSDKFile(old, backing);
|
||||
assertSame(yaml, old.getServerDataFile().getData());
|
||||
old.setLoadServerData(true);
|
||||
old.checkPluginUpdate();
|
||||
assertNotNull(callback.get());
|
||||
doAnswer(call -> {
|
||||
entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
// The SDK now cleans up a failed startup itself. Start the
|
||||
// real captured writer before that cleanup retires startup.
|
||||
writingReference.set(externalWriter.submit(callback.get()));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
throw new StartupReached();
|
||||
}).when(old).onPreLoad();
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
Future<?> writing = writingReference.get();
|
||||
assertNotNull(writing);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
old.onDisable();
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached,
|
||||
"SDK publication must retain ownership even when every captured executor is terminated");
|
||||
verify(next, never()).onPreLoad();
|
||||
release.countDown(); writing.get(5, TimeUnit.SECONDS);
|
||||
var successor = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
successor.set("Adjacent.Value", "successor"); successor.save(target.toFile());
|
||||
doAnswer(startup -> {
|
||||
// Run while the SDK's replacement plugin identity is live,
|
||||
// before its sentinel failure triggers automatic cleanup.
|
||||
assertThrows(IllegalStateException.class, callback.get()::run,
|
||||
"an old callback that starts after replacement cannot publish");
|
||||
assertThrows(IllegalStateException.class, () -> old.getServerDataFile().setPrevDay(12));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("Adjacent.Value"));
|
||||
throw new StartupReached();
|
||||
}).when(next).onPreLoad();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
awaitCapturedRetirement(next);
|
||||
next.getServerDataPublicationFence().retire();
|
||||
} finally {
|
||||
release.countDown(); externalWriter.shutdownNow();
|
||||
assertTrue(externalWriter.awaitTermination(5, TimeUnit.SECONDS));
|
||||
if (old.getServerDataPublicationFence() != null) old.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inheritedFileOperationsKeepTheSDKConfigurationAuthoritative() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var backing = new ServerData(plugin); backing.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, backing);
|
||||
var facade = plugin.getServerDataFile();
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
facade.setIgnoreCase(true);
|
||||
assertTrue(backing.isIgnoreCase()); assertTrue(facade.isIgnoreCase());
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
facade.setValue("Sample.Value", "kept");
|
||||
facade.createSection("Sample.Section");
|
||||
var merged = new YamlConfiguration(); merged.set("Adjacent.Value", "merged");
|
||||
facade.setData(merged); facade.saveData();
|
||||
facade.reloadData();
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
assertEquals("kept", facade.getData().getString("Sample.Value"));
|
||||
assertEquals("merged", facade.getData().getString("Adjacent.Value"));
|
||||
assertNotNull(facade.getData().getConfigurationSection("Sample.Section"));
|
||||
assertEquals(backing.isJustCreated(), facade.isJustCreated());
|
||||
assertEquals(backing.isFailedToRead(), facade.isFailedToRead());
|
||||
assertSame(backing.getPlugin(), facade.getPlugin());
|
||||
assertEquals(backing.getdFile(), facade.getdFile());
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void oldVotingPluginWrapperDoesNotBorrowTheSamePluginObjectsReplacementFileOrFence() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var first = new ServerData(plugin); first.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, first);
|
||||
var stale = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
stale.getData().set("Adjacent.Value", "first"); stale.saveData();
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, replacement);
|
||||
var current = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
current.getData().set("Adjacent.Value", "successor"); current.saveData();
|
||||
assertThrows(IllegalStateException.class, () -> stale.addTimeVoted(0,
|
||||
new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L)));
|
||||
assertEquals("successor", current.getData().getString("Adjacent.Value"));
|
||||
assertNull(current.getData().get("TimedVoteCache"));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile())
|
||||
.getString("VotingPlugin.Adjacent.Value"));
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void firstUseOfAnOldWrapperAfterReenableDoesNotMutateTheReplacementFile() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var first = new ServerData(plugin); first.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, first);
|
||||
var stale = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, replacement);
|
||||
var current = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
current.getData().set("Adjacent.Value", "successor"); current.saveData();
|
||||
assertThrows(IllegalStateException.class, () -> stale.addTimeVoted(0,
|
||||
new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L)));
|
||||
assertEquals("successor", current.getData().getString("Adjacent.Value"));
|
||||
assertNull(current.getData().get("TimedVoteCache"));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile())
|
||||
.getString("VotingPlugin.Adjacent.Value"));
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReenableBeforeFileReplacementCanRecoverAfterNativeTimestampCleanup() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
var observed = new AtomicReference<CompletionStage<Void>>();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
// Exercise the actual SDK timestamp submission, while
|
||||
// unrelated platform teardown remains controlled.
|
||||
var platform = new com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform(plugin);
|
||||
var method = platform.getClass().getDeclaredMethod("submitTimestampPersistence", Executor.class);
|
||||
method.setAccessible(true);
|
||||
var workerField = platform.getClass().getDeclaredField("persistenceWorker");
|
||||
workerField.setAccessible(true);
|
||||
var worker = (ExecutorService) workerField.get(platform);
|
||||
CompletionStage<Void> stage;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
var submitted = (CompletionStage<Void>) method.invoke(platform, worker);
|
||||
stage = submitted;
|
||||
} finally {
|
||||
worker.shutdown();
|
||||
}
|
||||
observed.set(stage);
|
||||
plugin.onShutdownComplete(stage);
|
||||
plugin.releaseServerDataOwnershipAfter(stage);
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
var initialFailure = assertThrows(StartupReached.class, plugin::onEnable);
|
||||
assertEquals(0, initialFailure.getSuppressed().length,
|
||||
"native timestamp cleanup must actually execute without hidden fixture failures");
|
||||
assertNotNull(observed.get(), "capture the actual SDK final-publication stage");
|
||||
observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
awaitCapturedRetirement(plugin);
|
||||
var backing = new ServerData(plugin); backing.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, backing);
|
||||
var retiredFacade = plugin.getServerDataFile();
|
||||
assertNotNull(retiredFacade);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
awaitCapturedRetirement(plugin);
|
||||
assertThrows(IllegalStateException.class, () -> retiredFacade.setPrevDay(12));
|
||||
assertThrows(StartupReached.class, plugin::onEnable,
|
||||
"a failed pre-load must not permanently reserve either generation owner");
|
||||
observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
awaitCapturedRetirement(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fileReadBetweenReenableAndSDKReplacementWithholdsPredecessorAndKeepsCapturedFence() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var backing = new ServerData(plugin); backing.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, backing);
|
||||
backing.setPrevDay(1);
|
||||
var predecessor = plugin.getServerDataFile();
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
var intervening = new AtomicReference<ServerData>();
|
||||
doAnswer(call -> {
|
||||
intervening.set(plugin.getServerDataFile());
|
||||
throw new StartupReached();
|
||||
}).when(plugin).onPreLoad();
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, replacement);
|
||||
plugin.getServerDataFile().setValue("Adjacent.Value", "successor");
|
||||
assertNull(intervening.get(), "pre-load must not expose a predecessor file to native final publication");
|
||||
assertThrows(IllegalStateException.class, () -> predecessor.setPrevDay(12));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile())
|
||||
.getString("Adjacent.Value"));
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkDurableTransitionPublicationAlsoOwnsTheGenerationUntilItReturns() throws Exception {
|
||||
var old = plugin(); var next = plugin();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService externalWriter = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
var backing = new ServerData(old); backing.setData(yaml); installSDKFile(old, backing);
|
||||
doAnswer(call -> {
|
||||
entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).saveToString();
|
||||
var file = old.getServerDataFile();
|
||||
Future<ServerData.TimeChangeTransitionState> writing = externalWriter.submit(() -> file.beginTimeChangeTransition(TimeType.DAY, "synthetic-period", "12"));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
old.getServerDataPublicationFence().retire();
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached);
|
||||
release.countDown(); var transition = writing.get(5, TimeUnit.SECONDS);
|
||||
var restored = new ServerData(old); restored.setData(YamlConfiguration.loadConfiguration(target.toFile()));
|
||||
assertEquals(transition, restored.getPendingTimeChangeTransition(TimeType.DAY));
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertThrows(IllegalStateException.class, () -> file.completeTimeChangeTransition(transition));
|
||||
next.getServerDataPublicationFence().retire();
|
||||
} finally {
|
||||
release.countDown(); externalWriter.shutdownNow();
|
||||
assertTrue(externalWriter.awaitTermination(5, TimeUnit.SECONDS));
|
||||
if (old.getServerDataPublicationFence() != null) old.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
|
||||
class ServerDataGenerationShutdownTest {
|
||||
@TempDir Path directory;
|
||||
private static final class StartupReached extends IllegalStateException { }
|
||||
|
||||
private static void initializePlugin(VotingPluginMain plugin) throws Exception {
|
||||
// Constructor-bypassing Mockito objects need the real SDK lifecycle lock.
|
||||
var lock = com.bencodez.advancedcore.AdvancedCorePlugin.class.getDeclaredField("lifecycleLock");
|
||||
lock.setAccessible(true);
|
||||
lock.set(plugin, new Object());
|
||||
doNothing().when(plugin).registerEvents(any());
|
||||
}
|
||||
|
||||
private static org.mockito.MockedConstruction<AdvancedCoreLifecycle> completedCleanup() {
|
||||
// Only platform cleanup is controlled. Actual plugin admission, timed
|
||||
// publication and generation/writer reservations remain under test.
|
||||
return mockConstruction(AdvancedCoreLifecycle.class, (cleanup, context) -> {
|
||||
var plugin = (VotingPluginMain) context.arguments().get(0);
|
||||
doAnswer(call -> {
|
||||
for (var writer : plugin.getServerDataWriterExecutors())
|
||||
if (writer != null) writer.shutdown();
|
||||
var completed = CompletableFuture.<Void>completedFuture(null);
|
||||
plugin.onShutdownComplete(completed);
|
||||
plugin.releaseServerDataOwnershipAfter(completed);
|
||||
return null;
|
||||
}).when(cleanup).shutdown(anyLong());
|
||||
});
|
||||
}
|
||||
|
||||
private static ScheduledExecutorService terminatedExecutor() {
|
||||
var executor = mock(ScheduledExecutorService.class);
|
||||
when(executor.isTerminated()).thenReturn(true);
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Test void failedShutdownSuffixKeepsItsGenerationUntilExplicitRetryPersistsEveryVote() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
initializePlugin(plugin);
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target);
|
||||
var field = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence");
|
||||
field.setAccessible(true); field.set(plugin, fence);
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(plugin).getServerDataFile();
|
||||
doReturn(new ServerData(plugin)).when(plugin).getServerData();
|
||||
doReturn(new ScheduledExecutorService[0]).when(plugin).getServerDataWriterExecutors();
|
||||
var producer = new ScheduledThreadPoolExecutor(1);
|
||||
producer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producer).when(plugin).getVoteTimer();
|
||||
try {
|
||||
var handler = new TimeQueueHandler(plugin);
|
||||
handler.addVote("First", "site");
|
||||
handler.addVote("Second", "site");
|
||||
handler.addVote("Third", "site");
|
||||
producer.shutdown(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
int attempt = writes.incrementAndGet();
|
||||
if (attempt == 2 || attempt == 3) throw new java.io.IOException("synthetic suffix failure");
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown);
|
||||
fence.retire();
|
||||
assertEquals(2, handler.getTimeChangeQueue().size());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target));
|
||||
assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown);
|
||||
assertEquals(2, handler.getTimeChangeQueue().size());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target));
|
||||
handler.saveForShutdown();
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
var recovered = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("First", recovered.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals("Second", recovered.getString("VotingPlugin.TimedVoteCache.1.Name"));
|
||||
assertEquals("Third", recovered.getString("VotingPlugin.TimedVoteCache.2.Name"));
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
} finally {
|
||||
producer.shutdownNow(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedShutdownWorkerRetryRetainsTheOriginalProducerAfterUnloadClearsItsField() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
initializePlugin(plugin);
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target);
|
||||
var field = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence");
|
||||
field.setAccessible(true); field.set(plugin, fence);
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(plugin).getServerDataFile();
|
||||
doReturn(new ServerData(plugin)).when(plugin).getServerData();
|
||||
var releaseCore = new CountDownLatch(1);
|
||||
var coreWriter = mock(ScheduledExecutorService.class);
|
||||
when(coreWriter.isTerminated()).thenAnswer(call -> releaseCore.getCount() == 0);
|
||||
when(coreWriter.awaitTermination(anyLong(), any())).thenAnswer(call ->
|
||||
releaseCore.await(call.getArgument(0), call.getArgument(1)));
|
||||
var capturedWriters = new ScheduledExecutorService[] { coreWriter };
|
||||
doReturn(capturedWriters).when(plugin).getServerDataWriterExecutors();
|
||||
var producer = new ScheduledThreadPoolExecutor(1);
|
||||
producer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producer).when(plugin).getVoteTimer();
|
||||
var failWrites = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
if (failWrites.get()) throw new java.io.IOException("synthetic late suffix failure");
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
var handler = new TimeQueueHandler(plugin) {
|
||||
private boolean reject = true;
|
||||
@Override protected void startShutdownWorker(Runnable task) {
|
||||
if (reject) { reject = false; throw new RejectedExecutionException("synthetic admission failure"); }
|
||||
super.startShutdownWorker(task);
|
||||
}
|
||||
};
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var lateFailure = new java.util.concurrent.atomic.AtomicReference<Throwable>();
|
||||
try {
|
||||
producer.submit(() -> {
|
||||
entered.countDown();
|
||||
boolean interrupted = false;
|
||||
while (true) {
|
||||
try { if (release.await(10, TimeUnit.SECONDS)) break; throw new AssertionError("release timeout"); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
try { handler.addVote("Late", "site"); }
|
||||
catch (Throwable failure) { lateFailure.set(failure); }
|
||||
finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
producer.shutdownNow();
|
||||
assertThrows(RejectedExecutionException.class, handler::saveForShutdown);
|
||||
fence.retire();
|
||||
// The actual unload path clears this accessor even if shutdownNow did not terminate work.
|
||||
doReturn(null).when(plugin).getVoteTimer();
|
||||
capturedWriters[0] = null;
|
||||
handler.saveForShutdown();
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire());
|
||||
release.countDown(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertInstanceOf(java.io.UncheckedIOException.class, lateFailure.get());
|
||||
// The original core writer is also retained, independent of the caller's mutable array.
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire());
|
||||
releaseCore.countDown();
|
||||
assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown);
|
||||
assertEquals(1, handler.getTimeChangeQueue().size());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target));
|
||||
failWrites.set(false); handler.saveForShutdown();
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
} finally {
|
||||
release.countDown(); releaseCore.countDown(); producer.shutdownNow();
|
||||
assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
failWrites.set(false);
|
||||
// Do not start a new retry from cleanup or hide the failed ownership assertion.
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedStartupWithoutATimedQueueKeepsCoreWritesFencedThroughActualDisable() throws Exception {
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
ScheduledThreadPoolExecutor writer = new ScheduledThreadPoolExecutor(1);
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
writer, terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
var runningReference = new java.util.concurrent.atomic.AtomicReference<Future<?>>();
|
||||
doAnswer(startup -> {
|
||||
runningReference.set(writer.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
throw new StartupReached();
|
||||
}).when(old).onPreLoad();
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached);
|
||||
Future<?> running = runningReference.get();
|
||||
assertNotNull(running);
|
||||
old.onDisable();
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached);
|
||||
var completion = old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors());
|
||||
assertFalse(completion.toCompletableFuture().isDone());
|
||||
release.countDown(); running.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
completion.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
next.getServerDataPublicationFence().retire();
|
||||
} finally { release.countDown(); writer.shutdownNow(); assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
|
||||
@Test void heartbeatWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("heartbeat"); }
|
||||
@Test void loginWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("login"); }
|
||||
@Test void inventoryWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("inventory"); }
|
||||
@Test void timeCheckerWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("timechecker"); }
|
||||
|
||||
private void checkCoreWriter(String writerKind) throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
yaml.set("Adjacent.Value", "old");
|
||||
yaml.save(target.toFile());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
doReturn(new ServerData(old)).when(old).getServerData();
|
||||
doReturn(terminatedExecutor()).when(old).getVoteTimer();
|
||||
ScheduledThreadPoolExecutor writer = new ScheduledThreadPoolExecutor(1);
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
switch (writerKind) {
|
||||
case "heartbeat" -> doReturn(writer).when(old).getTimer();
|
||||
case "login" -> doReturn(writer).when(old).getLoginTimer();
|
||||
case "inventory" -> doReturn(writer).when(old).getInventoryTimer();
|
||||
case "timechecker" -> {
|
||||
var checker = mock(com.bencodez.advancedcore.api.time.TimeChecker.class);
|
||||
doReturn(checker).when(old).getTimeChecker();
|
||||
when(checker.getTimer()).thenReturn(writer);
|
||||
}
|
||||
default -> throw new AssertionError(writerKind);
|
||||
}
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
doAnswer(call -> {
|
||||
entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
Future<?> writing = writer.submit(() -> {
|
||||
try { file.getData().save(file.getdFile()); }
|
||||
catch (java.io.IOException failure) { throw new java.io.UncheckedIOException(failure); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached);
|
||||
verify(next, never()).onPreLoad();
|
||||
writer.shutdown(); release.countDown(); writing.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
} finally {
|
||||
release.countDown(); writer.shutdownNow(); caller.shutdownNow();
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void initialShutdownWriteFailureStillRetainsTheRunningProducerReservation() throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
doReturn(new ServerData(old)).when(old).getServerData();
|
||||
ScheduledThreadPoolExecutor producers = new ScheduledThreadPoolExecutor(1);
|
||||
producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producers).when(old).getVoteTimer();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
handler.addVote("First", "site");
|
||||
var late = producers.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
handler.addVote("Late", "site");
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS)); producers.shutdown();
|
||||
doThrow(new java.io.IOException("synthetic initial write failure")).doCallRealMethod()
|
||||
.when(yaml).save(target.toFile());
|
||||
caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached);
|
||||
release.countDown(); late.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
try { handler.saveForShutdown(); } catch (java.io.UncheckedIOException observedInitialFailure) { }
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertEquals("First", YamlConfiguration.loadConfiguration(target.toFile()).getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile()).getString("VotingPlugin.TimedVoteCache.1.Name"));
|
||||
} finally {
|
||||
release.countDown(); producers.shutdownNow(); caller.shutdownNow();
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void emptyShutdownSnapshotStillOwnsLateProducersUntilTheyFinish() throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
var yaml = new YamlConfiguration();
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
var data = new ServerData(old);
|
||||
doReturn(data).when(old).getServerData();
|
||||
ScheduledThreadPoolExecutor producers = new ScheduledThreadPoolExecutor(1);
|
||||
producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producers).when(old).getVoteTimer();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
Future<?> late = producers.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
handler.addVote("Late", "site");
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
producers.shutdown();
|
||||
caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached);
|
||||
verify(next, never()).onPreLoad();
|
||||
release.countDown();
|
||||
late.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
} finally {
|
||||
release.countDown(); producers.shutdownNow(); caller.shutdownNow();
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacementCannotReadOrWriteServerDataWhilePreviousShutdownPublicationIsBlocked() throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (VotingPluginMain plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
}
|
||||
YamlConfiguration yaml = spy(new YamlConfiguration());
|
||||
yaml.set("Adjacent.Value", "old");
|
||||
yaml.save(target.toFile());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
ServerData data = new ServerData(old);
|
||||
doReturn(data).when(old).getServerData();
|
||||
doReturn(terminatedExecutor()).when(old).getVoteTimer();
|
||||
doThrow(new StartupReached()).when(old).onPreLoad();
|
||||
doAnswer(call -> {
|
||||
YamlConfiguration replacement = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
replacement.set("Adjacent.Value", "replacement");
|
||||
replacement.save(target.toFile());
|
||||
throw new StartupReached();
|
||||
}).when(next).onPreLoad();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(),
|
||||
terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
handler.addVote("First", "site");
|
||||
doAnswer(call -> {
|
||||
entered.countDown();
|
||||
assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
Future<?> shutdown = caller.submit(handler::saveForShutdown);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
shutdown.get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached, "replacement must be fenced before any startup read/write");
|
||||
verify(next, never()).onPreLoad();
|
||||
release.countDown();
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertThrows(IllegalStateException.class, () -> data.addTimeVoted(1,
|
||||
new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L)));
|
||||
assertEquals("replacement", YamlConfiguration.loadConfiguration(target.toFile()).getString("Adjacent.Value"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class ServerDataProcessExitDrainTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
/** Observe entry to the real bounded wait without replacing its completion semantics. */
|
||||
private static final class ObservedCompletion extends CompletableFuture<Void> {
|
||||
final CountDownLatch entered = new CountDownLatch(1);
|
||||
volatile long nanos;
|
||||
@Override public Void get(long timeout, TimeUnit unit)
|
||||
throws InterruptedException, ExecutionException, TimeoutException {
|
||||
nanos = unit.toNanos(timeout);
|
||||
entered.countDown();
|
||||
return super.get(timeout, unit);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object field(Object owner, String name) throws Exception {
|
||||
var f = owner.getClass().getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f.get(owner);
|
||||
}
|
||||
|
||||
private static ObservedCompletion observe(ServerDataPublicationFence fence) throws Exception {
|
||||
var completion = new ObservedCompletion();
|
||||
var f = ServerDataPublicationFence.class.getDeclaredField("processExitCompletion");
|
||||
f.setAccessible(true);
|
||||
f.set(fence, completion);
|
||||
return completion;
|
||||
}
|
||||
|
||||
@Test void outstandingLeaseKeepsTheActualProcessHookWaitingUntilRetirementDrains() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
var lease = fence.acquire();
|
||||
fence.retainForProcessExit();
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
fence.retainForProcessExit();
|
||||
assertSame(hook, field(fence, "processHook"), "Repeated enrollment must not register another hook");
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var exit = caller.submit(hook::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(completion.isDone());
|
||||
assertFalse(exit.isDone(), "A daemon reservation must still be represented at process exit");
|
||||
lease.close();
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(completion.isDone());
|
||||
ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")).retire();
|
||||
} finally {
|
||||
lease.close();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void repeatedDisableCannotRenewTheOriginalSharedDeadline() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
var lease = fence.acquire();
|
||||
fence.retainForProcessExit();
|
||||
long original = System.nanoTime() - TimeUnit.SECONDS.toNanos(1);
|
||||
fence.beginShutdown(original);
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
var started = (CompletableFuture<?>) field(fence, "processShutdownStarted");
|
||||
assertEquals(original, started.get());
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
var exit = caller.submit(hook::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(completion.nanos > 0 && completion.nanos <= TimeUnit.SECONDS.toNanos(14),
|
||||
"Process exit must use the remaining disable budget, not another fifteen seconds");
|
||||
lease.close();
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
lease.close();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void expiredProcessBudgetDoesNotCertifyFailedPublicationOrLiftGenerationAdmission() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var completion = observe(fence);
|
||||
fence.retainForProcessExit();
|
||||
fence.retainUntilSuccessfulPublication(CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O")));
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
fence.beginShutdown(System.nanoTime() - TimeUnit.SECONDS.toNanos(16));
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
caller.submit(hook::run).get(5, TimeUnit.SECONDS);
|
||||
assertFalse(completion.isDone(), "Deadline expiry cannot report persistence success");
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
} finally {
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interruptedProcessWaitPreservesInterruptionWithoutAcknowledgingTheWriter() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
var lease = fence.acquire();
|
||||
fence.retainForProcessExit();
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
fence.retire();
|
||||
var interrupted = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
var observer = new Thread(() -> {
|
||||
hook.run();
|
||||
interrupted.set(Thread.currentThread().isInterrupted());
|
||||
});
|
||||
try {
|
||||
observer.start();
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
observer.interrupt();
|
||||
observer.join(5000);
|
||||
assertFalse(observer.isAlive());
|
||||
assertTrue(interrupted.get());
|
||||
assertFalse(completion.isDone());
|
||||
} finally {
|
||||
lease.close();
|
||||
observer.interrupt();
|
||||
observer.join(5000);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retainedExecutorTerminationIsIncludedWithoutAPreStorageDependencyCycle() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
fence.retainForProcessExit();
|
||||
var writer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
writer.execute(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
writer.shutdown();
|
||||
var retirement = fence.retainUntilTermination(new java.util.concurrent.ScheduledExecutorService[] { writer });
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var exit = caller.submit(((Thread) field(fence, "processHook"))::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(completion.isDone());
|
||||
release.countDown();
|
||||
retirement.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
release.countDown();
|
||||
writer.shutdownNow();
|
||||
caller.shutdownNow();
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedSignPublicationRemainsInTheProcessDrainAfterTheNativeSdkOwnerReleases() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var nativeOwner = com.bencodez.advancedcore.lifecycle.ServerDataOwnership.acquire(target);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var completion = observe(fence);
|
||||
fence.retainForProcessExit();
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var workers = new ArrayList<Runnable>();
|
||||
var signs = new Signs(plugin) {
|
||||
@Override protected void startShutdownWorker(Runnable worker) { workers.add(worker); }
|
||||
};
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
var sign = mock(SignHandler.class, CALLS_REAL_METHODS);
|
||||
for (var entry : java.util.Map.<String, Object>of("sign", "0", "position", 1,
|
||||
"data", "SyntheticSign").entrySet()) {
|
||||
var f = SignHandler.class.getDeclaredField(entry.getKey());
|
||||
f.setAccessible(true);
|
||||
f.set(sign, entry.getValue());
|
||||
}
|
||||
sign.setLocation(new Location(world, 1, 2, 3));
|
||||
signs.getSigns().add(sign);
|
||||
var published = signs.storeSignsForShutdown();
|
||||
long started = System.nanoTime();
|
||||
nativeOwner.beginShutdown(started);
|
||||
// Exercise the actual SDK ownership release once its own aggregate has settled.
|
||||
// This fixture does not simulate unrelated native cleanup actions.
|
||||
nativeOwner.releaseAfter(CompletableFuture.completedFuture(null));
|
||||
assertTrue(nativeOwner.isReleased());
|
||||
fence.beginShutdown(started);
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var exit = caller.submit(((Thread) field(fence, "processHook"))::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(completion.isDone(), "The native release cannot discard the consumer reservation");
|
||||
assertFalse(published.toCompletableFuture().isDone());
|
||||
workers.getFirst().run();
|
||||
published.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("SyntheticSign", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.Signs.0.Data"));
|
||||
} finally {
|
||||
if (!published.toCompletableFuture().isDone()) workers.getFirst().run();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class SignShutdownPublicationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private static final class Admission extends Signs {
|
||||
final List<Runnable> workers = new ArrayList<>();
|
||||
boolean reject;
|
||||
Admission(VotingPluginMain plugin) { super(plugin); }
|
||||
@Override protected void startShutdownWorker(Runnable work) {
|
||||
if (reject) throw new RejectedExecutionException("Synthetic rejection");
|
||||
workers.add(work);
|
||||
}
|
||||
}
|
||||
|
||||
private VotingPluginMain plugin(ServerDataPublicationFence fence) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private SignHandler sign(VotingPluginMain plugin, String id, Location location, Location skull) throws Exception {
|
||||
var sign = mock(SignHandler.class, CALLS_REAL_METHODS);
|
||||
for (var entry : java.util.Map.<String, Object>of("plugin", plugin, "sign", id,
|
||||
"data", "SyntheticSign" + id, "position", 2).entrySet()) {
|
||||
var field = SignHandler.class.getDeclaredField(entry.getKey());
|
||||
field.setAccessible(true);
|
||||
field.set(sign, entry.getValue());
|
||||
}
|
||||
sign.setLocation(location);
|
||||
sign.setSkullLocation(skull);
|
||||
return sign;
|
||||
}
|
||||
|
||||
@Test void rejectedAdmissionRetainsTheOriginalBatchAndPublishesItOnceOnRetry() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("VotingPlugin.Unrelated", "preserved");
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var owner = Thread.currentThread();
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenAnswer(call -> {
|
||||
assertSame(owner, Thread.currentThread(), "Platform state must be captured before handoff");
|
||||
return "SyntheticWorld";
|
||||
});
|
||||
var location = new Location(world, -1.5, 64, 2.5);
|
||||
var first = sign(plugin, "0", location, new Location(world, 3, 65, 4));
|
||||
signs.getSigns().add(first);
|
||||
signs.getSigns().add(sign(plugin, "1", new Location(world, 5, 66, 6), null));
|
||||
signs.reject = true;
|
||||
assertThrows(RejectedExecutionException.class, signs::storeSignsForShutdown);
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
// Retry must use the accepted detached snapshot, not newer platform state.
|
||||
location.setX(90);
|
||||
first.setData("LaterUnacceptedState");
|
||||
signs.getSigns().clear();
|
||||
signs.reject = false;
|
||||
var completion = signs.storeSignsForShutdown();
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(1, signs.workers.size());
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
worker.submit(signs.workers.getFirst()).get(5, TimeUnit.SECONDS);
|
||||
completion.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals(-1, disk.getInt("VotingPlugin.Signs.0.X"), "Preserve legacy coordinate truncation");
|
||||
assertEquals(3, disk.getInt("VotingPlugin.Signs.0.Skull.X"));
|
||||
assertEquals("SyntheticSign0", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
assertEquals(2, disk.getInt("VotingPlugin.Signs.1.Position"));
|
||||
assertEquals("preserved", disk.getString("VotingPlugin.Unrelated"));
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(1, signs.workers.size(), "Successful publication is idempotent");
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancellationAndForgedObservationCannotReleaseTheRealPublicationOwner() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var data = mock(ServerData.class);
|
||||
var durable = new CompletableFuture<Void>();
|
||||
when(data.publishShutdownSigns(anyList())).thenReturn(durable);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var pending = signs.storeSignsForShutdown();
|
||||
pending.toCompletableFuture().cancel(true);
|
||||
pending.toCompletableFuture().complete(null);
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(1, signs.workers.size(), "Accepted but unexecuted work retains one owner");
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
signs.workers.getFirst().run();
|
||||
assertFalse(pending.toCompletableFuture().isDone(), "Submission is not durability");
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
durable.complete(null);
|
||||
pending.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
|
||||
@Test void failedPublicationRetainsPayloadAndRequiresAnExplicitSuccessfulRetry() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var data = mock(ServerData.class);
|
||||
var attempts = new ArrayList<List<ServerData.SignSnapshot>>();
|
||||
when(data.publishShutdownSigns(anyList())).thenAnswer(call -> {
|
||||
attempts.add(call.getArgument(0));
|
||||
return attempts.size() == 1 ? CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O"))
|
||||
: CompletableFuture.completedFuture(null);
|
||||
});
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var first = signs.storeSignsForShutdown();
|
||||
signs.workers.getFirst().run();
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join());
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
signs.getSigns().clear();
|
||||
var retry = signs.storeSignsForShutdown();
|
||||
signs.workers.get(1).run();
|
||||
retry.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertSame(attempts.get(0), attempts.get(1), "Failure must not discard or recapture the accepted batch");
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
|
||||
@Test void emptyShutdownDoesNotAdmitAWorkerOrTouchServerData() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var signs = new Admission(plugin);
|
||||
assertDoesNotThrow(() -> signs.storeSignsForShutdown().toCompletableFuture().join());
|
||||
assertEquals(0, signs.workers.size());
|
||||
verifyNoInteractions(plugin);
|
||||
}
|
||||
|
||||
@Test void actualSdkFailedThenSuccessfulRetryReleasesTheOriginalPublicationOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var attempts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
if (attempts.incrementAndGet() == 1)
|
||||
throw new java.io.IOException("Synthetic first publication failure");
|
||||
super.moveAtomically(source, destination);
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("VotingPlugin.Unrelated", "preserved");
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var failed = signs.storeSignsForShutdown();
|
||||
signs.workers.getFirst().run();
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> failed.toCompletableFuture().join());
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
facade.mutateFinalData(candidate -> candidate.set("Independent.Value", "confirmed"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target),
|
||||
"A different successful operation cannot acknowledge the failed sign batch");
|
||||
signs.getSigns().clear();
|
||||
var retry = signs.storeSignsForShutdown();
|
||||
signs.workers.get(1).run();
|
||||
retry.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("SyntheticSign0", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
assertEquals("preserved", disk.getString("VotingPlugin.Unrelated"));
|
||||
assertEquals("confirmed", disk.getString("Independent.Value"));
|
||||
assertEquals(3, attempts.get());
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(2, signs.workers.size(), "Successful retry is not republished");
|
||||
assertDoesNotThrow(() -> ServerDataPublicationFence.beginGeneration(target).retire(),
|
||||
"Confirmed retry must release both facade and sign-batch ownership");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualPendingRetryCoalescesWithoutReplacingItsPayloadOrTrustingObservationCancellation() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var attempts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var retryEntered = new java.util.concurrent.CountDownLatch(1);
|
||||
var releaseRetry = new java.util.concurrent.CountDownLatch(1);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
if (attempts.incrementAndGet() == 1)
|
||||
throw new java.io.IOException("Synthetic first publication failure");
|
||||
retryEntered.countDown();
|
||||
try {
|
||||
if (!releaseRetry.await(5, TimeUnit.SECONDS))
|
||||
throw new java.io.IOException("Synthetic retry barrier expired");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(interrupted);
|
||||
}
|
||||
super.moveAtomically(source, destination);
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
var snapshot = List.of(ServerData.captureSignSnapshot("0", new Location(world, 1, 2, 3),
|
||||
null, "OriginalAcceptedSign", 2));
|
||||
var different = List.of(ServerData.captureSignSnapshot("0", new Location(world, 9, 8, 7),
|
||||
null, "DifferentUnacceptedSign", 4));
|
||||
assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> data.publishShutdownSigns(snapshot).toCompletableFuture().join());
|
||||
fence.retire();
|
||||
var workers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var retry = workers.submit(() -> data.publishShutdownSigns(snapshot));
|
||||
assertTrue(retryEntered.await(5, TimeUnit.SECONDS));
|
||||
var coalesced = workers.submit(() -> data.publishShutdownSigns(snapshot)).get(2, TimeUnit.SECONDS);
|
||||
coalesced.toCompletableFuture().cancel(true);
|
||||
coalesced.toCompletableFuture().complete(null);
|
||||
assertFalse(coalesced.toCompletableFuture().isDone(), "The real publication still owns its lease");
|
||||
var rejected = workers.submit(() -> data.publishShutdownSigns(different)).get(2, TimeUnit.SECONDS);
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> rejected.toCompletableFuture().join());
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
releaseRetry.countDown();
|
||||
retry.get(5, TimeUnit.SECONDS).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
coalesced.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
data.publishShutdownSigns(snapshot).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(2, attempts.get(), "One failed attempt and one shared durable retry");
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("OriginalAcceptedSign", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
assertEquals(1, disk.getInt("VotingPlugin.Signs.0.X"));
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
} finally {
|
||||
releaseRetry.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void confirmedPublicationRetriesOnlyFailedDisplayObservationWithoutRepeatingTheWrite() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
writes.incrementAndGet();
|
||||
super.moveAtomically(source, destination);
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = spy(new ServerData(plugin));
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
doThrow(new IllegalStateException("Synthetic observation failure")).doCallRealMethod()
|
||||
.when(data).refreshVotePartyDisplay();
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
var snapshot = List.of(ServerData.captureSignSnapshot("0", new Location(world, 1, 2, 3),
|
||||
null, "OriginalAcceptedSign", 2));
|
||||
assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> data.publishShutdownSigns(snapshot).toCompletableFuture().join());
|
||||
assertEquals("OriginalAcceptedSign", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.Signs.0.Data"), "The write committed before observation failed");
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
data.publishShutdownSigns(snapshot).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, writes.get(), "Retry must not replay an already confirmed mutation");
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualSdkIoFailureKeepsTheGenerationBlocked() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
throw new java.io.IOException("Synthetic failed atomic publication");
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var result = signs.storeSignsForShutdown();
|
||||
signs.workers.getFirst().run();
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join());
|
||||
assertFalse(java.nio.file.Files.exists(target));
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target),
|
||||
"Neither the SDK guard nor sign handoff may call failed I/O durable");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void runtimeSignSavingRemainsSynchronous() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var data = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var location = new Location(mock(World.class), 1, 2, 3);
|
||||
signs.getSigns().add(sign(plugin, "0", location, null));
|
||||
var caller = Thread.currentThread();
|
||||
doAnswer(call -> { assertSame(caller, Thread.currentThread()); return null; })
|
||||
.when(data).setSign("0", location, null, "SyntheticSign0", 2);
|
||||
signs.storeSigns();
|
||||
verify(data).setSign("0", location, null, "SyntheticSign0", 2);
|
||||
assertEquals(0, signs.workers.size());
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ public class VotePartyTest {
|
||||
public void addTotal_IncrementsTotalVotes() {
|
||||
when(plugin.getServerData().getData().getInt("VoteParty.Total")).thenReturn(5);
|
||||
voteParty.addTotal(user);
|
||||
verify(plugin.getServerData().getData()).set("VoteParty.Total", 6);
|
||||
verify(plugin.getServerData()).setInt("VoteParty.Total", 6);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,6 +114,8 @@ public class VotePartyTest {
|
||||
|
||||
@Test
|
||||
public void commandVoteParty_VotePartyEnabled_SendsMessage() {
|
||||
when(plugin.getServerData().getVotePartyDisplay()).thenReturn(
|
||||
new com.bencodez.votingplugin.data.ServerData.VotePartyDisplay(0, 0));
|
||||
CommandSender sender = Mockito.mock(CommandSender.class);
|
||||
when(plugin.getSpecialRewardsConfig().isVotePartyEnabled()).thenReturn(true);
|
||||
when(plugin.getConfigFile().getFormatCommandsVoteParty()).thenReturn(new ArrayList<>());
|
||||
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence;
|
||||
import com.bencodez.votingplugin.webhook.VotingPluginWebhookManager;
|
||||
|
||||
class VotingPluginMainShutdownTest {
|
||||
@Test
|
||||
void lateVoteTimerIsOptionalDuringPartialStartup() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
assertNull(field(plugin, "voteTimer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voteTimerCleanupIsRepeatable() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
setField(plugin, "voteTimer", timer);
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
assertDoesNotThrow(() -> invokeShutdownVoteTimer(plugin));
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, field(plugin, "voteTimer"));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(timer.isShutdown());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicUnloadSkipsLateOptionalResourcesAndIsIdempotent() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertNull(field(plugin, "signs"));
|
||||
assertNull(field(plugin, "timeQueueHandler"));
|
||||
verify((java.util.concurrent.ExecutorService) field(plugin, "backendHostedControlLifecycle")).shutdownNow();
|
||||
verify((java.util.concurrent.ExecutorService) field(plugin, "backendControlConnectorLifecycle")).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stoppedTimerRemainsVisibleUntilOverflowAndProducerHandlersClose() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
var queue = mock(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
setField(plugin, "votifierVoteOverflowQueue", queue);
|
||||
var duringClose = new java.util.concurrent.atomic.AtomicReference<ScheduledExecutorService>();
|
||||
var rejected = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var ran = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
duringClose.set(plugin.getVoteTimer());
|
||||
rejected.set(!com.bencodez.votingplugin.util.VoteTaskAdmission.trySubmit(plugin.getVoteTimer(),
|
||||
() -> ran.set(true)));
|
||||
return null;
|
||||
}).when(queue).close();
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
plugin.onUnLoad();
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, duringClose.get());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(rejected.get());
|
||||
org.junit.jupiter.api.Assertions.assertFalse(ran.get());
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
assertNull(plugin.getVoteTimer());
|
||||
} finally { timer.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void integratedUnloadClosesTheCurrentStreakMenuOnce() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var menu = mock(com.bencodez.votingplugin.streaks.VoteStreaksMenu.class);
|
||||
var signs = shutdownSignsMock();
|
||||
setField(plugin, "voteStreaksMenu", menu);
|
||||
setField(plugin, "signs", signs);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(menu).close();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedStreakMenuCloseDoesNotSkipOtherCleanupAndRetriesOnRepeat() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var menu = mock(com.bencodez.votingplugin.streaks.VoteStreaksMenu.class);
|
||||
var signs = shutdownSignsMock();
|
||||
setField(plugin, "voteStreaksMenu", menu);
|
||||
setField(plugin, "signs", signs);
|
||||
doThrow(new IllegalStateException("synthetic close failure")).doNothing().when(menu).close();
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(signs).storeSignsForShutdown();
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(menu, org.mockito.Mockito.times(2)).close();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedDiscordCloseDoesNotSkipDisplayRetirementAndRetriesOnlyTheFailedStep() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var generation = new java.util.concurrent.atomic.AtomicLong();
|
||||
setField(plugin, "topVoterDisplayGeneration", generation);
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class);
|
||||
setField(plugin, "discordHandler", discord);
|
||||
doThrow(new IllegalStateException("synthetic Discord close failure")).doNothing().when(discord).close();
|
||||
var pending = new java.util.concurrent.CompletableFuture<com.bencodez.advancedcore.api.user.presence.PlayerPresence>();
|
||||
var filter = new com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter(row -> pending);
|
||||
setField(plugin, "topVoterDisplayFilter", filter);
|
||||
var rankings = new java.util.LinkedHashMap<com.bencodez.votingplugin.topvoter.TopVoterPlayer, Integer>();
|
||||
rankings.put(new com.bencodez.votingplugin.topvoter.TopVoterPlayer(java.util.UUID.randomUUID(), "Example", 1L), 10);
|
||||
var display = filter.projectAsync(rankings, true).toCompletableFuture();
|
||||
org.junit.jupiter.api.Assertions.assertFalse(display.isDone());
|
||||
var signs = shutdownSignsMock();
|
||||
setField(plugin, "signs", signs);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(display.isDone(), "A failed independent close must not strand pending display evidence");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(rankings, display.getNow(null));
|
||||
verify(signs).storeSignsForShutdown();
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(discord, org.mockito.Mockito.times(2)).close();
|
||||
verify(discord).retireLeaderboardPublications();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1L, generation.get());
|
||||
pending.complete(com.bencodez.advancedcore.api.user.presence.PlayerPresence.NEVER_JOINED);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(rankings, display.getNow(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicUnloadCleansInitializedQueueAndSignResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
Signs signs = shutdownSignsMock();
|
||||
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
|
||||
setField(plugin, "signs", signs);
|
||||
setField(plugin, "timeQueueHandler", timeQueue);
|
||||
|
||||
plugin.onUnLoad();
|
||||
|
||||
verify(signs).storeSignsForShutdown();
|
||||
verify(timeQueue).saveForShutdown();
|
||||
plugin.onUnLoad();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedCleanupDoesNotSkipLaterResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
Signs signs = shutdownSignsMock();
|
||||
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
|
||||
VotingPluginWebhookManager webhook = mock(VotingPluginWebhookManager.class);
|
||||
doThrow(new IllegalStateException("webhook close failed")).when(webhook).shutdown();
|
||||
doThrow(new IllegalStateException("queue save failed")).when(timeQueue).saveForShutdown();
|
||||
setField(plugin, "webhookManager", webhook);
|
||||
setField(plugin, "signs", signs);
|
||||
setField(plugin, "timeQueueHandler", timeQueue);
|
||||
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
|
||||
verify(webhook).shutdown();
|
||||
verify(timeQueue).saveForShutdown();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedUnloadRetriesFailedBackendAndWebhookWithoutRepeatingSuccessfulResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var backend = mock(com.bencodez.votingplugin.backendproxy.BackendProxyHandler.class);
|
||||
var webhook = mock(VotingPluginWebhookManager.class);
|
||||
var signs = shutdownSignsMock();
|
||||
org.mockito.Mockito.doReturn(backend).when(plugin).getBackendProxyHandler();
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("synthetic backend close failure")).doNothing()
|
||||
.when(backend).closeForShutdown();
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("synthetic webhook close failure")).doNothing()
|
||||
.when(webhook).shutdown();
|
||||
setField(plugin, "webhookManager", webhook); setField(plugin, "signs", signs);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(backend, org.mockito.Mockito.times(2)).closeForShutdown();
|
||||
verify(webhook, org.mockito.Mockito.times(2)).shutdown();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedTimerShutdownRetainsItsReferenceUntilARepeatCanStopIt() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var timer = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("synthetic forced stop failure"))
|
||||
.doThrow(new IllegalStateException("synthetic repeated forced stop failure"))
|
||||
.doAnswer(call -> java.util.List.of())
|
||||
.when(timer).shutdownNow();
|
||||
setField(plugin, "voteTimer", timer);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, plugin.getVoteTimer());
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertNull(plugin.getVoteTimer());
|
||||
verify(timer, org.mockito.Mockito.times(2)).shutdown();
|
||||
verify(timer, org.mockito.Mockito.times(3)).shutdownNow();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentUnloadDoesNotRepeatAnActiveResourceClose() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var backend = mock(com.bencodez.votingplugin.backendproxy.BackendProxyHandler.class);
|
||||
org.mockito.Mockito.doReturn(backend).when(plugin).getBackendProxyHandler();
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
entered.countDown();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(release.await(10, java.util.concurrent.TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(backend).closeForShutdown();
|
||||
try {
|
||||
var first = worker.submit(plugin::onUnLoad);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(backend).closeForShutdown();
|
||||
release.countDown(); first.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(backend).closeForShutdown();
|
||||
} finally {
|
||||
release.countDown(); worker.shutdownNow();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedUnloadRetriesOnlyFailedTimedVotePersistence() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
|
||||
Signs signs = shutdownSignsMock();
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("disk unavailable")).doNothing()
|
||||
.when(timeQueue).saveForShutdown();
|
||||
setField(plugin, "timeQueueHandler", timeQueue);
|
||||
setField(plugin, "signs", signs);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(timeQueue, org.mockito.Mockito.times(2)).saveForShutdown();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void gracefulTimerShutdownDoesNotForceStop() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
|
||||
verify(timer).shutdown();
|
||||
verify(timer, never()).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void timerTimeoutForcesStop() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(false);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
|
||||
verify(timer).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void interruptedTimerShutdownRestoresInterruptAndForcesStop() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
doThrow(new InterruptedException()).when(timer).awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
|
||||
try {
|
||||
org.junit.jupiter.api.Assertions.assertTrue(Thread.currentThread().isInterrupted());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
verify(timer).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedForcedTimerStopStillClosesInitializedQueuesAndListenersOnce() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
doThrow(new IllegalStateException("controlled forced-stop failure")).when(timer).shutdownNow();
|
||||
setField(plugin, "voteTimer", timer);
|
||||
var queue = mock(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class);
|
||||
var cooldown = mock(com.bencodez.votingplugin.cooldown.CoolDownCheck.class);
|
||||
var reminders = mock(com.bencodez.votingplugin.votereminding.VoteRemindersManager.class);
|
||||
setField(plugin, "votifierVoteOverflowQueue", queue);
|
||||
setField(plugin, "coolDownCheck", cooldown);
|
||||
setField(plugin, "voteRemindersManager", reminders);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(queue).close();
|
||||
verify(cooldown).shutdown();
|
||||
verify(reminders).shutdown();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, field(plugin, "voteTimer"));
|
||||
verify(timer, org.mockito.Mockito.times(4)).shutdownNow();
|
||||
assertNull(field(plugin, "votifierVoteOverflowQueue"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedMysqlStartupRetainsItsCauseAndFullDisableCleansEarlyResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class,
|
||||
org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
org.mockito.Mockito.doReturn(options).when(plugin).getOptions();
|
||||
org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("startup-fixture")).when(plugin).getLogger();
|
||||
org.mockito.Mockito.when(options.getDebug().isDebug()).thenReturn(true);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var logger = mock(java.util.logging.Logger.class);
|
||||
org.mockito.Mockito.doReturn(logger).when(plugin).getLogger();
|
||||
ScheduledExecutorService coreTimer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
setField(plugin, "timer", coreTimer);
|
||||
setField(plugin, "loadUserData", true);
|
||||
var configuration = new org.bukkit.configuration.file.YamlConfiguration();
|
||||
configuration.set("Host", "127.0.0.1"); configuration.set("Port", 3306);
|
||||
configuration.set("Username", "fixture"); configuration.set("Password", "fixture");
|
||||
configuration.set("Database", "fixture"); configuration.set("MaxConnections", 1);
|
||||
configuration.set("ConnectionTimeout", 1000); configuration.set("MinimumIdle", 0);
|
||||
configuration.set("Driver", RejectingDriver.class.getName());
|
||||
configuration.set("Debug", true);
|
||||
try {
|
||||
IllegalStateException failure = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class,
|
||||
() -> new com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL(plugin, "Users", configuration));
|
||||
Throwable original = failure;
|
||||
while (original.getCause() != null) original = original.getCause();
|
||||
org.junit.jupiter.api.Assertions.assertInstanceOf(java.sql.SQLException.class, original);
|
||||
org.junit.jupiter.api.Assertions.assertEquals("controlled authentication rejection", original.getMessage());
|
||||
assertNull(plugin.getMysql(), "rejected constructor must not publish storage");
|
||||
assertDoesNotThrow(plugin::onDisable);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(coreTimer.isTerminated());
|
||||
assertNull(field(plugin, "voteTimer"));
|
||||
assertNull(field(plugin, "signs"));
|
||||
assertDoesNotThrow(plugin::onDisable);
|
||||
org.junit.jupiter.api.Assertions.assertSame(original, rootCause(failure));
|
||||
verify(logger, never()).warning(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(logger, never()).log(org.mockito.ArgumentMatchers.eq(java.util.logging.Level.WARNING),
|
||||
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(Throwable.class));
|
||||
} finally { coreTimer.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void signPersistenceDoesNotReblockUnloadAfterATimedVoteCheckpointTimesOut(
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) throws Exception {
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var firstMove = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
var producers = new java.util.concurrent.ScheduledThreadPoolExecutor(1);
|
||||
producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
try (var schedulers = org.mockito.Mockito.mockConstruction(
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
org.mockito.Mockito.doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(
|
||||
directory.resolve("ServerData.yml"));
|
||||
setField(plugin, "serverDataPublicationFence", fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(java.nio.file.Path source, java.nio.file.Path target)
|
||||
throws java.io.IOException {
|
||||
if (firstMove.compareAndSet(true, false)) {
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!release.await(10, java.util.concurrent.TimeUnit.SECONDS))
|
||||
throw new java.io.IOException("Synthetic stalled writer was not released");
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(failure);
|
||||
}
|
||||
}
|
||||
super.moveAtomically(source, target);
|
||||
}
|
||||
};
|
||||
backing.setData(new org.bukkit.configuration.file.YamlConfiguration());
|
||||
var facade = new com.bencodez.votingplugin.data.GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
org.mockito.Mockito.doReturn(facade).when(plugin).getServerDataFile();
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
org.mockito.Mockito.doReturn(data).when(plugin).getServerData();
|
||||
org.mockito.Mockito.doReturn(producers).when(plugin).getVoteTimer();
|
||||
var queue = new TimeQueueHandler(plugin);
|
||||
queue.addVote("SyntheticVoter", "SyntheticSite");
|
||||
producers.shutdown();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(producers.awaitTermination(2,
|
||||
java.util.concurrent.TimeUnit.SECONDS));
|
||||
setField(plugin, "timeQueueHandler", queue);
|
||||
var signs = new Signs(plugin);
|
||||
var sign = mock(com.bencodez.votingplugin.signs.SignHandler.class, CALLS_REAL_METHODS);
|
||||
for (var entry : java.util.Map.<String, Object>of("plugin", plugin, "sign", "0",
|
||||
"data", "SyntheticSign", "position", 1).entrySet()) {
|
||||
var f = com.bencodez.votingplugin.signs.SignHandler.class.getDeclaredField(entry.getKey());
|
||||
f.setAccessible(true);
|
||||
f.set(sign, entry.getValue());
|
||||
}
|
||||
var world = mock(org.bukkit.World.class);
|
||||
org.mockito.Mockito.when(world.getName()).thenReturn("SyntheticWorld");
|
||||
sign.setLocation(new org.bukkit.Location(world, -1.5, 64, 2.5));
|
||||
signs.getSigns().add(sign);
|
||||
setField(plugin, "signs", signs);
|
||||
try {
|
||||
var unload = caller.submit(() -> {
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
plugin.onUnLoad();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
});
|
||||
org.junit.jupiter.api.Assertions.assertTrue(entered.await(5,
|
||||
java.util.concurrent.TimeUnit.SECONDS), "Actual SDK file writer did not reach disk boundary");
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||
() -> unload.get(3, java.util.concurrent.TimeUnit.SECONDS),
|
||||
"Sign persistence must not reacquire the stalled YAML monitor on the unload caller");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1, release.getCount(),
|
||||
"Unload must complete before the stalled writer is released");
|
||||
} finally {
|
||||
release.countDown();
|
||||
var pendingSigns = (java.util.concurrent.CompletionStage<?>) field(plugin, "signShutdownPublication");
|
||||
if (pendingSigns != null) pendingSigns.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
queue.saveForShutdown();
|
||||
var disk = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(
|
||||
directory.resolve("ServerData.yml").toFile());
|
||||
org.junit.jupiter.api.Assertions.assertEquals("SyntheticSign", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1, data.getTimedVoteCacheKeys().size(),
|
||||
"Final sign publication must preserve the timed-vote checkpoint");
|
||||
caller.shutdown();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(caller.awaitTermination(5,
|
||||
java.util.concurrent.TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
} finally {
|
||||
release.countDown();
|
||||
caller.shutdownNow();
|
||||
producers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedUnloadRetriesAFailedSignPublicationWithoutRepeatingListenerCleanup() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var signs = mock(Signs.class);
|
||||
var pending = new java.util.concurrent.CompletableFuture<Void>();
|
||||
org.mockito.Mockito.when(signs.storeSignsForShutdown()).thenReturn(
|
||||
java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O")),
|
||||
pending.minimalCompletionStage());
|
||||
setField(plugin, "signs", signs);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
plugin.onUnLoad();
|
||||
plugin.onUnLoad();
|
||||
plugin.onUnLoad();
|
||||
verify(signs, org.mockito.Mockito.times(2)).storeSignsForShutdown();
|
||||
verify(signs, never()).storeSigns();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
pending.complete(null);
|
||||
plugin.onUnLoad();
|
||||
verify(signs, org.mockito.Mockito.times(2)).storeSignsForShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static Signs shutdownSignsMock() {
|
||||
var signs = mock(Signs.class);
|
||||
org.mockito.Mockito.when(signs.storeSignsForShutdown())
|
||||
.thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null));
|
||||
return signs;
|
||||
}
|
||||
|
||||
private static Throwable rootCause(Throwable failure) {
|
||||
while (failure.getCause() != null) failure = failure.getCause();
|
||||
return failure;
|
||||
}
|
||||
|
||||
public static final class RejectingDriver implements java.sql.Driver {
|
||||
public java.sql.Connection connect(String url, java.util.Properties properties) throws java.sql.SQLException {
|
||||
throw new java.sql.SQLException("controlled authentication rejection", "28000", 1045);
|
||||
}
|
||||
public boolean acceptsURL(String url) { return url != null && url.startsWith("jdbc:"); }
|
||||
public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties properties) {
|
||||
return new java.sql.DriverPropertyInfo[0];
|
||||
}
|
||||
public int getMajorVersion() { return 1; }
|
||||
public int getMinorVersion() { return 0; }
|
||||
public boolean jdbcCompliant() { return false; }
|
||||
public java.util.logging.Logger getParentLogger() { return java.util.logging.Logger.getGlobal(); }
|
||||
}
|
||||
|
||||
private static void initializeEarlyLifecycle(VotingPluginMain plugin) throws Exception {
|
||||
// The real plugin initializes this display retirement counter before startup.
|
||||
setField(plugin, "topVoterDisplayGeneration", new java.util.concurrent.atomic.AtomicLong());
|
||||
// Mockito bypasses the SDK constructor that creates this lock.
|
||||
setField(plugin, "lifecycleLock", new Object());
|
||||
var hosted = mock(java.util.concurrent.ExecutorService.class);
|
||||
var connector = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.when(hosted.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
|
||||
org.mockito.Mockito.when(connector.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
|
||||
setField(plugin, "pendingNativeUserStorageCloseLock", new Object());
|
||||
setField(plugin, "pendingNativeUserStorageCloses", new java.util.ArrayList<>());
|
||||
setField(plugin, "backendHostedControlLifecycle", hosted);
|
||||
setField(plugin, "backendControlConnectorLifecycle", connector);
|
||||
setField(plugin, "backendHostedControlManagers", java.util.concurrent.ConcurrentHashMap.newKeySet());
|
||||
org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("shutdown-fixture")).when(plugin).getLogger();
|
||||
}
|
||||
|
||||
private static void invokeShutdownVoteTimer(VotingPluginMain plugin) throws Exception {
|
||||
Method method = VotingPluginMain.class.getDeclaredMethod("shutdownVoteTimer");
|
||||
method.setAccessible(true);
|
||||
method.invoke(plugin);
|
||||
}
|
||||
|
||||
private static Object field(VotingPluginMain plugin, String name) throws Exception {
|
||||
Field field = VotingPluginMain.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field.get(plugin);
|
||||
}
|
||||
|
||||
private static void setField(VotingPluginMain plugin, String name, Object value) throws Exception {
|
||||
Field field = findField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, value);
|
||||
}
|
||||
|
||||
private static Field findField(String name) throws Exception {
|
||||
Class<?> type = VotingPluginMain.class;
|
||||
while (type != null) {
|
||||
try { return type.getDeclaredField(name); }
|
||||
catch (NoSuchFieldException ignored) { type = type.getSuperclass(); }
|
||||
}
|
||||
throw new NoSuchFieldException(name);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -95,8 +95,8 @@ public class VotingPluginBackgroundTaskTest {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation implementation =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler implementation =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
|
||||
+46
-2
@@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.simpleapi.folialib.FoliaLib;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
import com.bencodez.simpleapi.folialib.impl.PlatformScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.user.PointTransferResult;
|
||||
@@ -209,9 +209,53 @@ class CommandLoaderSchedulingTest {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(2, storageTasks.size());
|
||||
}
|
||||
|
||||
|
||||
@Test void clearingDiscordIdRunsOnTheWorkerAndReportsOnlyAfterConfirmation() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class);
|
||||
when(plugin.getDiscordHandler()).thenReturn(discord);
|
||||
when(discord.clearTopVoterMessageId(com.bencodez.votingplugin.topvoter.TopVoter.Monthly)).thenReturn(true);
|
||||
var worker = mock(java.util.concurrent.ScheduledExecutorService.class);
|
||||
when(plugin.getTimer()).thenReturn(worker);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
var queued = new AtomicReference<Runnable>(); var completion = new AtomicReference<Runnable>();
|
||||
org.mockito.Mockito.doAnswer(call -> { queued.set(call.getArgument(0)); return null; })
|
||||
.when(worker).execute(any(Runnable.class));
|
||||
org.mockito.Mockito.doAnswer(call -> { completion.set(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin),any(Runnable.class));
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
var method = CommandLoader.class.getDeclaredMethod("clearDiscordMessageId",CommandSender.class,
|
||||
com.bencodez.votingplugin.topvoter.TopVoter.class); method.setAccessible(true);
|
||||
method.invoke(new CommandLoader(plugin),sender,com.bencodez.votingplugin.topvoter.TopVoter.Monthly);
|
||||
org.mockito.Mockito.verifyNoInteractions(discord,sender);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(queued.get()); queued.get().run();
|
||||
verify(discord).clearTopVoterMessageId(com.bencodez.votingplugin.topvoter.TopVoter.Monthly);
|
||||
org.mockito.Mockito.verifyNoInteractions(sender);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(completion.get()); completion.get().run();
|
||||
verify(sender).sendMessage(org.mockito.ArgumentMatchers.contains("Cleared discord message ID"));
|
||||
}
|
||||
|
||||
@Test void rejectedDiscordIdClearDoesNotTouchStorage() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class); when(plugin.getDiscordHandler()).thenReturn(discord);
|
||||
var worker = mock(java.util.concurrent.ScheduledExecutorService.class); when(plugin.getTimer()).thenReturn(worker);
|
||||
doThrow(new java.util.concurrent.RejectedExecutionException("synthetic")).when(worker).execute(any(Runnable.class));
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
var completion = new AtomicReference<Runnable>();
|
||||
org.mockito.Mockito.doAnswer(call -> { completion.set(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin),any(Runnable.class));
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
var method = CommandLoader.class.getDeclaredMethod("clearDiscordMessageId",CommandSender.class,
|
||||
com.bencodez.votingplugin.topvoter.TopVoter.class); method.setAccessible(true);
|
||||
method.invoke(new CommandLoader(plugin),sender,com.bencodez.votingplugin.topvoter.TopVoter.Monthly);
|
||||
org.mockito.Mockito.verifyNoInteractions(discord);verify(plugin,never()).getServerData();
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(completion.get());completion.get().run();
|
||||
verify(sender).sendMessage(org.mockito.ArgumentMatchers.contains("no changes made"));
|
||||
}
|
||||
|
||||
private static void configureEntityScheduler(BukkitScheduler scheduler) {
|
||||
FoliaLib folia = mock(FoliaLib.class);
|
||||
ServerImplementation entityScheduler = mock(ServerImplementation.class);
|
||||
PlatformScheduler entityScheduler = mock(PlatformScheduler.class);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
when(folia.getImpl()).thenReturn(entityScheduler);
|
||||
when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class)))
|
||||
|
||||
+153
@@ -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<Runnable>();
|
||||
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.<String>getArgument(0).startsWith("SELECT `player_uuid`") ? lookup
|
||||
: call.<String>getArgument(0).startsWith("SELECT `Points` FROM") ? read : statement);
|
||||
var commands = new ArrayList<CommandHandler>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.advancedcore.api.rewards.RewardHandler;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardOptions;
|
||||
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
|
||||
import com.bencodez.advancedcore.api.user.UserData;
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
import com.bencodez.advancedcore.api.user.UserStorage;
|
||||
import com.bencodez.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.config.SpecialRewardsConfig;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import com.bencodez.votingplugin.votesites.VoteSiteManager;
|
||||
|
||||
class CoolDownBulkDispatchTest {
|
||||
private static final UUID UUID_VALUE = UUID.fromString("00000000-0000-0000-0000-000000000123");
|
||||
|
||||
@Test void singleSiteScanKeepsIdentityUntilDeferredRewardRuns() throws Exception { exercise(false, "OfflinePlayer", null, "OfflinePlayer"); }
|
||||
@Test void allSitesScanKeepsIdentityUntilDeferredRewardRuns() throws Exception { exercise(true, "OfflinePlayer", null, "OfflinePlayer"); }
|
||||
@Test void absentNameStillDispatchesTheEvent() throws Exception { exercise(false, null, null, ""); }
|
||||
@Test void explicitNameSurvivesCachedNameAndClear() throws Exception { exercise(true, "OldName", "OnlineName", "OnlineName"); }
|
||||
|
||||
@Test void uuidEntryPointsUseExistingUserAndOfflineFetchMode() {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
VoteSite site = mock(VoteSite.class);
|
||||
Config config = mock(Config.class);
|
||||
VoteSiteManager sites = mock(VoteSiteManager.class);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID_VALUE)).thenReturn(user);
|
||||
when(user.isOnline()).thenReturn(false);
|
||||
when(user.getCoolDownCheck()).thenReturn(true);
|
||||
when(user.canVoteAll()).thenReturn(true);
|
||||
when(user.getJavaUUID()).thenReturn(UUID_VALUE);
|
||||
when(user.getCoolDownCheckSiteList()).thenReturn(new HashMap<>());
|
||||
when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(site.getKey()).thenReturn("site");
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);
|
||||
when(sites.getVoteSitesEnabled()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
CoolDownCheck check = new CoolDownCheck(plugin);
|
||||
try {
|
||||
check.check(UUID_VALUE);
|
||||
check.checkPerSite(UUID_VALUE);
|
||||
verify(plugin.getVotingPluginUserManager(), times(2)).getVotingPluginUser(UUID_VALUE);
|
||||
verify(user, times(2)).userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
verify(plugin.getServer().getPluginManager()).callEvent(isA(PlayerVoteCoolDownEndEvent.class));
|
||||
verify(plugin.getServer().getPluginManager()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
verify(user, never()).setPlayerName(anyString());
|
||||
} finally {
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void exercise(boolean allSites, String cachedName, String explicitName, String expected) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
Config config = mock(Config.class);
|
||||
VoteSiteManager sites = mock(VoteSiteManager.class);
|
||||
VoteSite site = mock(VoteSite.class);
|
||||
RewardHandler rewards = mock(RewardHandler.class);
|
||||
SpecialRewardsConfig special = mock(SpecialRewardsConfig.class);
|
||||
Server server = mock(Server.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
YamlConfiguration data = new YamlConfiguration();
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);
|
||||
when(plugin.getRewardHandler()).thenReturn(rewards);
|
||||
when(plugin.getSpecialRewardsConfig()).thenReturn(special);
|
||||
when(special.getData()).thenReturn(data);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");
|
||||
when(site.getDisplayName()).thenReturn("Site");
|
||||
when(site.getVoteURL(false)).thenReturn("https://example.test");
|
||||
when(site.getSiteData()).thenReturn(data);
|
||||
when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
|
||||
VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin");
|
||||
pluginField.setAccessible(true);
|
||||
pluginField.set(user, plugin);
|
||||
Field dataField = AdvancedCoreUser.class.getDeclaredField("data");
|
||||
dataField.setAccessible(true);
|
||||
UserData userData = new UserData(user);
|
||||
dataField.set(user, userData);
|
||||
when(user.getUserData()).thenReturn(userData);
|
||||
when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getPlayerName();
|
||||
doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any(UserDataFetchMode.class));
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());
|
||||
doCallRealMethod().when(user).clearTempCache();
|
||||
if (explicitName != null) user.setPlayerName(explicitName);
|
||||
when(user.getCoolDownCheckSiteList()).thenReturn(new HashMap<>());
|
||||
when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(user.getCoolDownCheck()).thenReturn(allSites);
|
||||
when(user.canVoteAll()).thenReturn(true);
|
||||
when(user.getJavaUUID()).thenReturn(UUID_VALUE);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID_VALUE, false)).thenReturn(user);
|
||||
com.bencodez.advancedcore.api.user.UserManager storage = plugin.getUserManager();
|
||||
doAnswer(call -> {
|
||||
BiConsumer<UUID, ArrayList<Column>> each = call.getArgument(0);
|
||||
Consumer<Integer> done = call.getArgument(1);
|
||||
ArrayList<Column> columns = new ArrayList<>();
|
||||
if (cachedName != null) columns.add(new Column("PlayerName", new DataValueString(cachedName)));
|
||||
each.accept(UUID_VALUE, columns);
|
||||
done.accept(1);
|
||||
return null;
|
||||
}).when(storage).forEachUserKeys(any(), any());
|
||||
|
||||
CoolDownCheck check = new CoolDownCheck(plugin);
|
||||
AtomicReference<Runnable> deferred = new AtomicReference<>();
|
||||
AtomicReference<String> seen = new AtomicReference<>();
|
||||
doAnswer(call -> {
|
||||
AdvancedCoreUser recipient = call.getArgument(0);
|
||||
deferred.set(() -> seen.set(recipient.getPlayerName()));
|
||||
return null;
|
||||
}).when(rewards).giveReward(any(AdvancedCoreUser.class), any(), anyString(), any(RewardOptions.class));
|
||||
doAnswer(call -> {
|
||||
Event event = call.getArgument(0);
|
||||
if (event instanceof PlayerVoteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteCoolDownEndEvent) event);
|
||||
if (event instanceof PlayerVoteSiteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteSiteCoolDownEndEvent) event);
|
||||
return null;
|
||||
}).when(events).callEvent(any(Event.class));
|
||||
try {
|
||||
if (allSites) check.checkAllVoteSites(); else check.checkAllVoteSite(site);
|
||||
assertNull(userData.getTempCache());
|
||||
assertNotNull(deferred.get(), "cooldown listener should dispatch reward");
|
||||
deferred.get().run();
|
||||
assertEquals(expected, seen.get());
|
||||
assertEquals(UUID_VALUE, user.getJavaUUID());
|
||||
verify(user).setCoolDownCheckSite(any());
|
||||
if (allSites) verify(user).setCoolDownCheck(false);
|
||||
} finally {
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
|
||||
import com.bencodez.advancedcore.api.user.UserData;
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
import com.bencodez.advancedcore.api.user.UserStorage;
|
||||
import com.bencodez.simpleapi.sql.data.DataValue;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class CoolDownIdentitySnapshotTest {
|
||||
private VotingPluginUser user(String cachedName, String explicitName) throws Exception {
|
||||
VotingPluginUser user = mock(VotingPluginUser.class, CALLS_REAL_METHODS);
|
||||
AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class);
|
||||
when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin");
|
||||
pluginField.setAccessible(true);
|
||||
pluginField.set(user, plugin);
|
||||
Field dataField = AdvancedCoreUser.class.getDeclaredField("data");
|
||||
dataField.setAccessible(true);
|
||||
UserData data = new UserData(user);
|
||||
dataField.set(user, data);
|
||||
user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY);
|
||||
HashMap<String, DataValue> cache = new HashMap<>();
|
||||
if (cachedName != null) cache.put("PlayerName", new DataValueString(cachedName));
|
||||
data.setTempCache(cache);
|
||||
if (explicitName != null) user.setPlayerName(explicitName);
|
||||
return user;
|
||||
}
|
||||
|
||||
private void snapshot(VotingPluginUser user) throws Exception {
|
||||
Method capture = CoolDownCheck.class.getDeclaredMethod("capturePlayerName", VotingPluginUser.class);
|
||||
capture.setAccessible(true);
|
||||
capture.invoke(null, user);
|
||||
}
|
||||
|
||||
@Test void delayedRewardRetainsNameAfterTemporaryCacheIsCleared() throws Exception {
|
||||
VotingPluginUser user = user("OfflinePlayer", null);
|
||||
snapshot(user);
|
||||
var admitted = new CountDownLatch(1);
|
||||
var resume = new CountDownLatch(1);
|
||||
var worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var result = worker.submit(() -> {
|
||||
admitted.countDown();
|
||||
if (!resume.await(5, TimeUnit.SECONDS)) throw new AssertionError("worker not resumed");
|
||||
return user.getPlayerName();
|
||||
});
|
||||
assertTrue(admitted.await(5, TimeUnit.SECONDS));
|
||||
user.clearTempCache();
|
||||
resume.countDown();
|
||||
assertEquals("OfflinePlayer", result.get(5, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
resume.countDown();
|
||||
worker.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void missingNameRemainsMissingAndExplicitNameIsPreserved() throws Exception {
|
||||
VotingPluginUser missing = user(null, null);
|
||||
snapshot(missing);
|
||||
missing.clearTempCache();
|
||||
assertEquals("", missing.getPlayerName());
|
||||
VotingPluginUser online = user("OldName", "OnlineName");
|
||||
snapshot(online);
|
||||
online.clearTempCache();
|
||||
assertEquals("OnlineName", online.getPlayerName());
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.*;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import com.bencodez.advancedcore.api.user.*;
|
||||
import com.bencodez.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.*;
|
||||
class CoolDownMultipleSitesTest {
|
||||
@org.junit.jupiter.api.Test void dailyBatchPersistsEveryCompletedSite() throws Exception {
|
||||
var id=UUID.randomUUID(); var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS);
|
||||
var config=mock(Config.class);var sites=mock(VoteSiteManager.class);var site=mock(VoteSite.class);var second=mock(VoteSite.class);
|
||||
var server=mock(Server.class);var events=mock(PluginManager.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);when(plugin.getServer()).thenReturn(server);when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(second.getKey()).thenReturn("second");when(second.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site,second)));
|
||||
when(sites.hasVoteSite("site")).thenReturn(true);when(sites.getVoteSite("site",true)).thenReturn(site);
|
||||
var user=mock(VotingPluginUser.class); Field p=VotingPluginUser.class.getDeclaredField("plugin");p.setAccessible(true);p.set(user,plugin);
|
||||
Field ap=AdvancedCoreUser.class.getDeclaredField("plugin");ap.setAccessible(true);ap.set(user,plugin);
|
||||
var data=new UserData(user);when(user.getData()).thenReturn(data);when(user.getUserData()).thenReturn(data);when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getCoolDownCheckSiteList();when(user.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites");
|
||||
doCallRealMethod().when(user).getPlayerName();doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any());doCallRealMethod().when(user).getUserDataFetchMode();
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());doCallRealMethod().when(user).clearTempCache();
|
||||
when(user.getCoolDownCheck()).thenReturn(false);when(user.canVoteAll()).thenReturn(false);when(user.canVoteSite(site)).thenReturn(true);when(user.canVoteSite(second)).thenReturn(true);
|
||||
when(user.getSitesVotedOn()).thenReturn(1);when(user.getJavaUUID()).thenReturn(id);when(user.getNextSiteAvailable()).thenReturn(new NextSite(site,60));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user);
|
||||
var storage=plugin.getUserManager(); doAnswer(call->{BiConsumer<UUID,ArrayList<Column>> each=call.getArgument(0);Consumer<Integer> done=call.getArgument(1);
|
||||
each.accept(id,new ArrayList<>(List.of(new Column("PlayerName",new DataValueString("OfflinePlayer")),new Column("CoolDownCheck_Sites",new DataValueString("")))));done.accept(1);return null;}).when(storage).forEachUserKeys(any(),any());
|
||||
var writes=new ArrayList<HashMap<String,Boolean>>();
|
||||
doAnswer(call->{writes.add(new HashMap<>(call.getArgument(0)));return null;}).when(user).setCoolDownCheckSite(any());
|
||||
var check=new CoolDownCheck(plugin);
|
||||
check.checkAllVoteSites();
|
||||
assertEquals(2,writes.size());
|
||||
assertEquals(Map.of("site",true),writes.get(0));
|
||||
assertEquals(Map.of("site",true,"second",true),writes.get(1));
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.*;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import com.bencodez.advancedcore.api.user.*;
|
||||
import com.bencodez.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.*;
|
||||
class CoolDownPendingCacheTest {
|
||||
@org.junit.jupiter.api.Test void queuedCompletedFlagWinsOverStalePersistedFlag() throws Exception {
|
||||
var id=UUID.randomUUID(); var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS);
|
||||
var config=mock(Config.class);var sites=mock(VoteSiteManager.class);var site=mock(VoteSite.class);
|
||||
var server=mock(Server.class);var events=mock(PluginManager.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);when(plugin.getServer()).thenReturn(server);when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
when(sites.hasVoteSite("site")).thenReturn(true);when(sites.getVoteSite("site",true)).thenReturn(site);
|
||||
var user=mock(VotingPluginUser.class); Field p=VotingPluginUser.class.getDeclaredField("plugin");p.setAccessible(true);p.set(user,plugin);
|
||||
Field ap=AdvancedCoreUser.class.getDeclaredField("plugin");ap.setAccessible(true);ap.set(user,plugin);
|
||||
var data=new UserData(user);when(user.getData()).thenReturn(data);when(user.getUserData()).thenReturn(data);when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getCoolDownCheckSiteList();when(user.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites");
|
||||
doCallRealMethod().when(user).getPlayerName();doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any());doCallRealMethod().when(user).getUserDataFetchMode();
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());doCallRealMethod().when(user).clearTempCache();
|
||||
when(user.getCoolDownCheck()).thenReturn(true);when(user.canVoteAll()).thenReturn(false);when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(user.getSitesVotedOn()).thenReturn(1);when(user.getJavaUUID()).thenReturn(id);when(user.getNextSiteAvailable()).thenReturn(new NextSite(site,60));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user);
|
||||
var storage=plugin.getUserManager(); doAnswer(call->{BiConsumer<UUID,ArrayList<Column>> each=call.getArgument(0);Consumer<Integer> done=call.getArgument(1);
|
||||
each.accept(id,new ArrayList<>(List.of(new Column("PlayerName",new DataValueString("OfflinePlayer")),new Column("CoolDownCheck_Sites",new DataValueString("site//true")))));done.accept(1);return null;}).when(storage).forEachUserKeys(any(),any());
|
||||
var cacheManager=plugin.getUserManager().getDataManager();
|
||||
var cache=new com.bencodez.advancedcore.api.user.usercache.UserDataCache(cacheManager,id);
|
||||
cache.updateCache(new HashMap<>());
|
||||
when(cacheManager.getPlugin()).thenReturn(plugin);
|
||||
when(cacheManager.getTimer()).thenReturn(mock(ScheduledExecutorService.class));
|
||||
cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeString("CoolDownCheck_Sites", "site//true"),true);
|
||||
var fresh=mock(VotingPluginUser.class);p.set(fresh,plugin);ap.set(fresh,plugin);
|
||||
when(fresh.getPlugin()).thenReturn(plugin);when(fresh.isOnline()).thenReturn(false);when(fresh.isCached()).thenReturn(true);
|
||||
when(fresh.getCache()).thenReturn(cache);when(fresh.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites");
|
||||
doCallRealMethod().when(fresh).getCoolDownCheckSiteList();doCallRealMethod().when(fresh).userDataFetechMode(any());
|
||||
doCallRealMethod().when(fresh).getUserDataFetchMode();fresh.userDataFetechMode(UserDataFetchMode.DEFAULT);
|
||||
var freshData=new UserData(fresh);when(fresh.getData()).thenReturn(freshData);when(fresh.getUserData()).thenReturn(freshData);
|
||||
var sql=mock(com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess.class);
|
||||
when(sql.getString(any(),eq("CoolDownCheck_Sites"))).thenReturn("site//false");
|
||||
Field sqlField=UserData.class.getDeclaredField("sqlData");sqlField.setAccessible(true);sqlField.set(freshData,sql);
|
||||
when(fresh.canVoteSite(site)).thenReturn(true);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user,fresh);
|
||||
var check=new CoolDownCheck(plugin);Field f=CoolDownCheck.class.getDeclaredField("timer");f.setAccessible(true);((ScheduledExecutorService) f.get(check)).shutdownNow();var timer=mock(ScheduledExecutorService.class);var scheduled=new AtomicReference<Runnable>();
|
||||
when(timer.schedule(any(Runnable.class),anyLong(),any(TimeUnit.class))).thenAnswer(call->{scheduled.set(call.getArgument(0));return mock(ScheduledFuture.class);});
|
||||
f.set(check,timer);
|
||||
check.checkAllVoteSites();assertNull(data.getTempCache());assertNotNull(scheduled.get());
|
||||
verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
scheduled.get().run();
|
||||
verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
verify(sql,never()).getString(any(),eq("CoolDownCheck_Sites"));
|
||||
assertEquals(UserDataFetchMode.DEFAULT, fresh.getUserDataFetchMode());
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardHandler;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardOptions;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.SpecialRewardsConfig;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class CoolDownRewardDiagnosticsTest {
|
||||
@Test void synchronousFailureIsCorrelatedRedactedAndPropagated() {
|
||||
Fixture f = new Fixture(true);
|
||||
IllegalStateException failure = new IllegalStateException("private command and placeholder");
|
||||
doThrow(failure).when(f.handler).giveReward(eq(f.user), eq(f.data), eq("VoteCoolDownEndedReward"), any(RewardOptions.class));
|
||||
try {
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> f.check.onCoolDownEnd(new PlayerVoteCoolDownEndEvent(f.user))));
|
||||
ArgumentCaptor<String> attempted = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> warning = ArgumentCaptor.forClass(String.class);
|
||||
verify(f.logger).info(attempted.capture());
|
||||
verify(f.logger).warning(warning.capture());
|
||||
var match = java.util.regex.Pattern.compile("occurrence=([0-9a-f-]+)").matcher(attempted.getValue());
|
||||
assertTrue(match.find());
|
||||
assertTrue(warning.getValue().contains("occurrence=" + match.group(1)));
|
||||
assertTrue(warning.getValue().contains("player=" + f.uuid));
|
||||
assertTrue(warning.getValue().contains("site=all"));
|
||||
assertTrue(warning.getValue().contains("outcome=unknown/partial"));
|
||||
assertFalse(warning.getValue().contains("private command"));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void absentRewardKeepsLegacyDispatchWithoutDiagnosticNoise() {
|
||||
Fixture f = new Fixture(false);
|
||||
try {
|
||||
f.check.onCoolDownEnd(new PlayerVoteCoolDownEndEvent(f.user));
|
||||
verify(f.handler).giveReward(eq(f.user), eq(f.data), eq("VoteCoolDownEndedReward"), any(RewardOptions.class));
|
||||
verifyNoInteractions(f.logger);
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void loggerFailureDoesNotSuppressRewardDispatch() {
|
||||
Fixture f = new Fixture(true);
|
||||
doThrow(new IllegalStateException("logger unavailable")).when(f.logger).info(anyString());
|
||||
try {
|
||||
f.check.onCoolDownEnd(new PlayerVoteCoolDownEndEvent(f.user));
|
||||
verify(f.handler).giveReward(eq(f.user), eq(f.data), eq("VoteCoolDownEndedReward"), any(RewardOptions.class));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void nestedSiteSectionProducesAdmissionWithoutChangingDispatch() {
|
||||
Fixture f = new Fixture(false);
|
||||
var site = mock(com.bencodez.votingplugin.votesites.VoteSite.class);
|
||||
var section = f.data.createSection("site");
|
||||
section.set("CoolDownEndRewards.Command", "say hello");
|
||||
when(site.getSiteData()).thenReturn(section);
|
||||
when(site.getKey()).thenReturn("example");
|
||||
when(site.getDisplayName()).thenReturn("Example");
|
||||
when(site.getVoteURL(false)).thenReturn("https://example.test");
|
||||
try {
|
||||
f.check.onCoolDownEnd(new com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent(f.user, site));
|
||||
verify(f.logger).info(contains("status=attempted"));
|
||||
verify(f.handler).giveReward(eq(f.user), eq(section), eq("CoolDownEndRewards"), any(RewardOptions.class));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final UUID uuid = UUID.randomUUID();
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
final VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
final SpecialRewardsConfig special = mock(SpecialRewardsConfig.class);
|
||||
final RewardHandler handler = mock(RewardHandler.class);
|
||||
final Logger logger = mock(Logger.class);
|
||||
final YamlConfiguration data = new YamlConfiguration();
|
||||
final CoolDownCheck check;
|
||||
Fixture(boolean configured) {
|
||||
when(plugin.getSpecialRewardsConfig()).thenReturn(special);
|
||||
when(special.getData()).thenReturn(data);
|
||||
when(plugin.getRewardHandler()).thenReturn(handler);
|
||||
when(plugin.getLogger()).thenReturn(logger);
|
||||
when(user.getUUID()).thenReturn(uuid.toString());
|
||||
when(handler.hasRewards(data, "VoteCoolDownEndedReward")).thenReturn(configured);
|
||||
check = new CoolDownCheck(plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import com.bencodez.advancedcore.api.rewards.RewardHandler;
|
||||
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
|
||||
import com.bencodez.advancedcore.api.user.UserData;
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
import com.bencodez.advancedcore.api.user.UserStorage;
|
||||
import com.bencodez.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.config.SpecialRewardsConfig;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import com.bencodez.votingplugin.votesites.VoteSiteManager;
|
||||
|
||||
class CoolDownRewardIntegrationTest {
|
||||
@TempDir Path tempDir;
|
||||
private boolean globalsChanged;
|
||||
private com.bencodez.advancedcore.AdvancedCorePlugin oldInstance;
|
||||
private com.bencodez.advancedcore.api.misc.MiscUtils misc;
|
||||
private Field miscPlugin;
|
||||
private Object oldMiscPlugin;
|
||||
private RewardHandler activeRewards;
|
||||
private CoolDownCheck activeCheck;
|
||||
|
||||
private void installGlobals(VotingPluginMain plugin) throws Exception {
|
||||
oldInstance = com.bencodez.advancedcore.AdvancedCorePlugin.getInstance();
|
||||
misc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance();
|
||||
miscPlugin = misc.getClass().getDeclaredField("plugin");
|
||||
miscPlugin.setAccessible(true);
|
||||
oldMiscPlugin = miscPlugin.get(misc);
|
||||
globalsChanged = true;
|
||||
com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(plugin);
|
||||
miscPlugin.set(misc, plugin);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.AfterEach void cleanup() throws Exception {
|
||||
try {
|
||||
if (activeCheck != null) activeCheck.shutdown();
|
||||
} finally {
|
||||
try {
|
||||
if (activeRewards != null) activeRewards.getDelayedTimer().shutdownNow();
|
||||
} finally {
|
||||
if (globalsChanged) {
|
||||
try { miscPlugin.set(misc, oldMiscPlugin); }
|
||||
finally {
|
||||
com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(oldInstance);
|
||||
globalsChanged = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cleanupRestoresNullInstanceAfterPartialSetupFailure() throws Exception {
|
||||
var originalInstance = com.bencodez.advancedcore.AdvancedCorePlugin.getInstance();
|
||||
var originalMisc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance();
|
||||
Field field = originalMisc.getClass().getDeclaredField("plugin");
|
||||
field.setAccessible(true);
|
||||
Object originalMiscPlugin = field.get(originalMisc);
|
||||
try {
|
||||
com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(null);
|
||||
assertThrows(IllegalStateException.class, () -> {
|
||||
installGlobals(mock(VotingPluginMain.class));
|
||||
throw new IllegalStateException("fixture setup failed");
|
||||
});
|
||||
cleanup();
|
||||
assertNull(com.bencodez.advancedcore.AdvancedCorePlugin.getInstance());
|
||||
assertSame(originalMiscPlugin, field.get(originalMisc));
|
||||
} finally {
|
||||
cleanup();
|
||||
field.set(originalMisc, originalMiscPlugin);
|
||||
com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(originalInstance);
|
||||
}
|
||||
}
|
||||
private static final UUID UUID_VALUE = UUID.fromString("00000000-0000-0000-0000-000000000123");
|
||||
|
||||
@Test void singleSiteCommandListUsesCapturedOfflineName() throws Exception { exercise(false, "OfflinePlayer", false, false, false, false, false); }
|
||||
@Test void allSiteCommandUsesCapturedOfflineName() throws Exception { exercise(true, "OfflinePlayer", false, false, false, false, false); }
|
||||
@Test void missingIdentityRejectsPlayerTargetAndLogsFailure() throws Exception { exercise(false, null, false, true, false, false, false); }
|
||||
@Test void missingIdentityAllowsLiteralCommand() throws Exception { exercise(false, null, true, false, false, false, false); }
|
||||
|
||||
@Test void onlineIdentityUsesPlayerOwnerScheduler() throws Exception { exercise(false, "OldName", false, false, true, false, false); }
|
||||
|
||||
@Test void missingIdentityDoesNotReplayCompletedEffect() throws Exception { exercise(false, null, false, true, false, true, false); }
|
||||
|
||||
@Test void missingOfflineNameCannotTargetUnrelatedPlayer() throws Exception { exercise(false, null, true, true, false, false, true); }
|
||||
|
||||
private void exercise(boolean allSites, String cachedName, boolean literal, boolean reject, boolean online, boolean partial, boolean mixed) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
Config config = mock(Config.class);
|
||||
VoteSiteManager sites = mock(VoteSiteManager.class);
|
||||
VoteSite site = mock(VoteSite.class);
|
||||
when(plugin.getDataFolder()).thenReturn(tempDir.toFile());
|
||||
RewardHandler rewards = new RewardHandler(plugin);
|
||||
activeRewards = rewards;
|
||||
com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommand(rewards, plugin);
|
||||
com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(rewards, plugin);
|
||||
java.util.concurrent.atomic.AtomicInteger completed = new java.util.concurrent.atomic.AtomicInteger();
|
||||
if (partial) rewards.getInjectedRewards().add(0, new com.bencodez.advancedcore.api.rewards.injected.RewardInject("Marker") {
|
||||
@Override public Object onRewardRequest(com.bencodez.advancedcore.api.rewards.Reward reward,
|
||||
AdvancedCoreUser recipient, org.bukkit.configuration.ConfigurationSection data,
|
||||
HashMap<String, String> placeholders) { completed.incrementAndGet(); return null; }
|
||||
});
|
||||
SpecialRewardsConfig special = mock(SpecialRewardsConfig.class);
|
||||
Server server = mock(Server.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
YamlConfiguration data = new YamlConfiguration();
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getOptions().isProcessRewards()).thenReturn(true);
|
||||
when(plugin.getOptions().getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd");
|
||||
installGlobals(plugin);
|
||||
var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
java.util.ArrayDeque<Runnable> queued = new java.util.ArrayDeque<>();
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class));
|
||||
doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(org.bukkit.entity.Player.class));
|
||||
doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class));
|
||||
doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
|
||||
Logger logger = mock(Logger.class);
|
||||
when(plugin.getLogger()).thenReturn(logger);
|
||||
when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);
|
||||
when(plugin.getRewardHandler()).thenReturn(rewards);
|
||||
when(plugin.getSpecialRewardsConfig()).thenReturn(special);
|
||||
when(special.getData()).thenReturn(data);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");
|
||||
when(site.getDisplayName()).thenReturn("Site");
|
||||
when(site.getVoteURL(false)).thenReturn("https://example.test");
|
||||
when(site.getSiteData()).thenReturn(data);
|
||||
when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
|
||||
VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin");
|
||||
pluginField.setAccessible(true);
|
||||
pluginField.set(user, plugin);
|
||||
Field dataField = AdvancedCoreUser.class.getDeclaredField("data");
|
||||
dataField.setAccessible(true);
|
||||
UserData userData = new UserData(user);
|
||||
dataField.set(user, userData);
|
||||
when(user.getUserData()).thenReturn(userData);
|
||||
when(user.getUUID()).thenReturn(UUID_VALUE.toString());
|
||||
when(user.endAsyncActionCollection(any())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null));
|
||||
when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getPlayerName();
|
||||
doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any(UserDataFetchMode.class));
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());
|
||||
doCallRealMethod().when(user).clearTempCache();
|
||||
org.bukkit.entity.Player live = mock(org.bukkit.entity.Player.class);
|
||||
if (mixed) {
|
||||
when(plugin.getOptions().isOnlineMode()).thenReturn(false);
|
||||
doCallRealMethod().when(user).getPlayer();
|
||||
doCallRealMethod().when(user).validatePlayerCommandAvailabilityAsync();
|
||||
}
|
||||
if (online) {
|
||||
when(user.isOnline()).thenReturn(true);
|
||||
when(user.getPlayer()).thenReturn(live);
|
||||
user.setPlayerName("OnlinePlayer");
|
||||
}
|
||||
when(user.getCoolDownCheckSiteList()).thenReturn(new HashMap<>());
|
||||
when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(user.getCoolDownCheck()).thenReturn(allSites);
|
||||
when(user.canVoteAll()).thenReturn(true);
|
||||
when(user.getJavaUUID()).thenReturn(UUID_VALUE);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID_VALUE, false)).thenReturn(user);
|
||||
com.bencodez.advancedcore.api.user.UserManager storage = plugin.getUserManager();
|
||||
doAnswer(call -> {
|
||||
BiConsumer<UUID, ArrayList<Column>> each = call.getArgument(0);
|
||||
Consumer<Integer> done = call.getArgument(1);
|
||||
ArrayList<Column> columns = new ArrayList<>();
|
||||
if (cachedName != null) columns.add(new Column("PlayerName", new DataValueString(cachedName)));
|
||||
each.accept(UUID_VALUE, columns);
|
||||
done.accept(1);
|
||||
return null;
|
||||
}).when(storage).forEachUserKeys(any(), any());
|
||||
|
||||
data.set("VoteCoolDownEndedReward.ForceOffline", true);
|
||||
data.set("VoteCoolDownEndedReward.Command", literal ? "say ready" : "say %player%");
|
||||
data.set("CoolDownEndRewards.ForceOffline", true);
|
||||
if (partial) data.set("CoolDownEndRewards.Marker", true);
|
||||
if (mixed) {
|
||||
data.set("CoolDownEndRewards.Commands.Console", new ArrayList<>(List.of("say ready")));
|
||||
data.set("CoolDownEndRewards.Commands.Player", new ArrayList<>(List.of("unrelated-action")));
|
||||
} else data.set("CoolDownEndRewards.Commands", new ArrayList<>(List.of(literal ? "say ready" : "say %player%")));
|
||||
CoolDownCheck check = new CoolDownCheck(plugin);
|
||||
activeCheck = check;
|
||||
doAnswer(call -> {
|
||||
Event event = call.getArgument(0);
|
||||
if (event instanceof PlayerVoteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteCoolDownEndEvent) event);
|
||||
if (event instanceof PlayerVoteSiteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteSiteCoolDownEndEvent) event);
|
||||
return null;
|
||||
}).when(events).callEvent(any(Event.class));
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
var console = mock(org.bukkit.command.ConsoleCommandSender.class);
|
||||
var offline = mock(org.bukkit.OfflinePlayer.class);
|
||||
var unrelated = mock(org.bukkit.entity.Player.class);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(events);
|
||||
bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console);
|
||||
bukkit.when(() -> org.bukkit.Bukkit.getOfflinePlayer(anyString())).thenReturn(offline);
|
||||
if (online) { when(offline.isOnline()).thenReturn(true); when(offline.getPlayer()).thenReturn(live); }
|
||||
if (mixed) bukkit.when(() -> org.bukkit.Bukkit.getPlayer("")).thenReturn(unrelated);
|
||||
if (allSites) check.checkAllVoteSites(); else check.checkAllVoteSite(site);
|
||||
assertNull(userData.getTempCache());
|
||||
assertFalse(queued.isEmpty(), "real reward must be deferred");
|
||||
verify(server, never()).dispatchCommand(any(), anyString());
|
||||
while (!queued.isEmpty()) queued.remove().run();
|
||||
if (reject) {
|
||||
verify(server, never()).dispatchCommand(any(), anyString());
|
||||
verify(logger).log(eq(java.util.logging.Level.WARNING), contains("Failed to give reward"), any(Throwable.class));
|
||||
var attempted = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
var failure = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(logger, atLeastOnce()).info(attempted.capture());
|
||||
verify(logger).warning(failure.capture());
|
||||
String admission = attempted.getAllValues().stream()
|
||||
.filter(value -> value.startsWith("Cooldown reward status=attempted"))
|
||||
.findFirst().orElseThrow();
|
||||
var matcher = java.util.regex.Pattern.compile("occurrence=([0-9a-f-]+)").matcher(admission);
|
||||
assertTrue(matcher.find());
|
||||
assertTrue(failure.getValue().contains("occurrence=" + matcher.group(1)));
|
||||
assertTrue(failure.getValue().contains("player=" + UUID_VALUE));
|
||||
assertTrue(failure.getValue().contains("site=site:site"));
|
||||
assertTrue(failure.getValue().contains("outcome=unknown/partial"));
|
||||
assertTrue(failure.getValue().contains("completedInjections="));
|
||||
if (partial) {
|
||||
var count = java.util.regex.Pattern.compile("completedInjections=([0-9]+)").matcher(failure.getValue());
|
||||
assertTrue(count.find());
|
||||
assertTrue(Integer.parseInt(count.group(1)) >= 1);
|
||||
}
|
||||
assertFalse(failure.getValue().contains("%player%"));
|
||||
assertEquals(partial ? 1 : 0, completed.get());
|
||||
} else {
|
||||
verify(server, times(allSites ? 2 : 1)).dispatchCommand(console, literal ? "say ready" : online ? "say OnlinePlayer" : "say OfflinePlayer");
|
||||
}
|
||||
bukkit.verify(() -> org.bukkit.Bukkit.getOfflinePlayer(""), never());
|
||||
if (mixed) {
|
||||
bukkit.verify(() -> org.bukkit.Bukkit.getPlayer(""), never());
|
||||
verify(unrelated, never()).chat(anyString());
|
||||
}
|
||||
verify(scheduler, atLeastOnce()).executeOrScheduleSync(eq(plugin), any(Runnable.class));
|
||||
verify(scheduler, atLeastOnce()).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
if (online) verify(scheduler, atLeastOnce()).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(live));
|
||||
assertEquals(UUID_VALUE, user.getJavaUUID());
|
||||
verify(user).setCoolDownCheckSite(any());
|
||||
if (allSites) verify(user).setCoolDownCheck(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.advancedcore.api.user.UserData;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.NextSite;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
|
||||
class CoolDownTimerGenerationTest {
|
||||
@Test void replacedTimerDoesNotLoadOrDeliver() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.tasks.get(0).run();
|
||||
verify(f.plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(f.uuid, false);
|
||||
f.tasks.get(1).run();
|
||||
verify(f.plugin.getVotingPluginUserManager()).getVotingPluginUser(f.uuid, false);
|
||||
Field generations = CoolDownCheck.class.getDeclaredField("perSiteGenerations");
|
||||
generations.setAccessible(true);
|
||||
assertFalse(((java.util.Map<?, ?>) generations.get(f.check)).containsKey(f.uuid));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void replacementWhileLoadingStopsOldTimerBeforeSiteCheck() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
var manager = f.plugin.getVotingPluginUserManager();
|
||||
doAnswer(call -> { f.check.schedulePerSite(f.user, true); return f.fresh; })
|
||||
.when(manager).getVotingPluginUser(f.uuid, false);
|
||||
f.tasks.get(0).run();
|
||||
verify(f.fresh, never()).getCoolDownCheckSiteList();
|
||||
assertEquals(2, f.tasks.size());
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void shutdownRetiresAcceptedTimer() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.check.shutdown();
|
||||
f.tasks.get(0).run();
|
||||
verify(f.plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(f.uuid, false);
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
assertEquals(1, f.tasks.size());
|
||||
}
|
||||
|
||||
@Test void timerFailureLogsCorrelatedUncertainOutcomeAndPropagates() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
var data = f.fresh.getData();
|
||||
when(data.getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class))).thenThrow(new IllegalStateException("private detail"));
|
||||
f.tasks.get(0).run();
|
||||
f.tasks.get(1).run();
|
||||
f.tasks.get(2).run();
|
||||
IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> f.tasks.get(3).run());
|
||||
assertEquals("private detail", thrown.getMessage());
|
||||
verify(f.plugin.getLogger()).warning(argThat((String message) -> message.contains("occurrence=")
|
||||
&& message.contains("player=" + f.uuid) && message.contains("site=site")
|
||||
&& message.contains("time=") && message.contains("outcome is uncertain")
|
||||
&& !message.contains("private detail")));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void failedRefreshRetriesThenChecksWithoutReplaying() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
var data = f.fresh.getData();
|
||||
when(data.getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class)))
|
||||
.thenThrow(new IllegalStateException("unavailable")).thenReturn("CurrentName");
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.tasks.get(0).run();
|
||||
verify(f.fresh, never()).getCoolDownCheckSiteList();
|
||||
assertEquals(2, f.tasks.size());
|
||||
verify(f.timer).schedule(any(Runnable.class), eq(30L), eq(TimeUnit.SECONDS));
|
||||
f.tasks.get(1).run();
|
||||
verify(f.fresh).setPlayerName("CurrentName");
|
||||
verify(f.fresh).getCoolDownCheckSiteList();
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void replacedOrStoppedRetryCannotLoad() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
when(f.fresh.getData().getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class)))
|
||||
.thenThrow(new IllegalStateException("unavailable"));
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.tasks.get(0).run();
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
f.tasks.get(1).run();
|
||||
verify(f.plugin.getVotingPluginUserManager(), times(1)).getVotingPluginUser(f.uuid, false);
|
||||
f.check.shutdown();
|
||||
f.tasks.get(2).run();
|
||||
verify(f.plugin.getVotingPluginUserManager(), times(1)).getVotingPluginUser(f.uuid, false);
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
@Test void retiredCacheFailureIsVisibleAndDoesNotChangeSiteFlags() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
try {
|
||||
f.check.schedulePerSite(f.user, true);
|
||||
when(f.fresh.getCoolDownCheckSiteList()).thenThrow(new IllegalStateException("cache retired"));
|
||||
assertThrows(IllegalStateException.class, () -> f.tasks.get(0).run());
|
||||
verify(f.fresh, never()).setCoolDownCheckSite(any());
|
||||
assertEquals(1, f.tasks.size());
|
||||
verify(f.plugin.getLogger()).warning(argThat((String message) -> message.contains("player=" + f.uuid)
|
||||
&& message.contains("site=site") && message.contains("outcome is uncertain")
|
||||
&& !message.contains("cache retired")));
|
||||
} finally { f.check.shutdown(); }
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final UUID uuid = UUID.randomUUID();
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
final VotingPluginUser fresh = mock(VotingPluginUser.class);
|
||||
final VoteSite site = mock(VoteSite.class);
|
||||
final ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
final ArrayList<Runnable> tasks = new ArrayList<>();
|
||||
final CoolDownCheck check;
|
||||
Fixture() throws Exception {
|
||||
Config config = mock(Config.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(site.getKey()).thenReturn("site");
|
||||
when(user.getJavaUUID()).thenReturn(uuid);
|
||||
when(user.getPlayerName()).thenReturn("PriorName");
|
||||
when(user.getNextSiteAvailable()).thenReturn(new NextSite(site, 60));
|
||||
when(fresh.getData()).thenReturn(mock(UserData.class));
|
||||
when(fresh.getCoolDownCheckSiteList()).thenReturn(new HashMap<>(java.util.Map.of("site", true)));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false)).thenReturn(fresh);
|
||||
doAnswer(call -> { tasks.add(call.getArgument(0)); return mock(ScheduledFuture.class); })
|
||||
.when(timer).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
|
||||
check = new CoolDownCheck(plugin);
|
||||
Field field = CoolDownCheck.class.getDeclaredField("timer");
|
||||
field.setAccessible(true);
|
||||
((ScheduledExecutorService) field.get(check)).shutdownNow();
|
||||
field.set(check, timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.bencodez.votingplugin.tests.cooldown;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.*;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import com.bencodez.advancedcore.api.user.*;
|
||||
import com.bencodez.simpleapi.sql.Column;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.cooldown.CoolDownCheck;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.*;
|
||||
class CoolDownTimerRefreshTest {
|
||||
@org.junit.jupiter.api.Test void alreadyCompletedSiteIsNotDeliveredAgain() throws Exception { exercise(true, true, "OfflinePlayer", "Renamed", "Renamed"); }
|
||||
@org.junit.jupiter.api.Test void newerVoteIsConsultedBeforeDelivering() throws Exception { exercise(false, false, "OfflinePlayer", "", "OfflinePlayer"); }
|
||||
@org.junit.jupiter.api.Test void missingNameDoesNotInventIdentity() throws Exception { exercise(true, true, null, "", null); }
|
||||
private void exercise(boolean alreadyCompleted, boolean eligible, String scheduledName, String storedName, String expectedName) throws Exception {
|
||||
var id=UUID.randomUUID(); var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS);
|
||||
var config=mock(Config.class);var sites=mock(VoteSiteManager.class);var site=mock(VoteSite.class);
|
||||
var server=mock(Server.class);var events=mock(PluginManager.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
when(plugin.getConfigFile()).thenReturn(config);when(config.isPerSiteCoolDownEvents()).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);when(plugin.getServer()).thenReturn(server);when(server.getPluginManager()).thenReturn(events);
|
||||
when(site.getKey()).thenReturn("site");when(site.isVoteDelayDaily()).thenReturn(true);
|
||||
when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site)));
|
||||
when(sites.hasVoteSite("site")).thenReturn(true);when(sites.getVoteSite("site",true)).thenReturn(site);
|
||||
var user=mock(VotingPluginUser.class); Field p=VotingPluginUser.class.getDeclaredField("plugin");p.setAccessible(true);p.set(user,plugin);
|
||||
Field ap=AdvancedCoreUser.class.getDeclaredField("plugin");ap.setAccessible(true);ap.set(user,plugin);
|
||||
var data=new UserData(user);when(user.getData()).thenReturn(data);when(user.getUserData()).thenReturn(data);when(user.getPlugin()).thenReturn(plugin);
|
||||
doCallRealMethod().when(user).getCoolDownCheckSiteList();when(user.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites");
|
||||
doCallRealMethod().when(user).getPlayerName();doCallRealMethod().when(user).setPlayerName(anyString());
|
||||
doCallRealMethod().when(user).userDataFetechMode(any());doCallRealMethod().when(user).getUserDataFetchMode();
|
||||
doCallRealMethod().when(user).updateTempCacheWithColumns(any());doCallRealMethod().when(user).clearTempCache();
|
||||
when(user.getCoolDownCheck()).thenReturn(true);when(user.canVoteAll()).thenReturn(false);when(user.canVoteSite(site)).thenReturn(true);
|
||||
when(user.getSitesVotedOn()).thenReturn(1);when(user.getJavaUUID()).thenReturn(id);when(user.getNextSiteAvailable()).thenReturn(new NextSite(site,60));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user);
|
||||
var storage=plugin.getUserManager(); doAnswer(call->{BiConsumer<UUID,ArrayList<Column>> each=call.getArgument(0);Consumer<Integer> done=call.getArgument(1);
|
||||
ArrayList<Column> columns=new ArrayList<>(); if(scheduledName != null) columns.add(new Column("PlayerName",new DataValueString(scheduledName))); columns.add(new Column("CoolDownCheck_Sites",new DataValueString("site//true"))); each.accept(id,columns);done.accept(1);return null;}).when(storage).forEachUserKeys(any(),any());
|
||||
var fresh=mock(VotingPluginUser.class);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user, fresh);
|
||||
var freshData=mock(UserData.class); when(fresh.getData()).thenReturn(freshData);
|
||||
when(freshData.getString(eq("PlayerName"),nullable(UserDataFetchMode.class))).thenReturn(storedName);
|
||||
var freshFlags=new HashMap<String,Boolean>();
|
||||
if (alreadyCompleted) freshFlags.put("site", true);
|
||||
when(fresh.getCoolDownCheckSiteList()).thenReturn(freshFlags);
|
||||
when(fresh.canVoteSite(site)).thenReturn(eligible);
|
||||
var check=new CoolDownCheck(plugin);Field f=CoolDownCheck.class.getDeclaredField("timer");f.setAccessible(true);((ScheduledExecutorService) f.get(check)).shutdownNow();var timer=mock(ScheduledExecutorService.class);var scheduled=new AtomicReference<Runnable>();
|
||||
when(timer.schedule(any(Runnable.class),anyLong(),any(TimeUnit.class))).thenAnswer(call->{scheduled.set(call.getArgument(0));return mock(ScheduledFuture.class);});
|
||||
f.set(check,timer);
|
||||
check.checkAllVoteSites();assertNull(data.getTempCache());assertNotNull(scheduled.get());
|
||||
verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
try (var bukkit=mockStatic(org.bukkit.Bukkit.class)) { scheduled.get().run(); bukkit.verifyNoInteractions(); }
|
||||
verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class));
|
||||
verify(plugin.getVotingPluginUserManager(),times(2)).getVotingPluginUser(id,false);
|
||||
verify(plugin.getVotingPluginUserManager(),never()).getVotingPluginUser(id);
|
||||
if (expectedName == null) verify(fresh,never()).setPlayerName(anyString());
|
||||
else verify(fresh).setPlayerName(expectedName);
|
||||
verify(fresh).userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
verify(fresh,never()).setCoolDownCheckSite(any());
|
||||
check.shutdown();
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.bencodez.votingplugin.tests.discord;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.LongFunction;
|
||||
|
||||
import com.bencodez.votingplugin.discord.DiscordHandler;
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterPlayer;
|
||||
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.entities.TextChannel;
|
||||
|
||||
/** Test-only access to the native publication and channel seams. */
|
||||
final class DiscordHandlerAccess {
|
||||
private static final String OWNER = "com.bencodez.votingplugin.discord.DiscordHandler";
|
||||
|
||||
private DiscordHandlerAccess() { }
|
||||
|
||||
static void publish(DiscordHandler target, TopVoter period,
|
||||
LinkedHashMap<TopVoterPlayer, Integer> rows) {
|
||||
ReflectiveAccess.call(OWNER, target, "publishTopVoterMessage",
|
||||
new String[]{"TopVoter", "LinkedHashMap"}, new Object[]{period, rows});
|
||||
}
|
||||
|
||||
static void recordPublications(DiscordHandler target,
|
||||
Consumer<LinkedHashMap<TopVoterPlayer, Integer>> observer) {
|
||||
var stub = doAnswer(call -> {
|
||||
observer.accept(call.getArgument(1));
|
||||
return null;
|
||||
}).when(target);
|
||||
ReflectiveAccess.call(OWNER, stub, "publishTopVoterMessage",
|
||||
new String[]{"TopVoter", "LinkedHashMap", "long"},
|
||||
new Object[]{any(TopVoter.class), any(LinkedHashMap.class), anyLong()});
|
||||
}
|
||||
|
||||
static void channel(DiscordHandler target, LongFunction<TextChannel> lookup) {
|
||||
var stub = doAnswer(call -> lookup.apply(call.getArgument(0))).when(target);
|
||||
ReflectiveAccess.call(OWNER, stub, "leaderboardChannel",
|
||||
new String[]{"long"}, new Object[]{anyLong()});
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package com.bencodez.votingplugin.tests.discord;
|
||||
|
||||
import com.bencodez.votingplugin.discord.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresenceService;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterPlayer;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter;
|
||||
|
||||
class DiscordLeaderboardCompletionTest {
|
||||
static final class RecordingHandler {
|
||||
int posts;
|
||||
LinkedHashMap<TopVoterPlayer, Integer> published;
|
||||
final DiscordHandler delegate;
|
||||
RecordingHandler(VotingPluginMain plugin) {
|
||||
delegate = spy(new DiscordHandler(plugin));
|
||||
DiscordHandlerAccess.recordPublications(delegate, snapshot -> {
|
||||
posts++; published = snapshot;
|
||||
});
|
||||
}
|
||||
void updateTopVoterMessageId(TopVoter period) { delegate.updateTopVoterMessageId(period); }
|
||||
}
|
||||
static final class Fixture implements AutoCloseable {
|
||||
final ArrayDeque<Runnable> worker = new ArrayDeque<>();
|
||||
final AtomicLong clock = new AtomicLong(), generation = new AtomicLong();
|
||||
final TopVoterPlayer never = new TopVoterPlayer(new UUID(0, 1), "SyntheticNever", 1L);
|
||||
final TopVoterPlayer joined = new TopVoterPlayer(new UUID(0, 2), "SyntheticJoined", 1L);
|
||||
final LinkedHashMap<TopVoterPlayer, Integer> raw = new LinkedHashMap<>();
|
||||
final PlayerPresenceService service = new PlayerPresenceService(worker::add,
|
||||
uuid -> uuid.equals(never.getUuid()) ? PlayerPresence.NEVER_JOINED : PlayerPresence.JOINED);
|
||||
final TopVoterDisplayFilter filter = new TopVoterDisplayFilter(row -> service.checkAsync(row.getUuid()), clock::get);
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
final RecordingHandler handler;
|
||||
Fixture() {
|
||||
raw.put(never, 100); raw.put(joined, 90);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
when(plugin.getServerData()).thenReturn(mock(ServerData.class));
|
||||
when(plugin.getTopVoterDisplayGeneration()).thenAnswer(call -> generation.get());
|
||||
when(plugin.getTopVoterDisplayAsync(any())).thenAnswer(call -> filter.projectAsync(raw, true));
|
||||
when(plugin.getTopVoterDisplay(any())).thenAnswer(call -> filter.project(raw, true));
|
||||
var timer = mock(ScheduledExecutorService.class);
|
||||
doAnswer(call -> { worker.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class));
|
||||
when(plugin.getTimer()).thenReturn(timer);
|
||||
handler = new RecordingHandler(plugin);
|
||||
}
|
||||
void drain() {
|
||||
int bound = 20;
|
||||
while (!worker.isEmpty()) { assertTrue(bound-- > 0, "Completion must not retry indefinitely"); worker.remove().run(); }
|
||||
}
|
||||
@Override public void close() { filter.close(); service.close(); }
|
||||
}
|
||||
|
||||
@Test void asynchronousProjectionFailureHasARedactedDiagnosticAndReleasesPreparation() {
|
||||
try(var f=new Fixture()) {
|
||||
doThrow(new IllegalStateException("synthetic-private-detail")).when(f.plugin).getTopVoterDisplay(any());
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);assertDoesNotThrow(f::drain);
|
||||
verify(f.plugin.getLogger()).warning(argThat((String text)->text.contains("Discord")&&!text.contains("synthetic-private-detail")));
|
||||
assertEquals(0,f.handler.posts);
|
||||
doAnswer(call->f.filter.project(f.raw,true)).when(f.plugin).getTopVoterDisplay(any());
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);f.drain();assertEquals(1,f.handler.posts);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void freshAndExpiredUpdatesOnPresenceWorkerPublishOnlyCompletedProjection() {
|
||||
try (var fixture = new Fixture()) {
|
||||
fixture.worker.add(() -> fixture.handler.updateTopVoterMessageId(TopVoter.Monthly));
|
||||
fixture.worker.remove().run(); assertEquals(0, fixture.handler.posts);
|
||||
fixture.drain(); assertEquals(1, fixture.handler.posts);
|
||||
assertEquals(java.util.List.of(fixture.joined), java.util.List.copyOf(fixture.handler.published.keySet()));
|
||||
fixture.clock.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(31));
|
||||
fixture.worker.add(() -> fixture.handler.updateTopVoterMessageId(TopVoter.Monthly));
|
||||
fixture.worker.remove().run(); assertEquals(1, fixture.handler.posts);
|
||||
fixture.drain(); assertEquals(2, fixture.handler.posts);
|
||||
assertEquals(1, fixture.handler.published.size()); assertEquals(2, fixture.raw.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void repeatedUpdatesDuringPreparationCoalesceToOnePublish() {
|
||||
try (var fixture = new Fixture()) {
|
||||
for (int count = 0; count < 1000; count++) fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
assertEquals(2, fixture.worker.size()); assertEquals(0, fixture.handler.posts);
|
||||
fixture.drain(); assertEquals(1, fixture.handler.posts);
|
||||
verify(fixture.plugin, times(1)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reloadAndDisableDiscardOlderPreparedSnapshots() {
|
||||
try (var fixture = new Fixture()) {
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
fixture.generation.incrementAndGet(); fixture.drain(); assertEquals(0, fixture.handler.posts);
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
when(fixture.plugin.isEnabled()).thenReturn(false); fixture.drain(); assertEquals(0, fixture.handler.posts);
|
||||
when(fixture.plugin.isEnabled()).thenReturn(true);
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly); fixture.drain(); assertEquals(1, fixture.handler.posts);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void newerGenerationUpdateWhileOlderPreparationIsPendingIsNotLost() {
|
||||
try (var fixture = new Fixture()) {
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
fixture.generation.incrementAndGet();
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
fixture.drain(); assertEquals(1, fixture.handler.posts);
|
||||
assertEquals(1, fixture.handler.published.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacementGenerationWaitsForItsOwnEvidenceAfterOldSuccessOrFailure() {
|
||||
for (boolean queued : java.util.List.of(false, true))
|
||||
for (boolean failed : java.util.List.of(false, true)) try (var f = new Fixture()) {
|
||||
var older = new java.util.concurrent.CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
var newer = new java.util.concurrent.CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
var filtered = new LinkedHashMap<TopVoterPlayer, Integer>(); filtered.put(f.joined, 90);
|
||||
when(f.plugin.getTopVoterDisplayAsync(any())).thenReturn(older, newer);
|
||||
when(f.plugin.getTopVoterDisplay(any())).thenAnswer(call -> newer.isDone() ? filtered : f.raw);
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
Runnable settle = () -> {
|
||||
if (failed) older.completeExceptionally(new IllegalStateException("synthetic retired preparation"));
|
||||
else older.complete(filtered);
|
||||
};
|
||||
if (queued) settle.run();
|
||||
f.generation.incrementAndGet();
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
if (!queued) settle.run();
|
||||
f.drain();
|
||||
verify(f.plugin, times(2)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
assertEquals(0, f.handler.posts, "Retired evidence cannot publish the replacement's pending fallback");
|
||||
newer.complete(filtered); f.drain();
|
||||
assertEquals(1, f.handler.posts);
|
||||
assertEquals(filtered, f.handler.published);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedRetiredCompletionStillStartsTheExplicitReplacementPreparation() {
|
||||
try (var f = new Fixture()) {
|
||||
var older = new java.util.concurrent.CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
var newer = new java.util.concurrent.CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
when(f.plugin.getTopVoterDisplayAsync(any())).thenReturn(older, newer);
|
||||
var admissions = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var timer = f.plugin.getTimer();
|
||||
doAnswer(call -> {
|
||||
if (admissions.getAndIncrement() == 0) throw new java.util.concurrent.RejectedExecutionException("synthetic");
|
||||
f.worker.add(call.getArgument(0)); return null;
|
||||
}).when(timer).execute(any(Runnable.class));
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
f.generation.incrementAndGet(); f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
older.complete(f.raw);
|
||||
verify(f.plugin, times(2)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
assertEquals(0, f.handler.posts);
|
||||
newer.complete(f.raw); f.drain(); assertEquals(1, f.handler.posts);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacementPreparationFailureReleasesOwnershipForAnExplicitRetry() {
|
||||
try (var f = new Fixture()) {
|
||||
var older = new java.util.concurrent.CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
var retry = new java.util.concurrent.CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
when(f.plugin.getTopVoterDisplayAsync(any())).thenReturn(older)
|
||||
.thenThrow(new IllegalStateException("synthetic replacement admission"))
|
||||
.thenReturn(retry);
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
f.generation.incrementAndGet(); f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
older.complete(f.raw); f.drain(); assertEquals(0, f.handler.posts);
|
||||
verify(f.plugin, times(2)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
retry.complete(f.raw); f.drain(); assertEquals(1, f.handler.posts);
|
||||
verify(f.plugin, times(3)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedCompletionDispatchReleasesPreparationForNextUpdate() {
|
||||
try (var fixture = new Fixture()) {
|
||||
ScheduledExecutorService timer = fixture.plugin.getTimer();
|
||||
doThrow(new java.util.concurrent.RejectedExecutionException("synthetic")).when(timer).execute(any(Runnable.class));
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly); fixture.drain();
|
||||
assertEquals(0, fixture.handler.posts);
|
||||
doAnswer(call -> { fixture.worker.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class));
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly); fixture.drain();
|
||||
assertEquals(1, fixture.handler.posts);
|
||||
}
|
||||
}
|
||||
@Test void overlappingUpdatesPublishFirstRealDeadlineFallbackWithoutRestartingWait() throws Exception {
|
||||
try (var fixture = new Fixture()) {
|
||||
var dispatched = new java.util.concurrent.CompletableFuture<Runnable>();
|
||||
ScheduledExecutorService timer = fixture.plugin.getTimer();
|
||||
doAnswer(call -> { dispatched.complete(call.getArgument(0)); return null; })
|
||||
.when(timer).execute(any(Runnable.class));
|
||||
when(fixture.plugin.getTopVoterDisplay(any())).thenAnswer(call -> fixture.filter.project(fixture.raw, true));
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
for (int count = 0; count < 1000; count++) fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
// Leave the real presence worker queued: the production five-second preparation timeout fires.
|
||||
dispatched.get(7, java.util.concurrent.TimeUnit.SECONDS).run();
|
||||
assertEquals(1, fixture.handler.posts);
|
||||
assertEquals(fixture.raw, fixture.handler.published);
|
||||
verify(fixture.plugin, times(1)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
assertEquals(2, fixture.worker.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void coalescedPublicationUsesCurrentTotalsAndDiscardsSubsequentUnrequestedReload() {
|
||||
try (var fixture = new Fixture()) {
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
fixture.raw.put(fixture.joined, 123);
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
fixture.drain(); assertEquals(1, fixture.handler.posts);
|
||||
assertEquals(123, fixture.handler.published.get(fixture.joined));
|
||||
fixture.clock.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(31));
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
fixture.generation.incrementAndGet(); fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
fixture.generation.incrementAndGet(); fixture.drain();
|
||||
assertEquals(1, fixture.handler.posts, "An unrequested later generation must discard coalesced stale work");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void joinAfterPreparationBeforeQueuedPublicationRevokesOldOmission() {
|
||||
try (var fixture = new Fixture()) {
|
||||
fixture.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
fixture.worker.remove().run(); fixture.worker.remove().run();
|
||||
assertEquals(0, fixture.handler.posts);
|
||||
fixture.filter.joined(fixture.never.getUuid(), fixture.never.getPlayerName());
|
||||
fixture.worker.remove().run();
|
||||
assertEquals(1, fixture.handler.posts);
|
||||
assertTrue(fixture.handler.published.containsKey(fixture.never),
|
||||
"Publication must revalidate the omitted row after a join");
|
||||
verify(fixture.plugin, times(1)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+982
@@ -0,0 +1,982 @@
|
||||
package com.bencodez.votingplugin.tests.discord;
|
||||
|
||||
import com.bencodez.votingplugin.discord.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.topvoter.*;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.entities.*;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.requests.restaction.MessageAction;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.requests.restaction.AuditableRestAction;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.requests.ErrorResponse;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.exceptions.ErrorResponseException;
|
||||
|
||||
class DiscordLeaderboardRecoveryTest {
|
||||
@Test void ordinaryEditsCoalesceUntilSuccessAndPublishTheLatestFrameOnce() {
|
||||
try (var f = new Fixture()) {
|
||||
f.publish();
|
||||
var firstSuccess = f.editSuccess;
|
||||
f.board.clear(); f.board.put(f.first, 100);
|
||||
f.publish(); f.publish();
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
firstSuccess.accept(mock(Message.class)); f.drain();
|
||||
var frames = org.mockito.ArgumentCaptor.forClass(MessageEmbed.class);
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), frames.capture());
|
||||
assertTrue(frames.getAllValues().get(0).getDescription().contains("SyntheticSecond"));
|
||||
assertTrue(frames.getAllValues().get(1).getDescription().contains("SyntheticFirst"));
|
||||
f.editSuccess.accept(mock(Message.class)); f.drain();
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.channel, never()).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryEditFailurePublishesOnlyAnExplicitCoalescedRefresh() {
|
||||
try (var f = new Fixture()) {
|
||||
f.publish(); var firstFailure = f.editFailure;
|
||||
f.board.clear(); f.board.put(f.first, 100); f.publish();
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
firstFailure.accept(new IllegalStateException("synthetic edit failure")); f.drain();
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
f.editFailure.accept(new IllegalStateException("synthetic repeated failure")); f.drain();
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.channel, never()).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void newerGenerationsWaitForAnOlderEditThenPrepareTheirOwnEvidence() {
|
||||
for (int retirement : List.of(0, 1, 2)) for (boolean failure : List.of(false, true))
|
||||
try (var f = new Fixture()) {
|
||||
f.publish(); var olderSuccess = f.editSuccess; var olderFailure = f.editFailure;
|
||||
f.generation.incrementAndGet();
|
||||
if (retirement == 1) f.handler.retireLeaderboardPublications();
|
||||
f.board.clear(); f.board.put(f.first, 100);
|
||||
var prepared = new CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
when(f.plugin.getTopVoterDisplayAsync(TopVoter.Monthly)).thenReturn(prepared);
|
||||
f.publish();
|
||||
if (retirement == 2) f.handler.retireLeaderboardPublications();
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
if (failure) olderFailure.accept(new IllegalStateException("synthetic retired edit"));
|
||||
else olderSuccess.accept(mock(Message.class));
|
||||
f.drain();
|
||||
verify(f.plugin, times(1)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
prepared.complete(new LinkedHashMap<>(f.board)); f.drain();
|
||||
var frames = org.mockito.ArgumentCaptor.forClass(MessageEmbed.class);
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), frames.capture());
|
||||
assertTrue(frames.getAllValues().get(1).getDescription().contains("SyntheticFirst"));
|
||||
f.editSuccess.accept(mock(Message.class)); f.drain();
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.channel, never()).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
verify(f.data, never()).setTopVoterMessageId(any(), anyLong());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retirementPreservesRequestsInTheStillCurrentGeneration() {
|
||||
try (var f = new Fixture()) {
|
||||
f.publish(); var success = f.editSuccess; f.publish();
|
||||
f.handler.retireLeaderboardPublications();
|
||||
success.accept(mock(Message.class)); f.drain();
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.plugin, never()).getTopVoterDisplayAsync(any());
|
||||
verify(f.data, never()).setTopVoterMessageId(any(), anyLong());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retirementDiscardsAQueuedGenerationThatHasAlreadyBeenSuperseded() {
|
||||
try (var f = new Fixture()) {
|
||||
f.publish(); var success = f.editSuccess;
|
||||
f.generation.incrementAndGet(); f.publish();
|
||||
f.generation.incrementAndGet(); f.handler.retireLeaderboardPublications();
|
||||
success.accept(mock(Message.class)); f.drain();
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.plugin, never()).getTopVoterDisplayAsync(any());
|
||||
verify(f.data, never()).setTopVoterMessageId(any(), anyLong());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retirementBetweenCallbackAdmissionAndFinishPreservesTheReplacementRequest() {
|
||||
for (boolean failure : List.of(false, true)) try (var f = new Fixture()) {
|
||||
f.publish(); var success = f.editSuccess; var error = f.editFailure;
|
||||
f.generation.incrementAndGet(); f.publish();
|
||||
var prepared = new CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
when(f.plugin.getTopVoterDisplayAsync(TopVoter.Monthly)).thenReturn(prepared);
|
||||
var retireOnCallback = new AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
if (retireOnCallback.getAndSet(false)) f.handler.retireLeaderboardPublications();
|
||||
return true;
|
||||
}).when(f.plugin).isEnabled();
|
||||
if (failure) error.accept(new IllegalStateException("synthetic callback failure"));
|
||||
else success.accept(mock(Message.class));
|
||||
f.drain();
|
||||
assertFalse(retireOnCallback.get());
|
||||
verify(f.plugin, times(1)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
prepared.complete(new LinkedHashMap<>(f.board)); f.drain();
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retirementAfterCallbackFinishPreservesPendingReplacementPreparation() {
|
||||
for (boolean completed : List.of(false, true)) try (var f = new Fixture()) {
|
||||
f.publish(); var success = f.editSuccess;
|
||||
f.generation.incrementAndGet(); f.publish();
|
||||
var prepared = new CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
when(f.plugin.getTopVoterDisplayAsync(TopVoter.Monthly)).thenReturn(prepared);
|
||||
success.accept(mock(Message.class));
|
||||
verify(f.plugin, times(1)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
if (completed) prepared.complete(new LinkedHashMap<>(f.board));
|
||||
f.handler.retireLeaderboardPublications();
|
||||
if (!completed) prepared.complete(new LinkedHashMap<>(f.board));
|
||||
f.drain();
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retirementPreservesAReplacementEditAndItsCoalescedCurrentRefresh() {
|
||||
try (var f = new Fixture()) {
|
||||
f.publish(); var success = f.editSuccess;
|
||||
f.generation.incrementAndGet(); f.publish();
|
||||
when(f.plugin.getTopVoterDisplayAsync(TopVoter.Monthly))
|
||||
.thenAnswer(call -> CompletableFuture.completedFuture(new LinkedHashMap<>(f.board)));
|
||||
success.accept(mock(Message.class)); f.drain();
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
var replacement = f.editSuccess;
|
||||
f.board.clear(); f.board.put(f.first, 100); f.publish();
|
||||
f.handler.retireLeaderboardPublications();
|
||||
replacement.accept(mock(Message.class)); f.drain();
|
||||
verify(f.channel, times(3)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.plugin, times(1)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
verify(f.data, never()).setTopVoterMessageId(any(), anyLong());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retirementPreservesCurrentSuccessfulSendsAwaitingTheirWorker() {
|
||||
for (boolean append : List.of(false, true)) try (var f = new Fixture()) {
|
||||
f.generation.incrementAndGet();
|
||||
f.handler.getTopVoterMessageIds().put(TopVoter.Monthly, 0L);
|
||||
when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(append);
|
||||
DiscordHandlerAccess.publish(f.handler, TopVoter.Monthly, f.board);
|
||||
var created = new CreatedMessage(13L); f.sendSuccess.accept(created.message);
|
||||
f.handler.retireLeaderboardPublications();
|
||||
verify(created.message, never()).delete();
|
||||
f.drain();
|
||||
assertEquals(13L, f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
if (append) verify(f.data, never()).setTopVoterMessageId(any(), anyLong());
|
||||
else assertEquals(13L, f.storedId.get());
|
||||
verify(created.message, never()).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retirementPreservesCurrentPreparationWithoutAnOlderPublicationSlot() {
|
||||
try (var f = new Fixture()) {
|
||||
f.generation.incrementAndGet();
|
||||
var prepared = new CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
when(f.plugin.getTopVoterDisplayAsync(TopVoter.Monthly)).thenReturn(prepared);
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
f.handler.retireLeaderboardPublications();
|
||||
prepared.complete(new LinkedHashMap<>(f.board)); f.drain();
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retirementRestartsQueuedReplacementBeforeCancellingTheOlderPreparationWorker() {
|
||||
for (boolean alreadyQueued : List.of(false, true)) try (var f = new Fixture()) {
|
||||
var older = new CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
var newer = new CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
when(f.plugin.getTopVoterDisplayAsync(TopVoter.Monthly)).thenReturn(older, newer);
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
if (alreadyQueued) older.complete(new LinkedHashMap<>(f.board));
|
||||
f.generation.incrementAndGet(); f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
f.handler.retireLeaderboardPublications();
|
||||
verify(f.plugin, times(2)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
if (!alreadyQueued) older.completeExceptionally(new IllegalStateException("synthetic retired preparation"));
|
||||
f.worker.clear(); // Retirement can cancel an accepted older completion.
|
||||
newer.complete(new LinkedHashMap<>(f.board)); f.drain();
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.plugin, times(2)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedRetiredPreparationCannotDuplicateItsAlreadyRestartedReplacement() {
|
||||
try (var f = new Fixture()) {
|
||||
var older = new CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
var newer = new CompletableFuture<LinkedHashMap<TopVoterPlayer, Integer>>();
|
||||
when(f.plugin.getTopVoterDisplayAsync(TopVoter.Monthly)).thenReturn(older, newer);
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
f.generation.incrementAndGet(); f.handler.updateTopVoterMessageId(TopVoter.Monthly);
|
||||
f.handler.retireLeaderboardPublications();
|
||||
verify(f.plugin, times(2)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
var timer = f.plugin.getTimer();
|
||||
doThrow(new RejectedExecutionException("synthetic retired worker")).when(timer).execute(any(Runnable.class));
|
||||
older.complete(new LinkedHashMap<>(f.board));
|
||||
doAnswer(call -> { f.worker.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class));
|
||||
newer.complete(new LinkedHashMap<>(f.board)); f.drain();
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.plugin, times(2)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void clearingAnIdAllowsANewPostWithoutReusingTheOlderEditTarget() {
|
||||
try (var f = new Fixture()) {
|
||||
f.publish(); var olderSuccess = f.editSuccess; var olderFailure = f.editFailure;
|
||||
assertTrue(f.handler.clearTopVoterMessageId(TopVoter.Monthly));
|
||||
DiscordHandlerAccess.publish(f.handler, TopVoter.Monthly, f.board);
|
||||
var created = new CreatedMessage(13L); f.sendSuccess.accept(created.message); f.drain();
|
||||
olderSuccess.accept(mock(Message.class));
|
||||
olderFailure.accept(new IllegalStateException("synthetic obsolete callback")); f.drain();
|
||||
assertEquals(13L, f.storedId.get());
|
||||
assertEquals(13L, f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.channel, times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
verify(created.message, never()).delete();
|
||||
}
|
||||
}
|
||||
|
||||
static final class Fixture implements AutoCloseable {
|
||||
final VotingPluginMain plugin=mock(VotingPluginMain.class);
|
||||
final Logger logger=mock(Logger.class);
|
||||
final Config config=mock(Config.class);final ServerData data=mock(ServerData.class);
|
||||
final TextChannel channel=mock(TextChannel.class);
|
||||
final MessageAction edit=mock(MessageAction.class),send=mock(MessageAction.class);
|
||||
final AtomicLong generation=new AtomicLong(), storedId=new AtomicLong(9L);
|
||||
final ArrayDeque<Runnable> worker=new ArrayDeque<>();
|
||||
final LinkedHashMap<TopVoterPlayer,Integer> board=new LinkedHashMap<>();
|
||||
final TopVoterPlayer first=new TopVoterPlayer(new UUID(0,1),"SyntheticFirst",1L);
|
||||
final TopVoterPlayer second=new TopVoterPlayer(new UUID(0,2),"SyntheticSecond",1L);
|
||||
Consumer<Throwable> editFailure,sendFailure;Consumer<Message> sendSuccess,editSuccess;MessageEmbed sent;
|
||||
Runnable beforeChannelLookup = () -> { };
|
||||
final DiscordHandler handler;
|
||||
Fixture() {
|
||||
when(plugin.getConfigFile()).thenReturn(config);when(plugin.getServerData()).thenReturn(data);
|
||||
when(plugin.getLogger()).thenReturn(logger);when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getTopVoterDisplayGeneration()).thenAnswer(call->generation.get());
|
||||
when(plugin.getTopVoterDisplay(any())).thenAnswer(call->new LinkedHashMap<>(board));
|
||||
when(config.getDiscordSRVTopVoterChannel(any())).thenReturn(1L);
|
||||
when(config.getDiscordSRVTopVoterTitle(any())).thenReturn("Synthetic board");
|
||||
when(config.isDiscordSRVTopVoterAutoRecoverMessageOnFailure()).thenReturn(true);
|
||||
when(data.getTopVoterMessageId(any())).thenAnswer(call->storedId.get());
|
||||
doAnswer(call->{storedId.set(call.getArgument(1));return null;})
|
||||
.when(data).setTopVoterMessageId(any(),anyLong());
|
||||
doAnswer(call->{data.setTopVoterMessageId(call.getArgument(0),call.getArgument(1));return true;})
|
||||
.when(data).setTopVoterMessageIdConfirmed(any(),anyLong());
|
||||
var timer=mock(ScheduledExecutorService.class);when(plugin.getTimer()).thenReturn(timer);
|
||||
doAnswer(call->{worker.add(call.getArgument(0));return null;}).when(timer).execute(any(Runnable.class));
|
||||
when(channel.editMessageEmbedsById(anyLong(),any(MessageEmbed.class))).thenReturn(edit);
|
||||
when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenAnswer(call->{sent=call.getArgument(0);return send;});
|
||||
doAnswer(call->{editSuccess=call.getArgument(0);editFailure=call.getArgument(1);return null;}).when(edit).queue(any(),any());
|
||||
doAnswer(call->{sendSuccess=call.getArgument(0);sendFailure=call.getArgument(1);return null;}).when(send).queue(any(),any());
|
||||
board.put(second,90);handler=spy(new DiscordHandler(plugin));
|
||||
DiscordHandlerAccess.channel(handler, channelId -> {
|
||||
assertEquals(1L,channelId);
|
||||
var hook = beforeChannelLookup; beforeChannelLookup = () -> { }; hook.run();
|
||||
return channel;
|
||||
});
|
||||
}
|
||||
void publish() {DiscordHandlerAccess.publish(handler,TopVoter.Monthly,new LinkedHashMap<>(board));assertNotNull(editFailure);}
|
||||
void unknown() {var error=mock(ErrorResponseException.class);when(error.getErrorResponse()).thenReturn(ErrorResponse.UNKNOWN_MESSAGE);editFailure.accept(error);}
|
||||
void drain() {int bound=20;while(!worker.isEmpty()){assertTrue(bound-->0);worker.remove().run();}}
|
||||
public void close(){}
|
||||
}
|
||||
|
||||
private static final class CreatedMessage {
|
||||
final Message message=mock(Message.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
final AuditableRestAction<Void> deletion=mock(AuditableRestAction.class);
|
||||
CreatedMessage(long id) {
|
||||
when(message.getIdLong()).thenReturn(id);
|
||||
when(message.delete()).thenReturn(deletion);
|
||||
}
|
||||
void assertDeletionQueued() {
|
||||
verify(message,times(1)).delete();
|
||||
verify(deletion,times(1)).queue(any(),any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void clearedIdDoesNotDeleteIntentionallyDetachedPost() {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);var first=new CreatedMessage(12L);
|
||||
f.sendSuccess.accept(first.message);f.drain();
|
||||
// Existing ClearDiscordMessageID command semantics: detach, leave the post intact.
|
||||
assertTrue(f.handler.clearTopVoterMessageId(TopVoter.Monthly));
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var next=new CreatedMessage(13L);f.sendSuccess.accept(next.message);f.drain();
|
||||
assertEquals(13L,f.storedId.get());verify(first.message,never()).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void clearDetachesSuccessfulPendingSendsBeforeCompletionOrCancellation() {
|
||||
for(boolean recovery:List.of(false,true))for(int retirement=0;retirement<3;retirement++)try(var f=new Fixture()) {
|
||||
startPersistentSend(f,recovery);var created=new CreatedMessage(12L);var success=f.sendSuccess;
|
||||
// The clear command was queued first; the send succeeds before either worker action executes.
|
||||
f.worker.add(()->assertTrue(f.handler.clearTopVoterMessageId(TopVoter.Monthly)));
|
||||
success.accept(created.message);assertEquals(2,f.worker.size());f.worker.remove().run();
|
||||
assertEquals(0L,f.storedId.get());
|
||||
if(retirement==0)f.drain();
|
||||
else {
|
||||
if(retirement==1){f.generation.incrementAndGet();f.handler.retireLeaderboardPublications();}
|
||||
else f.handler.close();
|
||||
f.worker.clear();
|
||||
}
|
||||
success.accept(created.message);verify(created.message,never()).delete();
|
||||
verify(f.data,never()).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
if(retirement!=2) {
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var next=new CreatedMessage(13L);f.sendSuccess.accept(next.message);f.drain();
|
||||
assertEquals(13L,f.storedId.get());verify(created.message,never()).delete();
|
||||
verify(next.message,never()).delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedClearDoesNotDeleteTheAlreadySuccessfulPendingPost() {
|
||||
for(boolean recovery:List.of(false,true))try(var f=new Fixture()) {
|
||||
startPersistentSend(f,recovery);var created=new CreatedMessage(12L);f.sendSuccess.accept(created.message);
|
||||
doReturn(false).when(f.data).setTopVoterMessageIdConfirmed(TopVoter.Monthly,0L);
|
||||
assertFalse(f.handler.clearTopVoterMessageId(TopVoter.Monthly));f.drain();
|
||||
verify(created.message,never()).delete();
|
||||
verify(f.data,never()).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
verify(f.logger).warning(contains("storage"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void clearingOneRankingDetachesOnlyItsPendingSuccessfulPosts() {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);var monthly=new CreatedMessage(12L);f.sendSuccess.accept(monthly.message);
|
||||
f.handler.getTopVoterMessageIds().put(TopVoter.Weekly,0L);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Weekly,f.board);
|
||||
var weekly=new CreatedMessage(13L);f.sendSuccess.accept(weekly.message);
|
||||
assertTrue(f.handler.clearTopVoterMessageId(TopVoter.Monthly));
|
||||
f.handler.close();f.worker.clear();
|
||||
verify(monthly.message,never()).delete();weekly.assertDeletionQueued();
|
||||
verify(f.data,never()).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
verify(f.data,never()).setTopVoterMessageId(TopVoter.Weekly,13L);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void successReportedOnlyAfterClearIsStillReclaimedAsAnObsoletePublication() {
|
||||
for(boolean recovery:List.of(false,true))try(var f=new Fixture()) {
|
||||
startPersistentSend(f,recovery);var success=f.sendSuccess;
|
||||
assertTrue(f.handler.clearTopVoterMessageId(TopVoter.Monthly));
|
||||
var created=new CreatedMessage(12L);success.accept(created.message);f.drain();
|
||||
created.assertDeletionQueued();
|
||||
verify(f.data,never()).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
assertEquals(0L,f.storedId.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void swallowedRealFileSaveFailureRequiresStorageReconciliation(
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) throws Exception {
|
||||
try(var f=new Fixture()) {
|
||||
var file=mock(com.bencodez.advancedcore.data.ServerData.class,CALLS_REAL_METHODS);
|
||||
var yaml=new org.bukkit.configuration.file.YamlConfiguration();
|
||||
var dataField=com.bencodez.simpleapi.file.YMLFile.class.getDeclaredField("data");
|
||||
dataField.setAccessible(true);dataField.set(file,yaml);
|
||||
var pathField=com.bencodez.simpleapi.file.YMLFile.class.getDeclaredField("dFile");
|
||||
pathField.setAccessible(true);pathField.set(file,directory.toFile());
|
||||
when(f.plugin.getServerDataFile()).thenReturn(file);
|
||||
var serverData = new ServerData(f.plugin);
|
||||
when(f.plugin.getServerData()).thenReturn(serverData);
|
||||
startPersistentSend(f,false);var created=new CreatedMessage(12L);
|
||||
f.sendSuccess.accept(created.message);f.drain();
|
||||
assertEquals(12L,yaml.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId.Monthly"));
|
||||
assertTrue(java.nio.file.Files.isDirectory(directory));
|
||||
verify(f.logger).warning(argThat((String text)->text.contains("storage")
|
||||
&&!text.contains(directory.toString())));
|
||||
verify(created.message,never()).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledAcceptedCompletionsAreReconciledBeforeWorkerTeardown() {
|
||||
for(boolean recovery:List.of(false,true))for(boolean shutdown:List.of(false,true))try(var f=new Fixture()) {
|
||||
startPersistentSend(f,recovery);var created=new CreatedMessage(12L);
|
||||
f.sendSuccess.accept(created.message);assertFalse(f.worker.isEmpty());
|
||||
if(shutdown)f.handler.close();else {f.generation.incrementAndGet();f.handler.retireLeaderboardPublications();}
|
||||
f.worker.clear(); // The executor accepted the task, then discarded it without invoking the closure.
|
||||
created.assertDeletionQueued();verify(f.data,never()).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
if(shutdown){f.handler.close();f.sendSuccess.accept(created.message);created.assertDeletionQueued();}
|
||||
else {
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var next=new CreatedMessage(13L);f.sendSuccess.accept(next.message);f.drain();
|
||||
assertEquals(13L,f.storedId.get());verify(next.message,never()).delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void shutdownDoesNotDeleteRecordedOrIntentionallyAppendedPosts() {
|
||||
for(boolean append:List.of(false,true))try(var f=new Fixture()) {
|
||||
if(append)when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(true);
|
||||
startPersistentSend(f,false);var created=new CreatedMessage(12L);
|
||||
f.sendSuccess.accept(created.message);if(!append)f.drain();f.handler.close();f.worker.clear();
|
||||
verify(created.message,never()).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void shutdownDuringAStartedWriteKeepsItsConfirmedReceipt() {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);var created=new CreatedMessage(12L);
|
||||
doAnswer(call->{f.handler.close();f.storedId.set(12L);return null;})
|
||||
.when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
f.sendSuccess.accept(created.message);f.drain();
|
||||
assertEquals(12L,f.storedId.get());verify(created.message,never()).delete();
|
||||
verify(f.logger).warning(contains("storage"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void lateSuccessAfterShutdownAndRejectedCleanupRemainObservable() {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);var created=new CreatedMessage(12L);f.handler.close();
|
||||
when(created.message.delete()).thenThrow(new IllegalStateException("synthetic-provider-content"));
|
||||
f.sendSuccess.accept(created.message);f.drain();
|
||||
verify(f.logger).warning(argThat((String text)->text.contains("untracked")&&!text.contains("synthetic-provider-content")));
|
||||
verify(f.data,never()).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void uncertainSavesKeepAllReceiptsUntilAConfirmedReplacement() {
|
||||
try(var f=new Fixture()) {
|
||||
doAnswer(call->{f.data.setTopVoterMessageId(call.getArgument(0),call.getArgument(1));return false;})
|
||||
.when(f.data).setTopVoterMessageIdConfirmed(any(),anyLong());
|
||||
startPersistentSend(f,false);var first=new CreatedMessage(12L);f.sendSuccess.accept(first.message);f.drain();
|
||||
f.generation.incrementAndGet();f.handler.getTopVoterMessageIds().put(TopVoter.Monthly,0L);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var second=new CreatedMessage(13L);f.sendSuccess.accept(second.message);f.drain();
|
||||
verify(first.message,never()).delete();verify(second.message,never()).delete();
|
||||
doAnswer(call->{f.data.setTopVoterMessageId(call.getArgument(0),call.getArgument(1));return true;})
|
||||
.when(f.data).setTopVoterMessageIdConfirmed(any(),anyLong());
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);f.editSuccess.accept(second.message);f.drain();
|
||||
first.assertDeletionQueued();verify(second.message,never()).delete();assertEquals(13L,f.storedId.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void uncertainReceiptCapacityRefusesAnotherSaveAndReclaimsOnlyTheNewPost() {
|
||||
try(var f=new Fixture()) {
|
||||
doAnswer(call->{f.data.setTopVoterMessageId(call.getArgument(0),call.getArgument(1));return false;})
|
||||
.when(f.data).setTopVoterMessageIdConfirmed(any(),anyLong());
|
||||
var posts=new ArrayList<CreatedMessage>();
|
||||
for(int index=0;index<129;index++) {
|
||||
f.generation.incrementAndGet();f.handler.getTopVoterMessageIds().put(TopVoter.Monthly,0L);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var created=new CreatedMessage(1000L+index);posts.add(created);f.sendSuccess.accept(created.message);f.drain();
|
||||
}
|
||||
for(int index=0;index<128;index++)verify(posts.get(index).message,never()).delete();
|
||||
posts.get(128).assertDeletionQueued();assertEquals(1127L,f.storedId.get());
|
||||
verify(f.data,never()).setTopVoterMessageId(TopVoter.Monthly,1128L);
|
||||
assertFalse(f.handler.clearTopVoterMessageId(TopVoter.Monthly));
|
||||
for(int index=0;index<128;index++)verify(posts.get(index).message,never()).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void clearDuringAReentrantStartedWriteKeepsItsPostDetached() {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);var created=new CreatedMessage(12L);
|
||||
doAnswer(call->{f.storedId.set(12L);assertTrue(f.handler.clearTopVoterMessageId(TopVoter.Monthly));return null;})
|
||||
.when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
f.sendSuccess.accept(created.message);f.drain();
|
||||
assertEquals(0L,f.storedId.get());assertEquals(0L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var next=new CreatedMessage(13L);f.sendSuccess.accept(next.message);f.drain();
|
||||
verify(created.message,never()).delete();assertEquals(13L,f.storedId.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void anUnconfirmedNewerWriteKeepsBothReceiptsUntilConfirmation() {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);var first=new CreatedMessage(12L);var next=new CreatedMessage(13L);
|
||||
doAnswer(call->{f.data.setTopVoterMessageId(call.getArgument(0),call.getArgument(1));return false;})
|
||||
.when(f.data).setTopVoterMessageIdConfirmed(TopVoter.Monthly,13L);
|
||||
doAnswer(call->{
|
||||
f.storedId.set(12L);f.generation.incrementAndGet();
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
f.sendSuccess.accept(next.message);f.drain();return null;
|
||||
}).when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
f.sendSuccess.accept(first.message);f.drain();
|
||||
verify(first.message,never()).delete();verify(next.message,never()).delete();
|
||||
doAnswer(call->{f.data.setTopVoterMessageId(call.getArgument(0),call.getArgument(1));return true;})
|
||||
.when(f.data).setTopVoterMessageIdConfirmed(TopVoter.Monthly,13L);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);f.editSuccess.accept(next.message);f.drain();
|
||||
first.assertDeletionQueued();verify(next.message,never()).delete();assertEquals(13L,f.storedId.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void shutdownDoesNotWaitForAStartedWriteHoldingTheSaveMonitor() throws Exception {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);var created=new CreatedMessage(12L);
|
||||
var entered=new CountDownLatch(1);var release=new CountDownLatch(1);var error=new AtomicReference<Throwable>();
|
||||
doAnswer(call->{entered.countDown();assertTrue(release.await(5,TimeUnit.SECONDS));f.storedId.set(12L);return null;})
|
||||
.when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
f.sendSuccess.accept(created.message);var action=f.worker.remove();
|
||||
Thread worker=new Thread(()->{try{action.run();}catch(Throwable failure){error.set(failure);}});
|
||||
worker.start();
|
||||
try {
|
||||
assertTrue(entered.await(5,TimeUnit.SECONDS));
|
||||
assertTimeoutPreemptively(java.time.Duration.ofSeconds(1),f.handler::close);
|
||||
verify(created.message,never()).delete();
|
||||
} finally {release.countDown();worker.join(5000);}
|
||||
assertFalse(worker.isAlive());assertNull(error.get());assertEquals(12L,f.storedId.get());
|
||||
verify(created.message,never()).delete();verify(f.logger).warning(contains("storage"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void startPersistentSend(Fixture f,boolean recovery) {
|
||||
if(recovery){f.publish();f.unknown();f.drain();}
|
||||
else {
|
||||
f.handler.getTopVoterMessageIds().put(TopVoter.Monthly,0L);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
}
|
||||
assertNotNull(f.sendSuccess);
|
||||
}
|
||||
|
||||
@Test void discardedPersistentSuccessReclaimsItsCreatedMessageAcrossAdmissionAndRetirement() {
|
||||
for(boolean recovery:List.of(false,true))for(int timing=0;timing<4;timing++)try(var f=new Fixture()) {
|
||||
startPersistentSend(f,recovery);
|
||||
var created=new CreatedMessage(12L);
|
||||
var success=f.sendSuccess;
|
||||
if(timing==0)f.generation.incrementAndGet();
|
||||
var timer=f.plugin.getTimer();
|
||||
if(timing==2)doThrow(new RejectedExecutionException("synthetic rejected completion"))
|
||||
.when(timer).execute(any(Runnable.class));
|
||||
if(timing==3)when(f.plugin.isEnabled()).thenReturn(false);
|
||||
success.accept(created.message);
|
||||
if(timing==1)f.generation.incrementAndGet();
|
||||
f.drain();success.accept(created.message);f.drain();
|
||||
created.assertDeletionQueued();
|
||||
verify(f.data,never()).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retiredSuccessCleansOnlyItsOwnMessageAndNeverTheSuccessors() {
|
||||
for(boolean recovery:List.of(false,true))try(var f=new Fixture()) {
|
||||
startPersistentSend(f,recovery);var old=f.sendSuccess;
|
||||
f.generation.incrementAndGet();
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var successor=new CreatedMessage(13L);f.sendSuccess.accept(successor.message);f.drain();
|
||||
var retired=new CreatedMessage(12L);old.accept(retired.message);f.drain();
|
||||
assertEquals(13L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
assertEquals(13L,f.storedId.get());retired.assertDeletionQueued();
|
||||
verify(successor.message,never()).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void successfulPersistenceDuringRetirementRetainsTheCreatedIdForLaterUpdates() {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);
|
||||
doAnswer(call->{f.storedId.set(12L);f.generation.incrementAndGet();return null;})
|
||||
.when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
var created=new CreatedMessage(12L);f.sendSuccess.accept(created.message);f.drain();
|
||||
assertEquals(12L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
assertEquals(12L,f.storedId.get());verify(created.message,never()).delete();
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
verify(f.channel).editMessageEmbedsById(eq(12L),any(MessageEmbed.class));
|
||||
verify(f.channel,times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void overlappingSuccessorSendRetainsTheFirstReceiptUntilItCanBeReplaced() {
|
||||
for(boolean successorSucceeds:List.of(false,true))try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);
|
||||
doAnswer(call->{
|
||||
f.storedId.set(12L);f.generation.incrementAndGet();
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
return null;
|
||||
}).when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
var first=new CreatedMessage(12L);f.sendSuccess.accept(first.message);f.drain();
|
||||
assertEquals(12L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
if(successorSucceeds) {
|
||||
var next=new CreatedMessage(13L);f.sendSuccess.accept(next.message);f.drain();
|
||||
assertEquals(13L,f.storedId.get());first.assertDeletionQueued();
|
||||
verify(next.message,never()).delete();
|
||||
} else {
|
||||
f.sendFailure.accept(new IllegalStateException("synthetic successor failure"));f.drain();
|
||||
assertEquals(12L,f.storedId.get());verify(first.message,never()).delete();
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
verify(f.channel).editMessageEmbedsById(eq(12L),any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void idPersistenceFailureRetainsObservableOwnershipAndAcceptedDuplicatesDoNotDelete() {
|
||||
for(boolean failed:List.of(false,true))try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);
|
||||
if(failed)doThrow(new IllegalStateException("synthetic failed ID write"))
|
||||
.when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
var created=new CreatedMessage(12L);var success=f.sendSuccess;
|
||||
success.accept(created.message);f.drain();success.accept(created.message);f.drain();
|
||||
verify(created.message,never()).delete();
|
||||
if(failed)verify(f.logger).warning(contains("storage"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cleanupSubmissionAndNetworkFailuresAreObservableWithoutPrivateProviderDetails() {
|
||||
for(boolean synchronous:List.of(false,true))try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);f.generation.incrementAndGet();var created=new CreatedMessage(12L);
|
||||
if(synchronous)when(created.message.delete()).thenThrow(new IllegalStateException("synthetic-private-provider-detail"));
|
||||
else doAnswer(call->{
|
||||
Consumer<Throwable> failed=call.getArgument(1);
|
||||
failed.accept(new IllegalStateException("synthetic-private-provider-detail"));return null;
|
||||
}).when(created.deletion).queue(any(),any());
|
||||
assertDoesNotThrow(()->f.sendSuccess.accept(created.message));f.drain();
|
||||
verify(f.logger).warning(argThat((String text)->text.contains("untracked")
|
||||
&&!text.contains("synthetic-private-provider-detail")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void intentionalAppendModeDoesNotDeleteRetiredPosts() {
|
||||
try(var f=new Fixture()) {
|
||||
when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(true);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
f.generation.incrementAndGet();var created=new CreatedMessage(12L);
|
||||
f.sendSuccess.accept(created.message);f.drain();verify(created.message,never()).delete();
|
||||
verify(f.data,never()).setTopVoterMessageId(any(),anyLong());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aNewerIdRecordedReentrantlyCannotBeOverwrittenByTheOlderCompletion() {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);var first=new CreatedMessage(12L);var next=new CreatedMessage(13L);
|
||||
doAnswer(call->{
|
||||
f.storedId.set(12L);f.generation.incrementAndGet();
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
f.sendSuccess.accept(next.message);f.drain();return null;
|
||||
}).when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
f.sendSuccess.accept(first.message);f.drain();
|
||||
assertEquals(13L,f.storedId.get());
|
||||
assertEquals(13L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
first.assertDeletionQueued();verify(next.message,never()).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cleanupOfAnAlreadyDeletedMessageIsAnIdempotentSuccess() {
|
||||
try(var f=new Fixture()) {
|
||||
startPersistentSend(f,false);f.generation.incrementAndGet();var created=new CreatedMessage(12L);
|
||||
var missing=mock(ErrorResponseException.class);when(missing.getErrorResponse()).thenReturn(ErrorResponse.UNKNOWN_MESSAGE);
|
||||
doAnswer(call->{Consumer<Throwable> failed=call.getArgument(1);failed.accept(missing);return null;})
|
||||
.when(created.deletion).queue(any(),any());
|
||||
f.sendSuccess.accept(created.message);f.drain();created.assertDeletionQueued();
|
||||
verifyNoInteractions(f.logger);
|
||||
}
|
||||
}
|
||||
@Test void reloadDuringFinalProjectionCannotPromoteAnOlderPreparedPublication() {
|
||||
try (var f = new Fixture()) {
|
||||
when(f.plugin.getTopVoterDisplayAsync(any())).thenReturn(CompletableFuture.completedFuture(f.board));
|
||||
var reload = new AtomicBoolean(true);
|
||||
when(f.plugin.getTopVoterDisplay(any())).thenAnswer(call -> {
|
||||
if (reload.getAndSet(false)) f.generation.incrementAndGet();
|
||||
return new LinkedHashMap<>(f.board);
|
||||
});
|
||||
f.handler.updateTopVoterMessageId(TopVoter.Monthly); f.drain();
|
||||
verifyNoInteractions(f.channel);
|
||||
verify(f.data, never()).setTopVoterMessageId(any(), anyLong());
|
||||
}
|
||||
}
|
||||
@Test void reloadDuringCoalescedProjectionCannotPromoteAnOlderNetworkRequest() {
|
||||
try (var f = new Fixture()) {
|
||||
when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(true);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly, f.board);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly, f.board);
|
||||
var reload = new AtomicBoolean(true);
|
||||
when(f.plugin.getTopVoterDisplay(any())).thenAnswer(call -> {
|
||||
if (reload.getAndSet(false)) f.generation.incrementAndGet();
|
||||
return new LinkedHashMap<>(f.board);
|
||||
});
|
||||
f.sendFailure.accept(new IllegalStateException("synthetic transient failure")); f.drain();
|
||||
verify(f.channel, times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
verify(f.data, never()).setTopVoterMessageId(any(), anyLong());
|
||||
}
|
||||
}
|
||||
@Test void olderChannelLookupCannotReplaceANewerPublicationOwner() {
|
||||
try (var f = new Fixture()) {
|
||||
f.handler.getTopVoterMessageIds().put(TopVoter.Monthly, 0L);
|
||||
f.beforeChannelLookup = () -> {
|
||||
f.generation.incrementAndGet();
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly, f.board);
|
||||
};
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly, f.board);
|
||||
assertNotNull(f.sendSuccess);
|
||||
var message = mock(Message.class); when(message.getIdLong()).thenReturn(44L);
|
||||
f.sendSuccess.accept(message); f.drain();
|
||||
assertEquals(44L, f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
verify(f.data).setTopVoterMessageId(TopVoter.Monthly, 44L);
|
||||
verify(f.channel, times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
@Test void failedSendStillPublishesOneExplicitCoalescedRefreshInEverySendMode() {
|
||||
for(int mode=0;mode<3;mode++)try(var f=new Fixture()) {
|
||||
if(mode==0)when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(true);
|
||||
if(mode==1)f.handler.getTopVoterMessageIds().put(TopVoter.Monthly,0L);
|
||||
if(mode==2){f.publish();f.unknown();f.drain();}
|
||||
else DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
f.board.clear();f.board.put(f.first,100);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var failed=f.sendFailure;failed.accept(new IllegalStateException("synthetic transient failure"));f.drain();
|
||||
verify(f.channel,times(2)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
assertTrue(f.sent.getDescription().contains("SyntheticFirst"));
|
||||
f.sendFailure.accept(new IllegalStateException("synthetic repeated failure"));f.drain();
|
||||
verify(f.channel,times(2)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
@Test void failedPublicationWarnsWithoutEchoingExceptionContent() {
|
||||
for(boolean send:List.of(false,true))try(var f=new Fixture()) {
|
||||
if(send){when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(true);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
f.sendFailure.accept(new IllegalStateException("synthetic-private-detail"));}
|
||||
else {f.publish();f.editFailure.accept(new IllegalStateException("synthetic-private-detail"));}
|
||||
verify(f.logger).warning(argThat((String text)->text.contains("Discord")&&!text.contains("synthetic-private-detail")));
|
||||
}
|
||||
}
|
||||
@Test void staleFailedSendCannotFlushAnOlderCoalescedRequest() {
|
||||
try(var f=new Fixture()) {
|
||||
when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(true);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
f.generation.incrementAndGet();f.sendFailure.accept(new IllegalStateException("synthetic stale failure"));f.drain();
|
||||
verify(f.channel,times(1)).sendMessageEmbeds(any(MessageEmbed.class));verifyNoInteractions(f.logger);
|
||||
}
|
||||
}
|
||||
@Test void missingChannelBuildAndRecoveryFailuresHaveRedactedDiagnostics() {
|
||||
for(int mode=0;mode<3;mode++)try(var f=new Fixture()) {
|
||||
if(mode==0) {
|
||||
var absent=spy(new DiscordHandler(f.plugin)); DiscordHandlerAccess.channel(absent, id -> null);
|
||||
DiscordHandlerAccess.publish(absent,TopVoter.Monthly,f.board);
|
||||
} else if(mode==1) {
|
||||
when(f.config.getDiscordSRVTopVoterTitle(any())).thenThrow(new IllegalStateException("synthetic-private-detail"));
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
} else {
|
||||
doThrow(new IllegalStateException("synthetic-private-detail")).when(f.data).setTopVoterMessageId(TopVoter.Monthly,0L);
|
||||
f.publish();f.unknown();f.drain();
|
||||
}
|
||||
verify(f.logger).warning(argThat((String text)->text.contains("Discord")&&!text.contains("synthetic-private-detail")));
|
||||
}
|
||||
}
|
||||
@Test void recoveryReprojectsCurrentEvidenceAfterJoinAndWorkerQueueDelay() {
|
||||
try(var f=new Fixture()) {
|
||||
f.publish();f.unknown();assertNull(f.sent,"Recovery belongs on the existing worker");
|
||||
var current=new LinkedHashMap<TopVoterPlayer,Integer>();current.put(f.first,100);current.putAll(f.board);f.board.clear();f.board.putAll(current);
|
||||
f.drain();assertNotNull(f.sent);assertTrue(f.sent.getDescription().contains("SyntheticFirst"));
|
||||
verify(f.plugin,times(2)).getTopVoterDisplay(TopVoter.Monthly);
|
||||
}
|
||||
}
|
||||
@Test void reloadOrDisableBeforeErrorCannotSendOrClearCurrentMessageId() {
|
||||
for(boolean reload:List.of(true,false))try(var f=new Fixture()) {
|
||||
f.publish();if(reload)f.generation.incrementAndGet();else when(f.plugin.isEnabled()).thenReturn(false);
|
||||
f.unknown();f.drain();assertNull(f.sent);verify(f.data,never()).setTopVoterMessageId(any(),anyLong());
|
||||
}
|
||||
}
|
||||
@Test void reloadBeforeQueuedRecoveryDropsOldWork() {
|
||||
try(var f=new Fixture()) {f.publish();f.unknown();f.generation.incrementAndGet();f.drain();assertNull(f.sent);}
|
||||
}
|
||||
@Test void lateRecoverySuccessCannotOverwriteNewGenerationMessageId() {
|
||||
try(var f=new Fixture()) {
|
||||
f.publish();f.unknown();f.drain();assertNotNull(f.sendSuccess);
|
||||
f.generation.incrementAndGet();f.handler.getTopVoterMessageIds().put(TopVoter.Monthly,22L);
|
||||
var message=mock(Message.class);when(message.getIdLong()).thenReturn(10L);f.sendSuccess.accept(message);f.drain();
|
||||
assertEquals(22L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
verify(f.data,never()).setTopVoterMessageId(TopVoter.Monthly,10L);
|
||||
}
|
||||
}
|
||||
@Test void duplicateUnknownCallbacksCoalesceAndFailedReplacementAllowsNextUpdate() {
|
||||
try(var f=new Fixture()) {
|
||||
f.publish();f.unknown();f.unknown();f.drain();
|
||||
verify(f.channel,times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
f.sendFailure.accept(new IllegalStateException("synthetic send failure"));
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
verify(f.channel,times(2)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
var message=mock(Message.class);when(message.getIdLong()).thenReturn(12L);f.sendSuccess.accept(message);f.drain();
|
||||
assertEquals(12L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
verify(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
}
|
||||
}
|
||||
@Test void rejectedRecoveryAdmissionRetainsIdAndAllowsRetry() {
|
||||
try(var f=new Fixture()) {
|
||||
var timer=f.plugin.getTimer();
|
||||
doThrow(new RejectedExecutionException("synthetic")).when(timer).execute(any(Runnable.class));
|
||||
f.publish();f.unknown();assertNull(f.sent);assertEquals(9L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
doAnswer(call->{f.worker.add(call.getArgument(0));return null;}).when(timer).execute(any(Runnable.class));
|
||||
f.unknown();f.drain();assertNotNull(f.sent);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedRecoveryAdmissionDrainsTheAlreadyQueuedRefreshWithoutAnotherUpdate() {
|
||||
try (var f = new Fixture()) {
|
||||
f.publish();
|
||||
f.board.clear(); f.board.put(f.first, 100); f.publish();
|
||||
var admissions = new AtomicInteger();
|
||||
var timer = f.plugin.getTimer();
|
||||
doAnswer(call -> {
|
||||
if (admissions.getAndIncrement() == 0)
|
||||
throw new RejectedExecutionException("synthetic first rejection");
|
||||
f.worker.add(call.getArgument(0)); return null;
|
||||
}).when(timer).execute(any(Runnable.class));
|
||||
f.unknown(); f.drain();
|
||||
var frames = org.mockito.ArgumentCaptor.forClass(MessageEmbed.class);
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), frames.capture());
|
||||
assertTrue(frames.getAllValues().get(1).getDescription().contains("SyntheticFirst"));
|
||||
assertEquals(9L, f.storedId.get());
|
||||
verify(f.data, never()).setTopVoterMessageId(any(), anyLong());
|
||||
verify(f.channel, never()).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
f.unknown(); f.drain(); // The retried network edit confirms the message is absent.
|
||||
var created = new CreatedMessage(13L); f.sendSuccess.accept(created.message); f.drain();
|
||||
assertEquals(13L, f.storedId.get());
|
||||
verify(f.channel, times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void persistentRecoveryRejectionBoundsTransferAndAllowsALaterExplicitRetry() {
|
||||
try (var f = new Fixture()) {
|
||||
f.publish(); f.publish();
|
||||
var timer = f.plugin.getTimer();
|
||||
doThrow(new RejectedExecutionException("synthetic persistent rejection"))
|
||||
.when(timer).execute(any(Runnable.class));
|
||||
f.unknown(); f.drain();
|
||||
verify(timer, times(2)).execute(any(Runnable.class));
|
||||
verify(f.channel, times(1)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
assertEquals(9L, f.storedId.get());
|
||||
verify(f.data, never()).setTopVoterMessageId(any(), anyLong());
|
||||
verify(f.channel, never()).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
verify(f.logger, atLeastOnce()).warning(contains("worker admission"));
|
||||
doAnswer(call -> { f.worker.add(call.getArgument(0)); return null; })
|
||||
.when(timer).execute(any(Runnable.class));
|
||||
f.publish(); f.unknown(); f.drain();
|
||||
verify(f.channel, times(2)).editMessageEmbedsById(eq(9L), any(MessageEmbed.class));
|
||||
verify(f.channel, times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
@Test void disabledRecoveryAndOtherErrorsPreserveExistingMessageId() {
|
||||
for(boolean disabled:List.of(true,false))try(var f=new Fixture()) {
|
||||
f.publish();
|
||||
if(disabled){when(f.config.isDiscordSRVTopVoterAutoRecoverMessageOnFailure()).thenReturn(false);f.unknown();}
|
||||
else f.editFailure.accept(new IllegalStateException("synthetic edit error"));
|
||||
f.drain();assertNull(f.sent);assertEquals(9L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
verify(f.data,never()).setTopVoterMessageId(any(),anyLong());
|
||||
}
|
||||
}
|
||||
@Test void newMessageModeKeepsItsNonpersistentIdAndDropsLateGenerationSuccess() {
|
||||
try(var f=new Fixture()) {
|
||||
when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(true);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var message=mock(Message.class);when(message.getIdLong()).thenReturn(12L);f.sendSuccess.accept(message);f.drain();
|
||||
assertEquals(12L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
verify(f.data,never()).setTopVoterMessageId(any(),anyLong());
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);f.generation.incrementAndGet();
|
||||
when(message.getIdLong()).thenReturn(13L);f.sendSuccess.accept(message);f.drain();
|
||||
assertEquals(12L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void recoveryReprojectsAfterBlockingIdPersistenceAndDropsRetiredGeneration() {
|
||||
for(boolean retire:List.of(false,true))try(var f=new Fixture()) {
|
||||
doAnswer(call->{
|
||||
if(retire)f.generation.incrementAndGet();
|
||||
else {var now=new LinkedHashMap<TopVoterPlayer,Integer>();now.put(f.first,100);now.putAll(f.board);f.board.clear();f.board.putAll(now);}
|
||||
return null;
|
||||
}).when(f.data).setTopVoterMessageId(TopVoter.Monthly,0L);
|
||||
f.publish();f.unknown();f.drain();
|
||||
if(retire)assertNull(f.sent,"No outgoing request after retirement during persistence");
|
||||
else assertTrue(f.sent.getDescription().contains("SyntheticFirst"),"Projection must follow persistence");
|
||||
}
|
||||
}
|
||||
@Test void ordinaryPendingSendsCoalesceAndOldCallbackCannotOverwriteSuccessor() {
|
||||
try(var f=new Fixture()) {
|
||||
when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(true);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);var old=f.sendSuccess;
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
verify(f.channel,times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
var first=mock(Message.class);when(first.getIdLong()).thenReturn(12L);old.accept(first);f.drain();
|
||||
verify(f.channel,times(2)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
var next=mock(Message.class);when(next.getIdLong()).thenReturn(13L);f.sendSuccess.accept(next);f.drain();
|
||||
old.accept(first);f.drain();assertEquals(13L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void lateEditFailureCannotRecoverOverSuccessorEdit() {
|
||||
try(var f=new Fixture()) {
|
||||
f.publish();var old=f.editFailure;var success=f.editSuccess;f.publish();
|
||||
verify(f.channel,times(1)).editMessageEmbedsById(eq(9L),any(MessageEmbed.class));
|
||||
success.accept(mock(Message.class));f.drain();
|
||||
verify(f.channel,times(2)).editMessageEmbedsById(eq(9L),any(MessageEmbed.class));
|
||||
var error=mock(ErrorResponseException.class);when(error.getErrorResponse()).thenReturn(ErrorResponse.UNKNOWN_MESSAGE);
|
||||
old.accept(error);f.drain();assertNull(f.sent);
|
||||
f.unknown();f.drain();assertNotNull(f.sent);
|
||||
}
|
||||
}
|
||||
@Test void initialPersistentSendCoalescesAndDuplicateSuccessCannotOverwriteLaterId() {
|
||||
try(var f=new Fixture()) {
|
||||
f.handler.getTopVoterMessageIds().put(TopVoter.Monthly,0L);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);var old=f.sendSuccess;
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
verify(f.channel,times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
var message=mock(Message.class);when(message.getIdLong()).thenReturn(12L);
|
||||
old.accept(message);old.accept(message);f.drain();
|
||||
verify(f.data,times(1)).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
f.unknown();f.drain();when(message.getIdLong()).thenReturn(13L);f.sendSuccess.accept(message);f.drain();
|
||||
when(message.getIdLong()).thenReturn(12L);old.accept(message);f.drain();
|
||||
assertEquals(13L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
}
|
||||
}
|
||||
@Test void persistenceFailureReleasesSendAndRecoveryForRetry() {
|
||||
for(boolean recovery:List.of(false,true))try(var f=new Fixture()) {
|
||||
if(recovery) {
|
||||
doThrow(new IllegalStateException("synthetic persistence failure")).when(f.data).setTopVoterMessageId(TopVoter.Monthly,0L);
|
||||
f.publish();f.unknown();f.drain();assertNull(f.sent);
|
||||
doNothing().when(f.data).setTopVoterMessageId(TopVoter.Monthly,0L);
|
||||
f.publish();f.unknown();f.drain();assertNotNull(f.sent);
|
||||
} else {
|
||||
f.handler.getTopVoterMessageIds().put(TopVoter.Monthly,0L);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var message=mock(Message.class);when(message.getIdLong()).thenReturn(12L);
|
||||
doThrow(new IllegalStateException("synthetic persistence failure")).when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
f.sendSuccess.accept(message);
|
||||
assertDoesNotThrow(f::drain);
|
||||
verify(f.logger).warning(contains("storage"));
|
||||
assertEquals(12L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
doAnswer(call->{f.storedId.set(12L);return null;}).when(f.data).setTopVoterMessageId(TopVoter.Monthly,12L);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
f.editSuccess.accept(message);f.drain();
|
||||
assertEquals(12L,f.storedId.get());
|
||||
verify(f.channel,times(1)).sendMessageEmbeds(any(MessageEmbed.class));
|
||||
verify(f.channel).editMessageEmbedsById(eq(12L),any(MessageEmbed.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void retirementWhileSuccessWaitsForDataMonitorPreventsPersistence() throws Exception {
|
||||
try(var f=new Fixture()) {
|
||||
f.handler.getTopVoterMessageIds().put(TopVoter.Monthly,0L);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);
|
||||
var message=mock(Message.class);when(message.getIdLong()).thenReturn(12L);f.sendSuccess.accept(message);
|
||||
var action=f.worker.remove();var started=new CountDownLatch(1);var failure=new AtomicReference<Throwable>();
|
||||
Thread callback=new Thread(()->{started.countDown();try{action.run();}catch(Throwable error){failure.set(error);}});
|
||||
synchronized(f.data) {
|
||||
callback.start();assertTrue(started.await(5,TimeUnit.SECONDS));
|
||||
long end=System.nanoTime()+TimeUnit.SECONDS.toNanos(5);
|
||||
while(callback.getState()!=Thread.State.BLOCKED && callback.isAlive() && System.nanoTime()<end)Thread.onSpinWait();
|
||||
assertEquals(Thread.State.BLOCKED,callback.getState());f.generation.incrementAndGet();
|
||||
}
|
||||
callback.join(5000);assertFalse(callback.isAlive());assertNull(failure.get());
|
||||
verify(f.data,never()).setTopVoterMessageId(any(),anyLong());
|
||||
assertEquals(0L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
}
|
||||
}
|
||||
@Test void rejectedSuccessDispatchReleasesOnlyItsOwnPublication() {
|
||||
try(var f=new Fixture()) {
|
||||
when(f.config.isDiscordSRVTopVoterNewMessageOnUpdate(any())).thenReturn(true);
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);var old=f.sendSuccess;
|
||||
var timer=f.plugin.getTimer();
|
||||
doThrow(new RejectedExecutionException("synthetic")).when(timer).execute(any(Runnable.class));
|
||||
var message=mock(Message.class);when(message.getIdLong()).thenReturn(12L);old.accept(message);
|
||||
doAnswer(call->{f.worker.add(call.getArgument(0));return null;}).when(timer).execute(any(Runnable.class));
|
||||
DiscordHandlerAccess.publish(f.handler,TopVoter.Monthly,f.board);var next=f.sendSuccess;
|
||||
old.accept(message);when(message.getIdLong()).thenReturn(13L);next.accept(message);f.drain();
|
||||
assertEquals(13L,f.handler.getTopVoterMessageIds().get(TopVoter.Monthly));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.bencodez.votingplugin.tests.discord;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
|
||||
class DiscordMessageIdPersistenceTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private com.bencodez.advancedcore.data.ServerData writer(Path file) throws Exception {
|
||||
var writer = mock(com.bencodez.advancedcore.data.ServerData.class, CALLS_REAL_METHODS);
|
||||
var owner = com.bencodez.simpleapi.file.YMLFile.class;
|
||||
var data = owner.getDeclaredField("data"); data.setAccessible(true);
|
||||
data.set(writer, new YamlConfiguration());
|
||||
var path = owner.getDeclaredField("dFile"); path.setAccessible(true); path.set(writer, file.toFile());
|
||||
return writer;
|
||||
}
|
||||
|
||||
@Test void successfulWritesAndClearsAreConfirmedInTheActualNamespacedFile() throws Exception {
|
||||
Path path = directory.resolve("state.yml");
|
||||
var plugin = mock(VotingPluginMain.class); var file = writer(path);
|
||||
when(plugin.getServerDataFile()).thenReturn(file); var data = new ServerData(plugin);
|
||||
assertTrue(data.setTopVoterMessageIdConfirmed(TopVoter.Monthly, 12L));
|
||||
var saved = new YamlConfiguration(); saved.load(path.toFile());
|
||||
assertEquals(12L, saved.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId.Monthly"));
|
||||
assertTrue(data.setTopVoterMessageIdConfirmed(TopVoter.Monthly, 0L));
|
||||
saved.load(path.toFile()); assertEquals(0L, saved.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId.Monthly"));
|
||||
}
|
||||
|
||||
@Test void actualLegacyWriterSwallowsIOExceptionButConfirmationRemainsFalse() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); var file = writer(directory);
|
||||
when(plugin.getServerDataFile()).thenReturn(file); var data = new ServerData(plugin);
|
||||
assertFalse(data.setTopVoterMessageIdConfirmed(TopVoter.Monthly, 12L));
|
||||
assertEquals(12L, data.getTopVoterMessageId(TopVoter.Monthly));
|
||||
assertTrue(Files.isDirectory(directory));
|
||||
}
|
||||
|
||||
@Test void customOrQueuedSaveDoesNotBecomeConfirmationFromMemoryAlone() throws Exception {
|
||||
Path path = directory.resolve("state.yml");
|
||||
var plugin = mock(VotingPluginMain.class); var file = writer(path);
|
||||
when(plugin.getServerDataFile()).thenReturn(file); var data = new ServerData(plugin);
|
||||
assertTrue(data.setTopVoterMessageIdConfirmed(TopVoter.Monthly, 9L));
|
||||
doNothing().when(file).saveData();
|
||||
assertFalse(data.setTopVoterMessageIdConfirmed(TopVoter.Monthly, 12L));
|
||||
assertEquals(12L, data.getTopVoterMessageId(TopVoter.Monthly));
|
||||
var saved = new YamlConfiguration(); saved.load(path.toFile());
|
||||
assertEquals(9L, saved.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId.Monthly"));
|
||||
}
|
||||
|
||||
@Test void corruptSavedContentIsNotAConfirmedId() throws Exception {
|
||||
Path path = directory.resolve("state.yml");
|
||||
var plugin = mock(VotingPluginMain.class); var file = writer(path);
|
||||
when(plugin.getServerDataFile()).thenReturn(file); var data = new ServerData(plugin);
|
||||
doAnswer(call -> { Files.writeString(path, "invalid: [\n"); return null; }).when(file).saveData();
|
||||
assertFalse(data.setTopVoterMessageIdConfirmed(TopVoter.Monthly, 12L));
|
||||
}
|
||||
}
|
||||
+9
@@ -167,4 +167,13 @@ public class VotiferEventDisabledVoteSiteTest {
|
||||
verify(overflowQueue).enqueue("Steve", SERVICE_SITE);
|
||||
verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableTimerUsesTheSameOverflowAdmissionPath() {
|
||||
when(plugin.getVoteTimer()).thenReturn(null);
|
||||
when(overflowQueue.enqueue("Steve", SERVICE_SITE)).thenReturn(true);
|
||||
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE));
|
||||
verify(overflowQueue).enqueue("Steve", SERVICE_SITE);
|
||||
verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class));
|
||||
}
|
||||
}
|
||||
+123
@@ -1,6 +1,8 @@
|
||||
package com.bencodez.votingplugin.tests.packaging;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -8,12 +10,14 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.io.StringWriter;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.jar.JarFile;
|
||||
import javax.tools.ToolProvider;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
@@ -119,6 +123,125 @@ public class PackagedArtifactTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedJedisUsesTheResolvedClientRatherThanAnEmbeddedProducerCopy() throws Exception {
|
||||
Path selected = Path.of(redis.clients.jedis.Jedis.class.getProtectionDomain()
|
||||
.getCodeSource().getLocation().toURI());
|
||||
try (JarFile dependency = new JarFile(selected.toFile());
|
||||
JarFile artifact = new JarFile(packagedJar().toFile())) {
|
||||
// MultiDbClient differs across the 8.0.1/8.0.2 patch boundary; Jedis alone does not.
|
||||
for (String name : java.util.List.of("redis/clients/jedis/Jedis.class",
|
||||
"redis/clients/jedis/JedisPool.class", "redis/clients/jedis/MultiDbClient.class")) {
|
||||
assertNotNull(dependency.getEntry(name), "Missing resolved client API: " + name);
|
||||
assertNotNull(artifact.getEntry(name), "Missing packaged client API: " + name);
|
||||
assertEquals(1L, artifact.stream().filter(entry -> entry.getName().equals(name)).count(),
|
||||
"Duplicate packaged client: " + name);
|
||||
try (var expected = dependency.getInputStream(dependency.getEntry(name));
|
||||
var actual = artifact.getInputStream(artifact.getEntry(name))) {
|
||||
assertArrayEquals(expected.readAllBytes(), actual.readAllBytes(),
|
||||
"A producer's embedded client must not shadow the resolved dependency: " + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedSimpleApiRedisHandlerUsesTheSelectedClientWithoutMavenDependencies(@TempDir Path directory)
|
||||
throws Exception {
|
||||
assertRedisHandlerLinks(packagedJar(),
|
||||
"com.bencodez.votingplugin.simpleapi.servercomm.redis.RedisHandler", directory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishedProducerArtifactsRetainLegacyRedisApisAndLinkTheirHandlers(@TempDir Path directory) throws Exception {
|
||||
var resources = PackagedArtifactTest.class.getClassLoader()
|
||||
.getResources("com/bencodez/simpleapi/servercomm/redis/RedisHandler.class");
|
||||
java.util.Set<URL> producers = new java.util.LinkedHashSet<>();
|
||||
while (resources.hasMoreElements()) {
|
||||
URL resource = resources.nextElement();
|
||||
assertEquals("jar", resource.getProtocol(), "Producer verification requires actual dependency JARs");
|
||||
var connection = (java.net.JarURLConnection) resource.openConnection();
|
||||
connection.setUseCaches(false);
|
||||
producers.add(connection.getJarFileURL());
|
||||
}
|
||||
assertEquals(2, producers.size(), "Verify both the normal AdvancedCore and direct SimpleAPI artifacts");
|
||||
int index = 0;
|
||||
URL platformSlf4j = org.slf4j.Logger.class.getProtectionDomain().getCodeSource().getLocation();
|
||||
for (URL producer : producers) {
|
||||
try (URLClassLoader loader = new URLClassLoader(new URL[] { producer, platformSlf4j },
|
||||
ClassLoader.getPlatformClassLoader())) {
|
||||
Class<?> endpoint = Class.forName("redis.clients.jedis.HostAndPort", true, loader);
|
||||
Class<?> config = Class.forName("redis.clients.jedis.JedisClientConfig", true, loader);
|
||||
Class.forName("redis.clients.jedis.JedisPooled", true, loader).getConstructor(endpoint, config);
|
||||
Class.forName("redis.clients.jedis.JedisSentineled", true, loader)
|
||||
.getConstructor(String.class, config, java.util.Set.class, config);
|
||||
Class.forName("redis.clients.jedis.UnifiedJedis", true, loader).getConstructor(endpoint, config);
|
||||
}
|
||||
assertRedisHandlerLinks(Path.of(producer.toURI()), "com.bencodez.simpleapi.servercomm.redis.RedisHandler",
|
||||
Files.createDirectory(directory.resolve("producer-" + index++)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertRedisHandlerLinks(Path artifact, String handlerName, Path directory) throws Exception {
|
||||
Path source = directory.resolve("PackagedRedisHandlerProbe.java");
|
||||
Files.writeString(source, """
|
||||
public final class PackagedRedisHandlerProbe
|
||||
extends %s {
|
||||
public PackagedRedisHandlerProbe(boolean tls) {
|
||||
super("localhost", 0, "", "", 3, tls);
|
||||
}
|
||||
@Override public void debug(String message) { }
|
||||
public Object listener() {
|
||||
return createEnvelopeListener("synthetic", (channel, envelope) -> { });
|
||||
}
|
||||
}
|
||||
""".formatted(handlerName));
|
||||
var compiler = ToolProvider.getSystemJavaCompiler();
|
||||
assertNotNull(compiler);
|
||||
StringWriter diagnostics = new StringWriter();
|
||||
try (var files = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)) {
|
||||
assertTrue(compiler.getTask(diagnostics, files, null,
|
||||
java.util.List.of("--release", "21", "-proc:none", "-classpath", artifact.toString(),
|
||||
"-d", directory.toString()), null,
|
||||
files.getJavaFileObjects(source.toFile())).call(), diagnostics.toString());
|
||||
}
|
||||
URL platformSlf4j = org.slf4j.Logger.class.getProtectionDomain().getCodeSource().getLocation();
|
||||
try (URLClassLoader loader = new URLClassLoader(new URL[] {
|
||||
directory.toUri().toURL(), artifact.toUri().toURL(), platformSlf4j },
|
||||
ClassLoader.getPlatformClassLoader())) {
|
||||
Class<?> probe = Class.forName("PackagedRedisHandlerProbe", true, loader);
|
||||
Class<?> handler = probe.getSuperclass();
|
||||
Class<?> configApi = Class.forName("redis.clients.jedis.JedisClientConfig", true, loader);
|
||||
Class<?> subscriber = Class.forName("redis.clients.jedis.JedisPubSub", true, loader);
|
||||
Class<?> jedis = Class.forName("redis.clients.jedis.Jedis", true, loader);
|
||||
jedis.getMethod("publish", String.class, String.class);
|
||||
jedis.getMethod("subscribe", subscriber, String[].class);
|
||||
var configField = handler.getDeclaredField("clientConfig");
|
||||
configField.setAccessible(true);
|
||||
var poolField = handler.getDeclaredField("publisherPool");
|
||||
poolField.setAccessible(true);
|
||||
for (boolean tls : new boolean[] { false, true }) {
|
||||
Object instance = probe.getConstructor(boolean.class).newInstance(tls);
|
||||
Object pool = poolField.get(instance);
|
||||
try {
|
||||
Object config = configField.get(instance);
|
||||
assertEquals(tls, configApi.getMethod("isSsl").invoke(config));
|
||||
assertEquals(3, configApi.getMethod("getDatabase").invoke(config));
|
||||
if (tls) {
|
||||
var parameters = (javax.net.ssl.SSLParameters) configApi.getMethod("getSslParameters").invoke(config);
|
||||
assertEquals("HTTPS", parameters.getEndpointIdentificationAlgorithm());
|
||||
}
|
||||
assertTrue(subscriber.isInstance(probe.getMethod("listener").invoke(instance)));
|
||||
assertEquals(false, pool.getClass().getMethod("isClosed").invoke(pool));
|
||||
} finally {
|
||||
// Constructors are lazy: no publish, subscribe or connection borrow is performed.
|
||||
handler.getMethod("close").invoke(instance);
|
||||
}
|
||||
assertEquals(true, pool.getClass().getMethod("isClosed").invoke(pool));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagedJdkTlsIdentityWorksWithoutExternalCrypto(@TempDir Path directory) throws Exception {
|
||||
URL jar = packagedJar().toUri().toURL();
|
||||
|
||||
+1822
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -39,7 +39,7 @@ import com.bencodez.advancedcore.api.placeholder.PlaceHolder;
|
||||
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
|
||||
import com.bencodez.advancedcore.api.user.usercache.UserDataManager;
|
||||
import com.bencodez.simpleapi.folialib.FoliaLib;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
import com.bencodez.simpleapi.folialib.impl.PlatformScheduler;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
@@ -225,7 +225,7 @@ class PlaceHoldersWorkerSafetyTest {
|
||||
PlaceHoldersAccess.publishUserDataChangePlaceholders(fixture.placeholders);
|
||||
fixture.presence.playerOnline(fixture.player);
|
||||
FoliaLib folia = mock(FoliaLib.class);
|
||||
ServerImplementation entityScheduler = mock(ServerImplementation.class);
|
||||
PlatformScheduler entityScheduler = mock(PlatformScheduler.class);
|
||||
when(fixture.scheduler.getFoliaLib()).thenReturn(folia);
|
||||
when(folia.getImpl()).thenReturn(entityScheduler);
|
||||
when(entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class)))
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.bencodez.votingplugin.tests.placeholders;
|
||||
|
||||
import com.bencodez.votingplugin.placeholders.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.advancedcore.api.placeholder.PlaceHolder;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.topvoter.*;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class TopVoterPositionFreshnessTest {
|
||||
@Test
|
||||
void everyRankBypassesOldResultCacheAcrossCompletionExpiryAndJoin() throws Exception {
|
||||
for (PlaceholderCacheLevel level : PlaceholderCacheLevel.values()) {
|
||||
for (String period : List.of("all", "month", "lastmonth", "week", "daily")) {
|
||||
var plugin = mock(VotingPluginMain.class); var config = mock(Config.class);
|
||||
when(plugin.getConfigFile()).thenReturn(config); when(config.getPlaceholderCacheLevel()).thenReturn(level);
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true);
|
||||
var user = mock(VotingPluginUser.class); var viewer = mock(OfflinePlayer.class);
|
||||
UUID id = new UUID(0, 2); when(user.getJavaUUID()).thenReturn(id);
|
||||
var placeholders = spy(new PlaceHolders(plugin)); PlaceHoldersAccess.resolvePlaceholderUser(doReturn(user).when(placeholders), viewer);
|
||||
var raw = new LinkedHashMap<TopVoterPlayer,Integer>();
|
||||
var absent = new TopVoterPlayer(new UUID(0, 1), "SyntheticNever", 1L);
|
||||
var target = new TopVoterPlayer(id, "SyntheticJoined", 1L); raw.put(absent,100);raw.put(target,90);
|
||||
var clock = new AtomicLong(); var pending = new AtomicReference<>(new CompletableFuture<PlayerPresence>());
|
||||
try (var filter = new TopVoterDisplayFilter(row -> row.getUuid().equals(absent.getUuid()) ? pending.get()
|
||||
: CompletableFuture.completedStage(PlayerPresence.JOINED), clock::get)) {
|
||||
String key = "top_" + period + "_position";
|
||||
var placeholder = new PlaceHolder<VotingPluginUser>(key) {
|
||||
@Override public String placeholderRequest(VotingPluginUser ignored, String identifier) {
|
||||
return Integer.toString(new ArrayList<>(filter.project(raw,true).keySet()).indexOf(target)+1);
|
||||
}
|
||||
};
|
||||
placeholder.setUseCache(true,key);placeholder.getCache().get(key).put(id,"2");
|
||||
placeholders.getPlaceholders().add(placeholder);
|
||||
var field = PlaceHolders.class.getDeclaredField("cachedPlaceholders");field.setAccessible(true);
|
||||
((ArrayList<String>)field.get(placeholders)).add(key);
|
||||
assertEquals("2",placeholders.getPlaceholderValue(viewer,key,false,false,true));
|
||||
pending.get().complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals("1",placeholder.placeholderRequest(user,key),"Fixture must actually omit the certified row");
|
||||
assertEquals("1",placeholders.getPlaceholderValue(viewer,key,false,false,true),period+" completion");
|
||||
pending.set(new CompletableFuture<>());clock.set(TimeUnit.SECONDS.toNanos(31));
|
||||
assertEquals("2",placeholders.getPlaceholderValue(viewer,key,false,false,true),period+" expired");
|
||||
pending.get().complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals("1",placeholder.placeholderRequest(user,key),"Fixture must actually omit the certified row");
|
||||
assertEquals("1",placeholders.getPlaceholderValue(viewer,key,false,false,true));
|
||||
pending.set(CompletableFuture.completedFuture(PlayerPresence.JOINED));
|
||||
filter.joined(absent.getUuid(),absent.getPlayerName());
|
||||
assertEquals("2",placeholders.getPlaceholderValue(viewer,key,false,false,true),period+" joined");
|
||||
filter.close();assertEquals("2",placeholders.getPlaceholderValue(viewer,key,false,false,true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Test void disabledFeatureAndUnrelatedPlaceholdersKeepConfiguredResultCaching() throws Exception {
|
||||
for (String key : List.of("top_month_position","points")) {
|
||||
var plugin=mock(VotingPluginMain.class);var config=mock(Config.class);
|
||||
when(plugin.getConfigFile()).thenReturn(config);when(config.getPlaceholderCacheLevel()).thenReturn(PlaceholderCacheLevel.AUTO);
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(key.equals("points"));
|
||||
var user=mock(VotingPluginUser.class);var viewer=mock(OfflinePlayer.class);var id=new UUID(0,2);
|
||||
when(user.getJavaUUID()).thenReturn(id);var placeholders=spy(new PlaceHolders(plugin));
|
||||
PlaceHoldersAccess.resolvePlaceholderUser(doReturn(user).when(placeholders), viewer);
|
||||
var placeholder=new PlaceHolder<VotingPluginUser>(key) {
|
||||
@Override public String placeholderRequest(VotingPluginUser ignored,String identifier) {return "fresh";}
|
||||
};
|
||||
placeholder.setUseCache(true,key);placeholder.getCache().get(key).put(id,"cached");placeholders.getPlaceholders().add(placeholder);
|
||||
var field=PlaceHolders.class.getDeclaredField("cachedPlaceholders");field.setAccessible(true);
|
||||
((ArrayList<String>)field.get(placeholders)).add(key);
|
||||
assertEquals("cached",placeholders.getPlaceholderValue(viewer,key,false,false,true));
|
||||
}
|
||||
}
|
||||
}
|
||||
+787
@@ -0,0 +1,787 @@
|
||||
package com.bencodez.votingplugin.tests.placeholders;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceHolders;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel;
|
||||
import com.bencodez.votingplugin.specialrewards.voteparty.VoteParty;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class VotePartyPublicationReadTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void allVotePartyPlaceholdersReturnWhilePublicationIsStalled() throws Exception {
|
||||
var previousPlugin = VotingPluginMain.plugin;
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
VotingPluginMain.plugin = plugin;
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getConfigFile().getPlaceholderCacheLevel()).thenReturn(PlaceholderCacheLevel.NONE);
|
||||
when(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()).thenReturn(20);
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var armed = new AtomicBoolean();
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void reloadData() {
|
||||
getData().set("VotingPlugin.VoteParty.Total", 31);
|
||||
getData().set("VotingPlugin.VotePartyExtraRequired", 4);
|
||||
}
|
||||
|
||||
@Override public void saveData() {
|
||||
assertTrue(Thread.holdsLock(publicationMonitor()));
|
||||
if (armed.get()) {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
}
|
||||
};
|
||||
var yaml = new YamlConfiguration();
|
||||
yaml.set("VotingPlugin.VoteParty.Total", 7);
|
||||
yaml.set("VotingPlugin.VotePartyExtraRequired", 3);
|
||||
backing.setData(yaml);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var exposed = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(exposed);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
when(plugin.getVoteParty()).thenReturn(new VoteParty(plugin));
|
||||
data.saveData();
|
||||
var placeholders = new PlaceHolders(plugin);
|
||||
placeholders.load();
|
||||
when(plugin.getSpecialRewardsConfig().isVotePartyEnabled()).thenReturn(true);
|
||||
when(plugin.getConfigFile().getFormatCommandsVoteParty())
|
||||
.thenReturn(new java.util.ArrayList<>(java.util.List.of("%votes%/%votesrequired%/%neededvotes%")));
|
||||
var sender = mock(org.bukkit.command.CommandSender.class);
|
||||
var workers = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
armed.set(true);
|
||||
var saving = workers.submit(() -> data.setInt("VoteParty.Total", 9));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var reading = workers.submit(() -> {
|
||||
assertEquals("9", value(placeholders, "VotePartyVotesCurrent"));
|
||||
assertEquals("23", value(placeholders, "VotePartyVotesRequired"));
|
||||
assertEquals("14", value(placeholders, "VotePartyVotesNeeded"));
|
||||
plugin.getVoteParty().commandVoteParty(sender);
|
||||
verify(sender).sendMessage(new String[] { "9/23/14" });
|
||||
});
|
||||
reading.get(2, TimeUnit.SECONDS);
|
||||
assertFalse(saving.isDone(), "the read must finish before the disk writer resumes");
|
||||
release.countDown();
|
||||
saving.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(9, plugin.getVoteParty().getTotalVotes(), "accounting still reads authoritative YAML");
|
||||
data.setVotePartyExtraRequired(-2);
|
||||
assertEquals("20", value(placeholders, "VotePartyVotesRequired"));
|
||||
assertEquals("11", value(placeholders, "VotePartyVotesNeeded"));
|
||||
data.setVotePartyExtraRequired(6);
|
||||
assertEquals("26", value(placeholders, "VotePartyVotesRequired"));
|
||||
assertEquals("17", value(placeholders, "VotePartyVotesNeeded"));
|
||||
exposed.reloadData();
|
||||
assertEquals("31", value(placeholders, "VotePartyVotesCurrent"));
|
||||
assertEquals("24", value(placeholders, "VotePartyVotesRequired"));
|
||||
assertEquals("-7", value(placeholders, "VotePartyVotesNeeded"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
} finally { VotingPluginMain.plugin = previousPlugin; }
|
||||
}
|
||||
|
||||
@Test void preLoadConstructionRemainsLazyUntilTheSdkCreatesItsFile() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var data = new ServerData(plugin);
|
||||
assertEquals(0, data.getVotePartyDisplay().total());
|
||||
verify(plugin, never()).getServerDataFile();
|
||||
}
|
||||
|
||||
@Test void startupReloadAndFailedSaveRetainLiveDisplaySemantics() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var backing = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
doCallRealMethod().when(backing).mutateOrdinaryData(any());
|
||||
when(backing.publicationMonitor()).thenReturn(new Object());
|
||||
var initial = new YamlConfiguration();
|
||||
initial.set("VotingPlugin.VoteParty.Total", 11);
|
||||
initial.set("VotingPlugin.VotePartyExtraRequired", 4);
|
||||
when(backing.getData()).thenReturn(initial);
|
||||
when(plugin.getServerDataFile()).thenReturn(backing);
|
||||
var data = new ServerData(plugin);
|
||||
data.refreshVotePartyDisplay();
|
||||
var first = data.getVotePartyDisplay();
|
||||
assertEquals(11, first.total());
|
||||
assertEquals(24, first.required(20));
|
||||
verify(backing, never()).saveData();
|
||||
|
||||
var reloaded = new YamlConfiguration();
|
||||
reloaded.set("VotingPlugin.VoteParty.Total", 13);
|
||||
doAnswer(invocation -> { when(backing.getData()).thenReturn(reloaded); return null; })
|
||||
.when(backing).reloadData();
|
||||
data.reloadData();
|
||||
assertEquals(13, data.getVotePartyDisplay().total());
|
||||
assertEquals(20, data.getVotePartyDisplay().required(20));
|
||||
assertEquals(11, first.total(), "previously published snapshots remain immutable");
|
||||
|
||||
doThrow(new IllegalStateException("Synthetic publication failure")).when(backing).saveData();
|
||||
assertThrows(IllegalStateException.class, () -> data.setInt("VoteParty.Total", 17));
|
||||
assertEquals(17, data.getVotePartyDisplay().total(), "display follows live YAML, not a durability claim");
|
||||
assertEquals(17, data.getData().getInt("VoteParty.Total"));
|
||||
}
|
||||
|
||||
@Test void sdkSetDataPublishesItsLiveDisplayBeforeDiskIo() throws Exception {
|
||||
sdkMutationAndSaveEntryPoint("setData");
|
||||
}
|
||||
|
||||
@Test void sdkSetValuePublishesItsLiveDisplayBeforeDiskIo() throws Exception {
|
||||
sdkMutationAndSaveEntryPoint("setValue");
|
||||
}
|
||||
|
||||
@Test void sdkSaveDataPublishesItsLiveDisplayBeforeDiskIo() throws Exception {
|
||||
sdkMutationAndSaveEntryPoint("saveData");
|
||||
}
|
||||
|
||||
@Test void sdkCheckedSavePublishesItsLiveDisplayBeforeDiskIo() throws Exception {
|
||||
sdkMutationAndSaveEntryPoint("saveDataChecked");
|
||||
}
|
||||
|
||||
private void sdkMutationAndSaveEntryPoint(String operation) throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
yaml.set("VotingPlugin.VoteParty.Total", 7);
|
||||
yaml.set("VotingPlugin.VotePartyExtraRequired", 3);
|
||||
backing.setData(yaml);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var exposed = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(exposed);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
data.refreshVotePartyDisplay();
|
||||
var saves = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
assertTrue(Thread.holdsLock(backing), "the actual SDK owner must hold live YAML");
|
||||
assertEquals(17, yaml.getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(17, data.getVotePartyDisplay().total(),
|
||||
"display must be refreshed before the blocking backing save");
|
||||
assertEquals(3, data.getVotePartyDisplay().extraRequired());
|
||||
saves.incrementAndGet();
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(any(java.io.File.class));
|
||||
try {
|
||||
switch (operation) {
|
||||
case "setData" -> exposed.setData("VotingPlugin.VoteParty.Total", 17);
|
||||
case "setValue" -> exposed.setValue("VotingPlugin.VoteParty.Total", 17);
|
||||
case "saveData" -> {
|
||||
synchronized (exposed.publicationMonitor()) {
|
||||
exposed.getData().set("VotingPlugin.VoteParty.Total", 17);
|
||||
exposed.saveData();
|
||||
}
|
||||
}
|
||||
case "saveDataChecked" -> {
|
||||
synchronized (exposed.publicationMonitor()) {
|
||||
exposed.getData().set("VotingPlugin.VoteParty.Total", 17);
|
||||
exposed.saveDataChecked();
|
||||
}
|
||||
}
|
||||
default -> throw new AssertionError(operation);
|
||||
}
|
||||
assertEquals(1, saves.get(), "one real backing save must be exercised");
|
||||
assertEquals(17, YamlConfiguration.loadConfiguration(backing.getdFile())
|
||||
.getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(17, data.getVotePartyDisplay().total());
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkBulkMergeKeepsItsNoSaveSemanticsAndRefreshesTheLiveDisplay() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
var original = new YamlConfiguration();
|
||||
backing.setData(original);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var exposed = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(exposed);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
data.refreshVotePartyDisplay();
|
||||
var replacement = new YamlConfiguration();
|
||||
replacement.set("VotingPlugin.VoteParty.Total", 19);
|
||||
replacement.set("VotingPlugin.VotePartyExtraRequired", 4);
|
||||
try {
|
||||
exposed.setData(replacement);
|
||||
assertSame(original, exposed.getData(), "bulk merge must retain the SDK's existing live YAML identity");
|
||||
assertFalse(java.nio.file.Files.exists(backing.getdFile().toPath()), "bulk merge must not save");
|
||||
assertEquals(19, data.getVotePartyDisplay().total());
|
||||
assertEquals(4, data.getVotePartyDisplay().extraRequired());
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeFinalPublicationRefreshesOnlyAfterItsLiveCommit() throws Exception {
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void saveDataChecked() {
|
||||
assertTrue(Thread.holdsLock(publicationMonitor()));
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
super.saveDataChecked();
|
||||
}
|
||||
})) {
|
||||
var workers = Executors.newFixedThreadPool(2);
|
||||
var original = fixture.data.getVotePartyDisplay();
|
||||
try {
|
||||
var publication = workers.submit(() -> fixture.exposed.mutateFinalData(candidate ->
|
||||
candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var reading = workers.submit(() -> fixture.data.getVotePartyDisplay());
|
||||
assertEquals(7, reading.get(2, TimeUnit.SECONDS).total(),
|
||||
"an unpublished final candidate must not replace the live projection");
|
||||
assertFalse(publication.isDone());
|
||||
release.countDown();
|
||||
publication.get(5, TimeUnit.SECONDS);
|
||||
assertSame(fixture.yaml, fixture.backing.getData());
|
||||
assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total(), "confirmed native commit must refresh display");
|
||||
assertEquals(7, original.total(), "prior published records remain immutable");
|
||||
assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile())
|
||||
.getInt("VotingPlugin.VoteParty.Total"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedNativeFinalCandidateKeepsTheLiveProjectionAndRetainsAdmission() throws Exception {
|
||||
var failure = new IllegalStateException("Synthetic checked publication failure");
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void saveDataChecked() { throw failure; }
|
||||
})) {
|
||||
var observation = fixture.exposed.mutateFinalData(candidate ->
|
||||
candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture();
|
||||
var observed = assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> observation.get(5, TimeUnit.SECONDS));
|
||||
assertSame(failure, observed.getCause());
|
||||
assertEquals(7, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(7, fixture.data.getVotePartyDisplay().total());
|
||||
assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath()));
|
||||
fixture.fence.retire();
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void legacyFinalMutationRefreshesBeforeSaveWithoutClaimingDurableAcknowledgement() throws Exception {
|
||||
var display = new java.util.concurrent.atomic.AtomicReference<ServerData>();
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void saveData() {
|
||||
assertTrue(Thread.holdsLock(publicationMonitor()));
|
||||
assertEquals(17, display.get().getVotePartyDisplay().total(),
|
||||
"a legacy owner's live mutation must be visible before its disk save");
|
||||
super.saveData();
|
||||
}
|
||||
})) {
|
||||
display.set(fixture.data);
|
||||
var observation = fixture.exposed.mutateFinalData(live ->
|
||||
live.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture();
|
||||
var failure = assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> observation.get(5, TimeUnit.SECONDS));
|
||||
assertInstanceOf(UnsupportedOperationException.class, failure.getCause(),
|
||||
"the existing SDK legacy-save contract must not become a durable-success acknowledgement");
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total());
|
||||
assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile())
|
||||
.getInt("VotingPlugin.VoteParty.Total"));
|
||||
fixture.fence.retire();
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledObservationStillRefreshesAfterTheAsynchronousOwnersActualCommit() throws Exception {
|
||||
class DeferredOwner extends com.bencodez.advancedcore.data.ServerData {
|
||||
final java.util.concurrent.CompletableFuture<Void> publication = new java.util.concurrent.CompletableFuture<>();
|
||||
java.util.function.Consumer<org.bukkit.configuration.file.FileConfiguration> mutation;
|
||||
DeferredOwner(VotingPluginMain plugin) { super(plugin); }
|
||||
@Override public java.util.concurrent.CompletionStage<Void> mutateFinalData(
|
||||
java.util.function.Consumer<org.bukkit.configuration.file.FileConfiguration> operation) {
|
||||
mutation = operation;
|
||||
return publication;
|
||||
}
|
||||
void publish() {
|
||||
// Defer the real native mutation and durable publication, rather than mocking their result.
|
||||
super.mutateFinalData(mutation).whenComplete((ignored, failure) -> {
|
||||
if (failure == null) publication.complete(null);
|
||||
else publication.completeExceptionally(failure);
|
||||
});
|
||||
}
|
||||
}
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(DeferredOwner::new)) {
|
||||
var owner = (DeferredOwner) fixture.backing;
|
||||
var observation = fixture.exposed.mutateFinalData(candidate ->
|
||||
candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture();
|
||||
assertEquals(7, fixture.data.getVotePartyDisplay().total());
|
||||
assertFalse(owner.publication.isDone());
|
||||
assertTrue(observation.cancel(false));
|
||||
assertFalse(owner.publication.isCancelled());
|
||||
fixture.fence.retire();
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath()));
|
||||
owner.publish();
|
||||
owner.publication.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(observation.isCancelled());
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total());
|
||||
assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile())
|
||||
.getInt("VotingPlugin.VoteParty.Total"));
|
||||
var successor = ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath());
|
||||
successor.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void finalPublicationWithoutADisplayWrapperDoesNotAddYamlObservation() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
var publication = new java.util.concurrent.CompletableFuture<Void>();
|
||||
when(backing.mutateFinalData(any())).thenReturn(publication);
|
||||
when(backing.publicationMonitor()).thenThrow(new AssertionError("No display owner to observe"));
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
try {
|
||||
var observation = facade.mutateFinalData(data -> {}).toCompletableFuture();
|
||||
fence.retire();
|
||||
assertFalse(observation.isDone());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")));
|
||||
publication.complete(null);
|
||||
observation.get(5, TimeUnit.SECONDS);
|
||||
verify(backing, never()).publicationMonitor();
|
||||
verify(backing, never()).getData();
|
||||
var successor = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
successor.retire();
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void publicOrdinaryMutationOwnsYamlAndRefreshesBeforeItsSave() throws Exception {
|
||||
var display = new java.util.concurrent.atomic.AtomicReference<ServerData>();
|
||||
var saves = new java.util.concurrent.atomic.AtomicInteger();
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void saveData() {
|
||||
assertTrue(Thread.holdsLock(publicationMonitor()));
|
||||
assertEquals(17, display.get().getVotePartyDisplay().total());
|
||||
saves.incrementAndGet();
|
||||
super.saveData();
|
||||
}
|
||||
})) {
|
||||
display.set(fixture.data);
|
||||
fixture.exposed.mutateOrdinaryData(live -> {
|
||||
assertTrue(Thread.holdsLock(fixture.backing.publicationMonitor()),
|
||||
"the newly public inherited entry must acquire the actual YAML owner before mutation");
|
||||
assertSame(fixture.yaml, live);
|
||||
live.set("VotingPlugin.VoteParty.Total", 17);
|
||||
});
|
||||
assertEquals(1, saves.get());
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total());
|
||||
assertSame(fixture.yaml, fixture.backing.getData());
|
||||
assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile())
|
||||
.getInt("VotingPlugin.VoteParty.Total"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryCallbackFailureKeepsLiveDisplayAndOriginalFailureWithoutSaving() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) {
|
||||
var failure = new IllegalArgumentException("Synthetic ordinary callback failure");
|
||||
assertSame(failure, assertThrows(IllegalArgumentException.class, () ->
|
||||
fixture.exposed.mutateOrdinaryData(live -> {
|
||||
live.set("VotingPlugin.VoteParty.Total", 17);
|
||||
throw failure;
|
||||
})));
|
||||
assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total());
|
||||
assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath()),
|
||||
"a failed callback must not run an ordinary save or claim rollback");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkNullRemovalAndSectionCreationRefreshTheSameLiveYaml() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) {
|
||||
fixture.exposed.setData("VotingPlugin.VoteParty.Total", null);
|
||||
assertFalse(fixture.yaml.contains("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(0, fixture.data.getVotePartyDisplay().total());
|
||||
fixture.exposed.setValue("VotingPlugin.VoteParty.Total", 11);
|
||||
assertEquals(11, fixture.data.getVotePartyDisplay().total());
|
||||
fixture.exposed.createSection("VotingPlugin.VoteParty");
|
||||
assertNotNull(fixture.yaml.getConfigurationSection("VotingPlugin.VoteParty"));
|
||||
assertEquals(0, fixture.data.getVotePartyDisplay().total());
|
||||
assertEquals(3, fixture.data.getVotePartyDisplay().extraRequired());
|
||||
assertSame(fixture.yaml, fixture.backing.getData());
|
||||
assertFalse(YamlConfiguration.loadConfiguration(fixture.backing.getdFile())
|
||||
.contains("VotingPlugin.VoteParty.Total"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retiredOrdinaryEntryRejectsBeforeInvokingTheCallback() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) {
|
||||
fixture.fence.retire();
|
||||
var successor = ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath());
|
||||
try {
|
||||
var called = new AtomicBoolean();
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
fixture.exposed.mutateOrdinaryData(live -> called.set(true)));
|
||||
assertFalse(called.get(), "a stale facade must reject before caller effects");
|
||||
assertEquals(7, fixture.yaml.getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(7, fixture.data.getVotePartyDisplay().total());
|
||||
} finally { successor.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkSetupReloadsTheLiveDisplayFromTheExistingFile() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) {
|
||||
var replacement = new YamlConfiguration();
|
||||
replacement.set("VotingPlugin.VoteParty.Total", 29);
|
||||
replacement.set("VotingPlugin.VotePartyExtraRequired", 4);
|
||||
replacement.save(fixture.backing.getdFile());
|
||||
byte[] before = java.nio.file.Files.readAllBytes(fixture.backing.getdFile().toPath());
|
||||
fixture.exposed.setup();
|
||||
assertEquals(29, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(29, fixture.data.getVotePartyDisplay().total());
|
||||
assertEquals(4, fixture.data.getVotePartyDisplay().extraRequired());
|
||||
assertArrayEquals(before, java.nio.file.Files.readAllBytes(fixture.backing.getdFile().toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkCaseModeChangesRefreshWithoutSavingOrRewritingKeys() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) {
|
||||
synchronized (fixture.backing.publicationMonitor()) {
|
||||
fixture.yaml.set("VotingPlugin", null);
|
||||
fixture.yaml.set("votingplugin.VoteParty.Total", 17);
|
||||
fixture.yaml.set("votingplugin.VotePartyExtraRequired", 4);
|
||||
}
|
||||
fixture.data.refreshVotePartyDisplay();
|
||||
assertEquals(0, fixture.data.getVotePartyDisplay().total());
|
||||
String original = fixture.yaml.saveToString();
|
||||
fixture.exposed.setIgnoreCase(true);
|
||||
assertEquals(17, fixture.backing.getData().getConfigurationSection("VotingPlugin")
|
||||
.getInt("VoteParty.Total"), "verify the actual SDK section contract before its display");
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total());
|
||||
assertEquals(4, fixture.data.getVotePartyDisplay().extraRequired());
|
||||
fixture.exposed.setIgnoreCase(false);
|
||||
assertEquals(0, fixture.data.getVotePartyDisplay().total());
|
||||
assertSame(fixture.yaml, fixture.backing.getData());
|
||||
assertEquals(original, fixture.yaml.saveToString());
|
||||
assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkLoadValuesRefreshesWithoutAnOrdinarySave() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void loadValues() { getData().set("VotingPlugin.VoteParty.Total", 17); }
|
||||
})) {
|
||||
fixture.exposed.loadValues();
|
||||
assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total());
|
||||
assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkFailedLoadRefreshesLiveValuesAndPreservesFailure() throws Exception {
|
||||
var failure = new IllegalArgumentException("Synthetic load failure after live mutation");
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void loadValues() {
|
||||
getData().set("VotingPlugin.VoteParty.Total", 17);
|
||||
throw failure;
|
||||
}
|
||||
})) {
|
||||
assertSame(failure, assertThrows(IllegalArgumentException.class, fixture.exposed::loadValues));
|
||||
assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total());
|
||||
assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkLifecycleHooksRemainLazyWhileTheFileIsUninitialized() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var display = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(display);
|
||||
try {
|
||||
facade.loadValues();
|
||||
facade.onFileCreation();
|
||||
facade.setIgnoreCase(true);
|
||||
assertNull(backing.getData());
|
||||
assertEquals(0, display.getVotePartyDisplay().total());
|
||||
assertFalse(java.nio.file.Files.exists(backing.getdFile().toPath()));
|
||||
verify(plugin, never()).getServerDataFile();
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkFailedLoadKeepsItsPrimaryWhenObservationAlsoFails() throws Exception {
|
||||
var primary = new IllegalArgumentException("Synthetic primary load failure");
|
||||
var secondary = new IllegalStateException("Synthetic unavailable live observation");
|
||||
var unavailable = new AtomicBoolean();
|
||||
var observations = new java.util.concurrent.atomic.AtomicInteger();
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public org.bukkit.configuration.file.FileConfiguration getData() {
|
||||
if (unavailable.get()) { observations.incrementAndGet(); throw secondary; }
|
||||
return super.getData();
|
||||
}
|
||||
@Override public void loadValues() {
|
||||
super.getData().set("VotingPlugin.VoteParty.Total", 17);
|
||||
unavailable.set(true);
|
||||
throw primary;
|
||||
}
|
||||
})) {
|
||||
try {
|
||||
assertSame(primary, assertThrows(IllegalArgumentException.class, fixture.exposed::loadValues));
|
||||
if (observations.get() > 0) {
|
||||
assertTrue(java.util.Arrays.asList(primary.getSuppressed()).contains(secondary),
|
||||
"an attempted observer failure must remain attached to the original load error");
|
||||
}
|
||||
} finally { unavailable.set(false); }
|
||||
assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
fixture.data.refreshVotePartyDisplay();
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void wrapperOrdinarySavesKeepOneOwnerCallDetachedListsAndConditionalNoOp() throws Exception {
|
||||
var saves = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var display = new java.util.concurrent.atomic.AtomicReference<ServerData>();
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void saveData() {
|
||||
assertTrue(Thread.holdsLock(publicationMonitor()));
|
||||
assertEquals(getData().getInt("VotingPlugin.VoteParty.Total"),
|
||||
display.get().getVotePartyDisplay().total());
|
||||
saves.incrementAndGet();
|
||||
super.saveData();
|
||||
}
|
||||
})) {
|
||||
display.set(fixture.data);
|
||||
fixture.data.setInt("VoteParty.Total", 17);
|
||||
assertEquals(1, saves.get());
|
||||
var values = new java.util.ArrayList<>(java.util.List.of("FIRST", "SECOND"));
|
||||
fixture.data.setStringList("AutoCachePlaceholders", values);
|
||||
values.add("later");
|
||||
assertEquals(2, saves.get());
|
||||
assertEquals(java.util.List.of("FIRST", "SECOND"), fixture.data.getAutoCachedPlaceholder());
|
||||
fixture.data.updatePlaceholders();
|
||||
assertEquals(3, saves.get(), "a compound ordinary update saves only after its complete admitted mutation");
|
||||
assertEquals(java.util.List.of("first", "second"), fixture.data.getAutoCachedPlaceholder());
|
||||
assertTrue(fixture.data.getData().getBoolean("AutoCacheUpdated"));
|
||||
fixture.data.updatePlaceholders();
|
||||
assertEquals(3, saves.get(), "the established already-updated no-op must not save");
|
||||
fixture.data.setVotePartyExtraRequired(5);
|
||||
assertEquals(4, saves.get());
|
||||
assertEquals(5, fixture.data.getVotePartyDisplay().extraRequired());
|
||||
assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile())
|
||||
.getInt("VotingPlugin.VoteParty.Total"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void wrapperIntRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception {
|
||||
assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setInt("VoteParty.Total", 23));
|
||||
}
|
||||
|
||||
@Test void wrapperListRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception {
|
||||
assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setStringList("SyntheticList", java.util.List.of("entry")));
|
||||
}
|
||||
|
||||
@Test void wrapperExtraRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception {
|
||||
assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setVotePartyExtraRequired(9));
|
||||
}
|
||||
|
||||
@Test void wrapperShopConversionRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception {
|
||||
assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setShopConverted(true));
|
||||
}
|
||||
|
||||
@Test void wrapperShopPurchaseRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception {
|
||||
assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setVoteShopPurchases("synthetic", 4));
|
||||
}
|
||||
|
||||
private void assertWrapperRejectsAfterUnconfirmedCommit(java.util.function.Consumer<ServerData> mutation)
|
||||
throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void forceDirectory(Path path) throws java.io.IOException {
|
||||
if (java.nio.file.Files.exists(getdFile().toPath()))
|
||||
throw new java.io.IOException("Synthetic post-replacement directory-force failure");
|
||||
super.forceDirectory(path);
|
||||
}
|
||||
})) {
|
||||
var publication = fixture.exposed.mutateFinalData(candidate ->
|
||||
candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture();
|
||||
var failure = assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> publication.get(5, TimeUnit.SECONDS));
|
||||
assertInstanceOf(java.io.UncheckedIOException.class, failure.getCause());
|
||||
assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"),
|
||||
"the real replacement became visible before durability remained unconfirmed");
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total());
|
||||
var liveBefore = fixture.backing.getData().saveToString();
|
||||
var diskBefore = java.nio.file.Files.readAllBytes(fixture.backing.getdFile().toPath());
|
||||
var displayBefore = fixture.data.getVotePartyDisplay();
|
||||
assertThrows(IllegalStateException.class, () -> mutation.accept(fixture.data));
|
||||
assertEquals(liveBefore, fixture.backing.getData().saveToString(),
|
||||
"SDK rejection must precede wrapper YAML mutation");
|
||||
assertEquals(displayBefore, fixture.data.getVotePartyDisplay(),
|
||||
"a rejected mutation cannot advance the live projection");
|
||||
assertArrayEquals(diskBefore, java.nio.file.Files.readAllBytes(fixture.backing.getdFile().toPath()));
|
||||
fixture.fence.retire();
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void synchronousFinalOwnerThrowAfterCommitRefreshesLiveDisplayAndRetainsFailure() throws Exception {
|
||||
var primary = new IllegalStateException("Synthetic post-commit owner cleanup failure");
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public java.util.concurrent.CompletionStage<Void> mutateFinalData(
|
||||
java.util.function.Consumer<org.bukkit.configuration.file.FileConfiguration> mutation) {
|
||||
var candidate = new YamlConfiguration();
|
||||
mutation.accept(candidate);
|
||||
assertEquals(17, candidate.getInt("VotingPlugin.VoteParty.Total"));
|
||||
getData().set("VotingPlugin.VoteParty.Total", 17);
|
||||
throw primary;
|
||||
}
|
||||
})) {
|
||||
var result = fixture.exposed.mutateFinalData(candidate ->
|
||||
candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture();
|
||||
assertSame(primary, assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> result.get(5, TimeUnit.SECONDS)).getCause());
|
||||
assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(17, fixture.data.getVotePartyDisplay().total(),
|
||||
"a synchronous final-owner throw must still observe its actual committed YAML");
|
||||
fixture.fence.retire();
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void synchronousFinalOwnerThrowBeforeCommitKeepsPredecessorDisplay() throws Exception {
|
||||
var primary = new IllegalArgumentException("Synthetic owner rejection before commit");
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public java.util.concurrent.CompletionStage<Void> mutateFinalData(
|
||||
java.util.function.Consumer<org.bukkit.configuration.file.FileConfiguration> mutation) {
|
||||
var candidate = new YamlConfiguration();
|
||||
mutation.accept(candidate);
|
||||
throw primary;
|
||||
}
|
||||
})) {
|
||||
var result = fixture.exposed.mutateFinalData(candidate ->
|
||||
candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture();
|
||||
assertSame(primary, assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> result.get(5, TimeUnit.SECONDS)).getCause());
|
||||
assertEquals(7, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(7, fixture.data.getVotePartyDisplay().total());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void synchronousFinalOwnerThrowKeepsPrimaryAndSuppressesObservationFailure() throws Exception {
|
||||
var primary = new IllegalArgumentException("Synthetic committed owner cleanup failure");
|
||||
var secondary = new IllegalStateException("Synthetic unavailable live observation");
|
||||
var unavailable = new AtomicBoolean();
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public org.bukkit.configuration.file.FileConfiguration getData() {
|
||||
if (unavailable.get()) throw secondary;
|
||||
return super.getData();
|
||||
}
|
||||
@Override public java.util.concurrent.CompletionStage<Void> mutateFinalData(
|
||||
java.util.function.Consumer<org.bukkit.configuration.file.FileConfiguration> mutation) {
|
||||
var candidate = new YamlConfiguration();
|
||||
mutation.accept(candidate);
|
||||
super.getData().set("VotingPlugin.VoteParty.Total", 17);
|
||||
unavailable.set(true);
|
||||
throw primary;
|
||||
}
|
||||
})) {
|
||||
try {
|
||||
var result = fixture.exposed.mutateFinalData(candidate ->
|
||||
candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture();
|
||||
assertSame(primary, assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> result.get(5, TimeUnit.SECONDS)).getCause());
|
||||
assertTrue(java.util.Arrays.asList(primary.getSuppressed()).contains(secondary));
|
||||
} finally { unavailable.set(false); }
|
||||
assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"));
|
||||
}
|
||||
}
|
||||
|
||||
private final class ProjectionFixture implements AutoCloseable {
|
||||
final com.bencodez.advancedcore.data.ServerData backing;
|
||||
final YamlConfiguration yaml = new YamlConfiguration();
|
||||
final ServerDataPublicationFence fence;
|
||||
final GenerationFencedServerDataFile exposed;
|
||||
final ServerData data;
|
||||
ProjectionFixture(java.util.function.Function<VotingPluginMain,
|
||||
com.bencodez.advancedcore.data.ServerData> factory) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
backing = factory.apply(plugin);
|
||||
yaml.set("VotingPlugin.VoteParty.Total", 7);
|
||||
yaml.set("VotingPlugin.VotePartyExtraRequired", 3);
|
||||
backing.setData(yaml);
|
||||
fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
exposed = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(exposed);
|
||||
data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
data.refreshVotePartyDisplay();
|
||||
}
|
||||
@Override public void close() { fence.retire(); }
|
||||
}
|
||||
|
||||
private static String value(PlaceHolders placeholders, String identifier) {
|
||||
return placeholders.getNonPlayerPlaceholders().stream().filter(p -> p.matches(identifier))
|
||||
.findFirst().orElseThrow().placeholderRequest(identifier);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -36,7 +36,7 @@ import com.bencodez.advancedcore.AdvancedCoreConfigOptions;
|
||||
import com.bencodez.simpleapi.time.ParsedDuration;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.simpleapi.folialib.FoliaLib;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
import com.bencodez.simpleapi.folialib.impl.PlatformScheduler;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardBuilder;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
@@ -258,7 +258,7 @@ public class VoteRemindersManagerTest {
|
||||
VoteReminderCooldownStore store = mock(VoteReminderCooldownStore.class);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
FoliaLib folia = mock(FoliaLib.class);
|
||||
ServerImplementation implementation = mock(ServerImplementation.class);
|
||||
PlatformScheduler implementation = mock(PlatformScheduler.class);
|
||||
Player player = mock(Player.class);
|
||||
PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence();
|
||||
UUID uuid = UUID.randomUUID();
|
||||
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
package com.bencodez.votingplugin.tests.signs;
|
||||
|
||||
import com.bencodez.votingplugin.signs.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.block.BlockState;
|
||||
import org.bukkit.block.Sign;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresenceService;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterPlayer;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter;
|
||||
|
||||
class SignLeaderboardCompletionTest {
|
||||
static final class Fixture implements AutoCloseable {
|
||||
final ArrayDeque<Runnable> worker = new ArrayDeque<>(), platform = new ArrayDeque<>();
|
||||
final AtomicLong clock = new AtomicLong(), generation = new AtomicLong();
|
||||
final TopVoterPlayer never = new TopVoterPlayer(new UUID(0, 1), "SyntheticNever", 1L);
|
||||
final TopVoterPlayer joined = new TopVoterPlayer(new UUID(0, 2), "SyntheticJoined", 1L);
|
||||
final LinkedHashMap<TopVoterPlayer, Integer> raw = new LinkedHashMap<>();
|
||||
final PlayerPresenceService service = new PlayerPresenceService(worker::add,
|
||||
id -> id.equals(never.getUuid()) ? PlayerPresence.NEVER_JOINED : PlayerPresence.JOINED);
|
||||
final TopVoterDisplayFilter filter = new TopVoterDisplayFilter(row -> service.checkAsync(row.getUuid()), clock::get);
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
final Config config = mock(Config.class);
|
||||
final BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
final Location location = mock(Location.class, RETURNS_DEEP_STUBS);
|
||||
final Location skull = mock(Location.class, RETURNS_DEEP_STUBS);
|
||||
final Sign sign = mock(Sign.class);
|
||||
final SignHandler handler;
|
||||
Fixture() {
|
||||
raw.put(never, 100); raw.put(joined, 90);
|
||||
when(plugin.isEnabled()).thenReturn(true); when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true);
|
||||
when(config.getFormatSignTopVoterSignLine1()).thenReturn("%position%:%player%");
|
||||
when(config.getFormatSignTopVoterSignLine2()).thenReturn("%votes%");
|
||||
when(config.getFormatSignTopVoterSignLine3()).thenReturn("%SiteName%");
|
||||
when(config.getFormatSignTopVoterSignLine4()).thenReturn("");
|
||||
when(plugin.getTopVoterDisplayGeneration()).thenAnswer(call -> generation.get());
|
||||
when(plugin.getTopVoterDisplay(any())).thenAnswer(call -> filter.project(raw, config.isHideTopVoterIfNotPlayedBefore()));
|
||||
when(plugin.getTopVoterDisplayAsync(any())).thenAnswer(call -> filter.projectAsync(raw, config.isHideTopVoterIfNotPlayedBefore()));
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(location.clone()).thenReturn(location); when(location.getBlock().getState()).thenReturn(sign);
|
||||
when(skull.clone()).thenReturn(skull);
|
||||
when(skull.getBlock().getState()).thenReturn(mock(BlockState.class));
|
||||
doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
|
||||
.when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(skull));
|
||||
doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
|
||||
.when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(location));
|
||||
doAnswer(call -> { platform.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTaskLater(eq(plugin), any(Runnable.class), anyLong(), eq(location));
|
||||
handler = new SignHandler(plugin, "synthetic", location, skull, "monthly", 1);
|
||||
}
|
||||
void refresh() { handler.updateLines(); handler.updateSign(0); }
|
||||
void drain() {
|
||||
int bound = 50;
|
||||
while (!worker.isEmpty() || !platform.isEmpty()) {
|
||||
assertTrue(bound-- > 0, "Completion cannot retry indefinitely");
|
||||
if (!worker.isEmpty()) worker.remove().run(); else platform.remove().run();
|
||||
}
|
||||
}
|
||||
public void close() { filter.close(); service.close(); }
|
||||
}
|
||||
@Test void nonSkullTargetDoesNotProjectTheWholeLeaderboard() {
|
||||
try(var f=new Fixture()) {
|
||||
clearInvocations(f.plugin);f.handler.checkSkulls();
|
||||
verify(f.plugin,never()).getTopVoterDisplay(any());
|
||||
}
|
||||
}
|
||||
@Test void signOnlyRefreshCompletesAfterLookupEvenWhenRefreshIntervalExceedsEvidenceTtl() {
|
||||
try (var f = new Fixture()) {
|
||||
for (int round = 0; round < 2; round++) {
|
||||
f.clock.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(round * 61L));
|
||||
f.refresh(); f.drain();
|
||||
verify(f.sign).setLine(0, "1:SyntheticJoined");
|
||||
verify(f.sign).setLine(1, "90");
|
||||
assertEquals(2, f.raw.size()); clearInvocations(f.sign);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void repeatedPendingSignRefreshesCoalesceAndOnlyOwningSchedulerWrites() {
|
||||
try (var f = new Fixture()) {
|
||||
for (int count = 0; count < 1000; count++) f.handler.updateSign(0);
|
||||
assertEquals(2, f.worker.size()); assertTrue(f.platform.isEmpty());
|
||||
while (!f.worker.isEmpty()) f.worker.remove().run();
|
||||
assertEquals(1, f.platform.size()); verify(f.sign, never()).setLine(anyInt(), anyString());
|
||||
f.platform.remove().run(); verify(f.sign).setLine(0, "1:SyntheticJoined");
|
||||
verify(f.plugin, times(1)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
}
|
||||
}
|
||||
@Test void joinAfterPreparationBeforePlatformPublicationRevokesOmission() {
|
||||
try (var f = new Fixture()) {
|
||||
f.refresh(); while (!f.worker.isEmpty()) f.worker.remove().run();
|
||||
f.filter.joined(f.never.getUuid(), f.never.getPlayerName());
|
||||
f.platform.remove().run(); verify(f.sign).setLine(0, "1:SyntheticNever");
|
||||
assertEquals(2, f.raw.size());
|
||||
}
|
||||
}
|
||||
@Test void reloadBeforePlatformPublicationDiscardsOldGeneration() {
|
||||
try (var f = new Fixture()) {
|
||||
f.refresh(); while (!f.worker.isEmpty()) f.worker.remove().run();
|
||||
f.generation.incrementAndGet(); f.platform.remove().run();
|
||||
verify(f.sign, never()).setLine(anyInt(), anyString());
|
||||
f.refresh(); f.drain(); verify(f.sign).setLine(0, "1:SyntheticJoined");
|
||||
}
|
||||
}
|
||||
@Test void disableBeforePublicationDropsOldWorkAndAllowsEnabledRetry() {
|
||||
try (var f = new Fixture()) {
|
||||
f.refresh(); while (!f.worker.isEmpty()) f.worker.remove().run();
|
||||
when(f.plugin.isEnabled()).thenReturn(false); f.platform.remove().run();
|
||||
verify(f.sign, never()).setLine(anyInt(), anyString());
|
||||
when(f.plugin.isEnabled()).thenReturn(true); f.refresh(); f.drain();
|
||||
verify(f.sign).setLine(0, "1:SyntheticJoined");
|
||||
}
|
||||
}
|
||||
@Test void oldPendingCompletionCannotClearNewGenerationRequest() {
|
||||
try (var f = new Fixture()) {
|
||||
f.handler.updateSign(0); f.generation.incrementAndGet(); f.handler.updateSign(0);
|
||||
f.drain(); verify(f.sign).setLine(0, "1:SyntheticJoined");
|
||||
verify(f.plugin, times(2)).getTopVoterDisplayAsync(TopVoter.Monthly);
|
||||
}
|
||||
}
|
||||
@Test void rejectedPlatformDispatchReleasesPreparationForRetry() {
|
||||
try (var f = new Fixture()) {
|
||||
doThrow(new java.util.concurrent.RejectedExecutionException("synthetic"))
|
||||
.when(f.scheduler).runTaskLater(eq(f.plugin), any(Runnable.class), anyLong(), eq(f.location));
|
||||
f.refresh(); f.drain(); verify(f.sign, never()).setLine(anyInt(), anyString());
|
||||
doAnswer(call -> { f.platform.add(call.getArgument(1)); return null; })
|
||||
.when(f.scheduler).runTaskLater(eq(f.plugin), any(Runnable.class), anyLong(), eq(f.location));
|
||||
f.refresh(); f.drain(); verify(f.sign).setLine(0, "1:SyntheticJoined");
|
||||
}
|
||||
}
|
||||
@Test void disabledOptionPreservesLegacyVisibleRowsWithoutAsyncPreparation() {
|
||||
try (var f = new Fixture()) {
|
||||
when(f.config.isHideTopVoterIfNotPlayedBefore()).thenReturn(false);
|
||||
f.refresh(); f.drain(); verify(f.sign).setLine(0, "1:SyntheticNever");
|
||||
assertTrue(f.worker.isEmpty()); verify(f.plugin, never()).getTopVoterDisplayAsync(any());
|
||||
}
|
||||
}
|
||||
@Test void changedSignPeriodDiscardsQueuedOldRenderAndNextRefreshUsesNewPeriod() {
|
||||
try (var f = new Fixture()) {
|
||||
f.refresh(); while (!f.worker.isEmpty()) f.worker.remove().run();
|
||||
f.handler.setData("weekly"); f.platform.remove().run();
|
||||
verify(f.sign, never()).setLine(anyInt(), anyString());
|
||||
f.refresh(); f.drain(); verify(f.sign).setLine(0, "1:SyntheticJoined");
|
||||
verify(f.sign).setLine(2, "weekly");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void removingTheLastVisibleSignRowClearsItsPreviousSkullOnOwnerScheduler() {
|
||||
try (var f = new Fixture()) {
|
||||
var head = mock(org.bukkit.block.Skull.class);
|
||||
when(f.skull.getBlock().getState()).thenReturn(head);
|
||||
// Existing public display can have a populated skull before this row is certified absent.
|
||||
f.raw.remove(f.joined); f.handler.updateLines(); f.handler.updateSign(0);
|
||||
while (!f.worker.isEmpty()) f.worker.remove().run();
|
||||
verify(head, never()).setOwnerProfile(any());
|
||||
f.platform.remove().run();
|
||||
verify(f.sign).setLine(0,"1:No Player");
|
||||
verify(head).setOwnerProfile(null);verify(head).update(true,false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void defaultFalseEmptySignPreservesLegacySkullBehavior() {
|
||||
try(var f=new Fixture()) {
|
||||
var head=mock(org.bukkit.block.Skull.class);when(f.skull.getBlock().getState()).thenReturn(head);
|
||||
when(f.config.isHideTopVoterIfNotPlayedBefore()).thenReturn(false);f.raw.clear();
|
||||
f.refresh();f.drain();verify(f.sign).setLine(0,"1:No Player");verifyNoInteractions(head);
|
||||
}
|
||||
}
|
||||
@Test void visibleSignRowStillPublishesItsCurrentSkullOwner() {
|
||||
try(var f=new Fixture()) {
|
||||
var head=mock(org.bukkit.block.Skull.class);when(f.skull.getBlock().getState()).thenReturn(head);
|
||||
when(head.setOwner(anyString())).thenReturn(true);
|
||||
f.refresh();f.drain();verify(head).setOwner("SyntheticJoined");
|
||||
verify(head,never()).setOwnerProfile(null);verify(head).update(true,false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void explicitSkullIsAccessedOnlyThroughItsOwnLocationScheduler() {
|
||||
try(var f=new Fixture()) {
|
||||
var owner=new java.util.concurrent.atomic.AtomicBoolean();var skullTasks=new ArrayDeque<Runnable>();
|
||||
var head=mock(org.bukkit.block.Skull.class);
|
||||
when(f.skull.getBlock().getState()).thenAnswer(call->{assertTrue(owner.get(),"Skull access outside its owner");return head;});
|
||||
when(f.skull.clone()).thenReturn(f.skull);
|
||||
doAnswer(call->{skullTasks.add(()->{owner.set(true);try{call.getArgument(1,Runnable.class).run();}finally{owner.set(false);}});return null;})
|
||||
.when(f.scheduler).runTask(eq(f.plugin),any(Runnable.class),eq(f.skull));
|
||||
f.raw.remove(f.joined);f.refresh();f.drain();verifyNoInteractions(head);
|
||||
assertEquals(1,skullTasks.size());skullTasks.remove().run();verify(head).setOwnerProfile(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedSkullFramesDropRetiredChangedTargetsAndSupersededRenders() {
|
||||
for(String change:java.util.List.of("generation","disabled","target","period","successor"))try(var f=new Fixture()) {
|
||||
var pending=new ArrayDeque<Runnable>();var head=mock(org.bukkit.block.Skull.class);
|
||||
when(f.skull.getBlock().getState()).thenReturn(head);
|
||||
doAnswer(call->{pending.add(call.getArgument(1));return null;})
|
||||
.when(f.scheduler).runTask(eq(f.plugin),any(Runnable.class),eq(f.skull));
|
||||
f.raw.remove(f.joined);f.refresh();f.drain();assertEquals(1,pending.size());
|
||||
switch(change) {
|
||||
case "generation" -> f.generation.incrementAndGet();
|
||||
case "disabled" -> when(f.plugin.isEnabled()).thenReturn(false);
|
||||
case "target" -> f.handler.setSkullLocation(mock(Location.class));
|
||||
case "period" -> f.handler.setData("weekly");
|
||||
case "successor" -> {f.handler.checkSkulls();assertEquals(2,pending.size());}
|
||||
}
|
||||
pending.remove().run();verifyNoInteractions(head);
|
||||
if(change.equals("successor")){pending.remove().run();verify(head).setOwnerProfile(null);}
|
||||
}
|
||||
}
|
||||
@Test void joinWhileSkullTaskWaitsUsesCurrentPlayer() {
|
||||
try(var f=new Fixture()) {
|
||||
var pending=new ArrayDeque<Runnable>();var head=mock(org.bukkit.block.Skull.class);
|
||||
when(f.skull.getBlock().getState()).thenReturn(head);
|
||||
doAnswer(call->{pending.add(call.getArgument(1));return null;})
|
||||
.when(f.scheduler).runTask(eq(f.plugin),any(Runnable.class),eq(f.skull));
|
||||
f.refresh();f.drain();f.filter.joined(f.never.getUuid(),f.never.getPlayerName());
|
||||
pending.remove().run();verify(head).setOwner("SyntheticNever");
|
||||
}
|
||||
}
|
||||
@Test void rejectedSkullDispatchAllowsLaterRefresh() {
|
||||
try(var f=new Fixture()) {
|
||||
var head=mock(org.bukkit.block.Skull.class);when(f.skull.getBlock().getState()).thenReturn(head);
|
||||
doThrow(new java.util.concurrent.RejectedExecutionException("synthetic"))
|
||||
.when(f.scheduler).runTask(eq(f.plugin),any(Runnable.class),eq(f.skull));
|
||||
f.raw.remove(f.joined);f.refresh();f.drain();verifyNoInteractions(head);
|
||||
doAnswer(call->{call.getArgument(1,Runnable.class).run();return null;})
|
||||
.when(f.scheduler).runTask(eq(f.plugin),any(Runnable.class),eq(f.skull));
|
||||
f.refresh();f.drain();verify(head).setOwnerProfile(null);
|
||||
}
|
||||
}
|
||||
|
||||
static final class RegionalSkulls implements AutoCloseable {
|
||||
final Fixture f = new Fixture();
|
||||
final org.bukkit.World world = mock(org.bukkit.World.class);
|
||||
final Location origin = new Location(world, 15, 64, 15);
|
||||
final Location target;
|
||||
final Location neighbor = new Location(world, 16, 64, 14);
|
||||
final org.bukkit.block.Skull head = mock(org.bukkit.block.Skull.class);
|
||||
final org.bukkit.block.Skull unrelated = mock(org.bukkit.block.Skull.class);
|
||||
final java.util.concurrent.atomic.AtomicBoolean neighborPlaced = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
final java.util.concurrent.atomic.AtomicReference<Location> owner = new java.util.concurrent.atomic.AtomicReference<>();
|
||||
final ArrayDeque<Runnable> tasks = new ArrayDeque<>();
|
||||
final java.util.Set<Location> visited = new java.util.HashSet<>();
|
||||
|
||||
RegionalSkulls(boolean preferred) {
|
||||
target = preferred ? new Location(world, 16, 65, 15) : new Location(world, 14, 63, 14);
|
||||
var direction = mock(org.bukkit.block.data.Directional.class);
|
||||
when(direction.getFacing()).thenReturn(org.bukkit.block.BlockFace.EAST);
|
||||
when(f.sign.getBlockData()).thenReturn(direction);
|
||||
when(head.setOwner(anyString())).thenReturn(true);
|
||||
when(head.update(true, false)).thenReturn(true);
|
||||
when(world.getBlockAt(any(Location.class))).thenAnswer(call -> {
|
||||
var point = call.getArgument(0, Location.class);
|
||||
return world.getBlockAt(point.getBlockX(), point.getBlockY(), point.getBlockZ());
|
||||
});
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(call -> {
|
||||
var point = new Location(world, call.getArgument(0, Integer.class),
|
||||
call.getArgument(1, Integer.class), call.getArgument(2, Integer.class));
|
||||
assertEquals(point, owner.get(), "Block access outside its owner");
|
||||
visited.add(point);
|
||||
var block = mock(org.bukkit.block.Block.class);
|
||||
when(block.getState()).thenAnswer(ignored -> {
|
||||
assertEquals(point, owner.get(), "Block state outside its owner");
|
||||
return point.equals(origin) ? f.sign : point.equals(target) ? head
|
||||
: neighborPlaced.get() && point.equals(neighbor) ? unrelated : mock(BlockState.class);
|
||||
});
|
||||
return block;
|
||||
});
|
||||
doAnswer(call -> {
|
||||
var point = call.getArgument(2, Location.class);
|
||||
var action = call.getArgument(1, Runnable.class);
|
||||
tasks.add(() -> { owner.set(point); try { action.run(); } finally { owner.set(null); } });
|
||||
return null;
|
||||
}).when(f.scheduler).runTask(eq(f.plugin), any(Runnable.class), any(Location.class));
|
||||
f.handler.setLocation(origin);
|
||||
f.handler.setSkullLocation(null);
|
||||
f.raw.remove(f.never);
|
||||
}
|
||||
org.bukkit.profile.PlayerProfile useProfile(boolean rejected) {
|
||||
return useProfile(rejected, false);
|
||||
}
|
||||
org.bukkit.profile.PlayerProfile useProfile(boolean rejected, boolean absent) {
|
||||
var cache = mock(com.bencodez.simpleapi.skull.SkullCacheHandler.class);
|
||||
var item = mock(org.bukkit.inventory.ItemStack.class);
|
||||
var meta = mock(org.bukkit.inventory.meta.SkullMeta.class);
|
||||
var profile = absent ? null : mock(org.bukkit.profile.PlayerProfile.class);
|
||||
when(meta.getOwnerProfile()).thenReturn(profile);
|
||||
when(item.getItemMeta()).thenReturn(meta);
|
||||
when(cache.getSkull(any(UUID.class), anyString())).thenReturn(item);
|
||||
when(f.plugin.getSkullCacheHandler()).thenReturn(cache);
|
||||
if (rejected) doThrow(new IllegalArgumentException("synthetic rejected profile"))
|
||||
.when(head).setOwnerProfile(profile);
|
||||
return profile;
|
||||
}
|
||||
void refresh() { f.handler.updateLines(); f.handler.checkSkulls(); }
|
||||
void drain() {
|
||||
int bound = 60;
|
||||
while (!tasks.isEmpty()) { assertTrue(bound-- > 0, "Bounded regional discovery"); tasks.remove().run(); }
|
||||
}
|
||||
void paint() {
|
||||
refresh(); drain(); verify(head).setOwner("SyntheticJoined");
|
||||
clearInvocations(head); visited.clear();
|
||||
}
|
||||
void empty() { f.raw.clear(); refresh(); drain(); }
|
||||
public void close() { f.close(); }
|
||||
}
|
||||
|
||||
@Test void unassignedEmptySignDoesNotClearPreferredOrNearbySkullOwners() {
|
||||
for (boolean preferred : java.util.List.of(true, false)) try (var r = new RegionalSkulls(preferred)) {
|
||||
r.neighborPlaced.set(true); r.empty();
|
||||
verifyNoInteractions(r.head, r.unrelated);
|
||||
assertEquals(java.util.Set.of(r.origin), r.visited);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void assignedAutomaticSkullClearsOnItsOwnerWithoutTouchingNewNeighbors() {
|
||||
for (boolean preferred : java.util.List.of(true, false)) try (var r = new RegionalSkulls(preferred)) {
|
||||
r.paint(); r.neighborPlaced.set(true); r.empty();
|
||||
verify(r.head).setOwnerProfile(null); verify(r.head).update(true, false);
|
||||
verifyNoInteractions(r.unrelated);
|
||||
assertEquals(java.util.Set.of(r.origin, r.target), r.visited);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void publicRangeRefreshDoesNotDiscoverSkullsForAnUnassignedEmptyRow() {
|
||||
try (var r = new RegionalSkulls(false)) {
|
||||
r.neighborPlaced.set(true); r.f.raw.clear(); r.f.handler.updateLines();
|
||||
r.f.handler.updateSkulls(r.origin.clone().add(-1, -1, -1), r.origin.clone().add(1, 1, 1));
|
||||
r.drain(); verifyNoInteractions(r.head, r.unrelated);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedVisibleDiscoveryBecomingEmptyCannotClearUnassignedSkulls() {
|
||||
for (boolean preferred : java.util.List.of(true, false)) try (var r = new RegionalSkulls(preferred)) {
|
||||
r.neighborPlaced.set(true); r.refresh(); r.f.raw.clear(); r.drain();
|
||||
verifyNoInteractions(r.head, r.unrelated);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void automaticAssignmentsDoNotCrossDisplayGenerationOrPeriod() {
|
||||
for (String change : java.util.List.of("generation", "period")) try (var r = new RegionalSkulls(false)) {
|
||||
r.paint();
|
||||
if (change.equals("generation")) r.f.generation.incrementAndGet();
|
||||
else r.f.handler.setData("weekly");
|
||||
r.empty(); verifyNoInteractions(r.head);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void successfulClearRetiresAutomaticAssociationButFailedClearCanRetry() {
|
||||
for (boolean failedFirst : java.util.List.of(true, false)) try (var r = new RegionalSkulls(false)) {
|
||||
r.paint(); when(r.head.update(true, false)).thenReturn(!failedFirst);
|
||||
r.empty(); verify(r.head).setOwnerProfile(null); clearInvocations(r.head);
|
||||
when(r.head.update(true, false)).thenReturn(true);
|
||||
r.empty();
|
||||
if (failedFirst) verify(r.head).setOwnerProfile(null);
|
||||
else verifyNoInteractions(r.head);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAutomaticDisplayDoesNotClaimTheExistingWorldSkull() {
|
||||
try (var r = new RegionalSkulls(false)) {
|
||||
when(r.head.update(true, false)).thenReturn(false); r.paint();
|
||||
r.empty(); verifyNoInteractions(r.head);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedFallbackAssignmentCannotClaimAnExistingAutomaticSkull() {
|
||||
for (boolean preferred : java.util.List.of(true, false)) try (var r = new RegionalSkulls(preferred)) {
|
||||
when(r.head.setOwner(anyString())).thenReturn(false);
|
||||
r.paint(); r.neighborPlaced.set(true); r.empty();
|
||||
verifyNoInteractions(r.head, r.unrelated);
|
||||
assertEquals(java.util.Set.of(r.origin), r.visited);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fallbackAssociationRequiresBothAssignmentAndPublication() {
|
||||
for (boolean preferred : java.util.List.of(true, false))
|
||||
for (boolean assigned : java.util.List.of(true, false))
|
||||
for (boolean published : java.util.List.of(true, false))
|
||||
try (var r = new RegionalSkulls(preferred)) {
|
||||
when(r.head.setOwner(anyString())).thenReturn(assigned);
|
||||
when(r.head.update(true, false)).thenReturn(published);
|
||||
r.refresh(); r.drain(); verify(r.head).setOwner("SyntheticJoined");
|
||||
if (!assigned) verify(r.head, never()).update(true, false);
|
||||
clearInvocations(r.head); r.empty();
|
||||
if (assigned && published) verify(r.head).setOwnerProfile(null);
|
||||
else verifyNoInteractions(r.head);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void profilePublicationUsesTheSameConfirmedAssociationRule() {
|
||||
for (boolean published : java.util.List.of(true, false)) try (var r = new RegionalSkulls(false)) {
|
||||
var profile = r.useProfile(false);
|
||||
when(r.head.update(true, false)).thenReturn(published);
|
||||
r.refresh(); r.drain(); verify(r.head).setOwnerProfile(profile);
|
||||
verify(r.head, never()).setOwner(anyString());
|
||||
clearInvocations(r.head); r.empty();
|
||||
if (published) verify(r.head).setOwnerProfile(null);
|
||||
else verifyNoInteractions(r.head);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedProfileUsesOnlySuccessfulFallbackAssociation() {
|
||||
for (boolean assigned : java.util.List.of(true, false))
|
||||
for (boolean published : java.util.List.of(true, false))
|
||||
try (var r = new RegionalSkulls(false)) {
|
||||
var profile = r.useProfile(true);
|
||||
when(r.head.setOwner(anyString())).thenReturn(assigned);
|
||||
when(r.head.update(true, false)).thenReturn(published);
|
||||
r.refresh(); r.drain(); verify(r.head).setOwnerProfile(profile);
|
||||
verify(r.head).setOwner("SyntheticJoined");
|
||||
clearInvocations(r.head); r.empty();
|
||||
if (assigned && published) verify(r.head).setOwnerProfile(null);
|
||||
else verifyNoInteractions(r.head);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedFallbackCanRetryBeforeCreatingAnAutomaticAssociation() {
|
||||
try (var r = new RegionalSkulls(false)) {
|
||||
when(r.head.setOwner(anyString())).thenReturn(false);
|
||||
r.refresh(); r.drain(); clearInvocations(r.head);
|
||||
when(r.head.setOwner(anyString())).thenReturn(true);
|
||||
r.paint(); r.empty(); verify(r.head).setOwnerProfile(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retiredFrameCannotPublishAfterOwnerAssignmentCompletes() {
|
||||
for (boolean profilePath : java.util.List.of(true, false))
|
||||
for (boolean preferred : java.util.List.of(true, false))
|
||||
try (var r = new RegionalSkulls(preferred)) {
|
||||
if (profilePath) {
|
||||
var profile = r.useProfile(false);
|
||||
doAnswer(call -> { r.f.generation.incrementAndGet(); return null; })
|
||||
.when(r.head).setOwnerProfile(profile);
|
||||
} else {
|
||||
when(r.head.setOwner(anyString())).thenAnswer(call -> {
|
||||
r.f.generation.incrementAndGet(); return true;
|
||||
});
|
||||
}
|
||||
r.refresh(); r.drain(); verify(r.head, never()).update(true, false);
|
||||
clearInvocations(r.head); r.empty(); verifyNoInteractions(r.head);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void absentCachedProfileUsesOnlyConfirmedFallbackPublication() {
|
||||
for (boolean preferred : java.util.List.of(true, false))
|
||||
for (boolean assigned : java.util.List.of(true, false))
|
||||
for (boolean published : java.util.List.of(true, false))
|
||||
try (var r = new RegionalSkulls(preferred)) {
|
||||
r.useProfile(false, true);
|
||||
when(r.head.setOwner(anyString())).thenReturn(assigned);
|
||||
when(r.head.update(true, false)).thenReturn(published);
|
||||
r.refresh(); r.drain();
|
||||
verify(r.head, never()).setOwnerProfile(null);
|
||||
verify(r.head).setOwner("SyntheticJoined");
|
||||
if (!assigned) verify(r.head, never()).update(true, false);
|
||||
clearInvocations(r.head); r.empty();
|
||||
if (assigned && published) verify(r.head).setOwnerProfile(null);
|
||||
else verifyNoInteractions(r.head);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void absentCachedProfileFailureCanRetryWithoutClearingTheExistingSkull() {
|
||||
try (var r = new RegionalSkulls(false)) {
|
||||
r.useProfile(false, true); when(r.head.setOwner(anyString())).thenReturn(false);
|
||||
r.refresh(); r.drain(); verify(r.head, never()).setOwnerProfile(null);
|
||||
verify(r.head, never()).update(true, false); clearInvocations(r.head);
|
||||
r.empty(); verifyNoInteractions(r.head);
|
||||
r.f.raw.put(r.f.joined, 20);
|
||||
when(r.head.setOwner(anyString())).thenReturn(true);
|
||||
r.paint(); r.empty(); verify(r.head).setOwnerProfile(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retiredFrameCannotPublishAnEmptyRowClear() {
|
||||
for (boolean preferred : java.util.List.of(true, false))
|
||||
for (String scope : java.util.List.of("generation", "period", "target"))
|
||||
try (var r = new RegionalSkulls(preferred)) {
|
||||
r.paint();
|
||||
doAnswer(call -> {
|
||||
if (scope.equals("generation")) r.f.generation.incrementAndGet();
|
||||
else if (scope.equals("period")) r.f.handler.setData("weekly");
|
||||
else r.f.handler.setSkullLocation(r.neighbor);
|
||||
return null;
|
||||
}).when(r.head).setOwnerProfile(null);
|
||||
r.empty(); verify(r.head).setOwnerProfile(null);
|
||||
verify(r.head, never()).update(true, false);
|
||||
verifyNoInteractions(r.unrelated);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.bencodez.votingplugin.tests.timequeue;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
|
||||
class TimeQueueCheckpointRetryTest {
|
||||
@Test void repeatedStorageFailuresBackOffAndRepairResumesTheSameOccurrence() {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
ServerData data = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
|
||||
var timer = plugin.getVoteTimer();
|
||||
var bukkit = plugin.getBukkitScheduler();
|
||||
List<Runnable> attempts = new ArrayList<>(), retries = new ArrayList<>();
|
||||
List<Long> seconds = new ArrayList<>();
|
||||
doAnswer(call -> { attempts.add(call.getArgument(0)); return null; })
|
||||
.when(timer).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
|
||||
doAnswer(call -> { retries.add(call.getArgument(1)); seconds.add(call.getArgument(2)); return null; })
|
||||
.when(bukkit).runTaskLaterAsynchronously(eq(plugin), any(Runnable.class), anyLong());
|
||||
TimeQueueHandler handler = new TimeQueueHandler(plugin);
|
||||
handler.addVote("First", "site");
|
||||
doThrow(new IllegalStateException("synthetic write failure"))
|
||||
.doThrow(new IllegalStateException("synthetic write failure"))
|
||||
.doNothing().when(data).prepareTimedVoteDispatch(any(), any(), any());
|
||||
assertThrows(IllegalStateException.class, attempts.getFirst()::run);
|
||||
var id = handler.getTimeChangeQueue().peek().getVoteId();
|
||||
retries.getFirst().run();
|
||||
assertThrows(IllegalStateException.class, attempts.get(1)::run);
|
||||
assertEquals(List.of(1L, 2L), seconds);
|
||||
assertEquals(id, handler.getTimeChangeQueue().peek().getVoteId());
|
||||
retries.get(1).run();
|
||||
attempts.get(2).run();
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
verify(plugin.getServer().getPluginManager(), times(1)).callEvent(any());
|
||||
}
|
||||
|
||||
@Test void scheduledPreparationAndCompletionFailuresEachRetryWithoutRepeatingDeliveredEvents() {
|
||||
for (String phase : List.of("prepare", "complete")) {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
ServerData data = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
|
||||
List<Runnable> scheduled = new ArrayList<>();
|
||||
var timer = plugin.getVoteTimer();
|
||||
doAnswer(call -> { scheduled.add(call.getArgument(0)); return null; })
|
||||
.when(timer).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
|
||||
TimeQueueHandler handler = new TimeQueueHandler(plugin);
|
||||
handler.addVote("First", "site");
|
||||
handler.addVote("Later", "site");
|
||||
if (phase.equals("prepare")) doThrow(new IllegalStateException("synthetic write failure"))
|
||||
.doNothing().when(data).prepareTimedVoteDispatch(any(), any(), any());
|
||||
else doThrow(new IllegalStateException("synthetic write failure"))
|
||||
.doNothing().when(data).completeTimedVoteDispatch(any());
|
||||
assertThrows(IllegalStateException.class, scheduled.getFirst()::run);
|
||||
var retry = org.mockito.ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(plugin.getBukkitScheduler(), times(1)).runTaskLaterAsynchronously(eq(plugin), retry.capture(), eq(1L));
|
||||
retry.getValue().run();
|
||||
assertEquals(2, scheduled.size());
|
||||
scheduled.get(1).run();
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
verify(plugin.getServer().getPluginManager(), times(2)).callEvent(any());
|
||||
if (phase.equals("complete")) verify(data, times(1)).addUncertainTimedVote(any(),
|
||||
argThat(vote -> vote.getName().equals("First")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -104,7 +104,9 @@ class TimeQueueHandlerCancellationTest {
|
||||
verify(acceptedUser).addTotalDaily();
|
||||
verify(acceptedUser).addTotalWeekly();
|
||||
verify(acceptedUser).setTime(site, 456L);
|
||||
verify(plugin.getServerData()).clearTimedVoteCache();
|
||||
verify(plugin.getServerData(), never()).clearTimedVoteCache();
|
||||
verify(plugin.getServerData(), times(2)).prepareTimedVoteDispatch(any(), any(), any());
|
||||
verify(plugin.getServerData(), times(2)).completeTimedVoteDispatch(any());
|
||||
|
||||
queue.processQueue();
|
||||
assertEquals(2, dispatched.size(), "a subsequent drain cannot dispatch either retired entry again");
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ class TimeQueueHandlerRejectionTest {
|
||||
TimeQueueHandler handler = new TimeQueueHandler(plugin);
|
||||
org.mockito.ArgumentCaptor<Runnable> retry = org.mockito.ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously(
|
||||
org.mockito.ArgumentMatchers.eq(plugin), retry.capture(), org.mockito.ArgumentMatchers.eq(20L));
|
||||
org.mockito.ArgumentMatchers.eq(plugin), retry.capture(), org.mockito.ArgumentMatchers.eq(1L));
|
||||
|
||||
reset(voteTimer);
|
||||
retry.getValue().run();
|
||||
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
package com.bencodez.votingplugin.tests.timequeue;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
|
||||
|
||||
/** Real ServerData/YAML writes and restart loading; mock only Bukkit services and its file adapter. */
|
||||
class TimeQueueRecoveryCheckpointTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test
|
||||
void stoppingDuringPreparationRestoresTheUndispatchedVoteForActualRestartRecovery() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("aborted-preparation.yml"));
|
||||
fixture.handler.addVote("First", "site");
|
||||
fixture.handler.addVote("Later", "site");
|
||||
fixture.handler.save();
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
doAnswer(call -> {
|
||||
call.callRealMethod();
|
||||
entered.countDown();
|
||||
assertTrue(release.await(10, java.util.concurrent.TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(fixture.yaml).save(fixture.path.toFile());
|
||||
var callers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var delivery = callers.submit(fixture.handler::processQueue);
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
callers.submit(fixture.handler::saveForShutdown).get(2, java.util.concurrent.TimeUnit.SECONDS);
|
||||
release.countDown();
|
||||
delivery.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
fixture.handler.saveForShutdown();
|
||||
verify(fixture.plugin.getServer().getPluginManager(), never()).callEvent(any());
|
||||
assertEquals(List.of("First", "Later"), new Fixture(fixture.path).names());
|
||||
assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty());
|
||||
assertEquals("keep", fixture.stored().getString("Adjacent.Value"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
callers.shutdownNow();
|
||||
assertTrue(callers.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
fixture.handler.saveForShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedLateAdmissionRemainsRetryableAfterTheEarlierShutdownSaveSucceeded() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("late-admission.yml"));
|
||||
fixture.handler.addVote("First", "site");
|
||||
fixture.handler.saveForShutdown();
|
||||
Path saved = fixture.blockWrites();
|
||||
assertThrows(java.io.UncheckedIOException.class, () -> fixture.handler.addVote("Late", "site"));
|
||||
assertEquals(List.of("Late"), fixture.names());
|
||||
fixture.repair(saved);
|
||||
fixture.handler.saveForShutdown();
|
||||
assertTrue(fixture.names().isEmpty());
|
||||
assertEquals(List.of("First", "Late"), new Fixture(fixture.path).names());
|
||||
assertEquals("keep", fixture.stored().getString("Adjacent.Value"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void abortedPreparationWriteFailurePreservesBothWrappersUntilRepairAndRetry() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("abort-retry.yml"));
|
||||
VoteTimeQueue first = new VoteTimeQueue("First", "site", 123L);
|
||||
VoteTimeQueue later = new VoteTimeQueue("Later", "site", 456L);
|
||||
UUID id = new UUID(1, 2);
|
||||
fixture.data.prepareTimedVoteDispatch(id, first, List.of(later));
|
||||
Path saved = fixture.blockWrites();
|
||||
assertThrows(java.io.UncheckedIOException.class,
|
||||
() -> fixture.data.abortTimedVoteDispatch(id, List.of(first, later)));
|
||||
assertEquals("Later", fixture.yaml.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals("First", fixture.yaml.getString("VotingPlugin.UncertainTimedVotes." + id + ".Name"));
|
||||
fixture.repair(saved);
|
||||
fixture.data.abortTimedVoteDispatch(id, List.of(first, later));
|
||||
assertEquals(List.of("First", "Later"), new Fixture(fixture.path).names());
|
||||
assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty());
|
||||
assertEquals("keep", fixture.stored().getString("Adjacent.Value"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledDispatchKeepsSuffixRecoverableWhenNextPreparationFails() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("ServerData.yml"));
|
||||
fixture.handler.addVote("First", "site");
|
||||
fixture.handler.addVote("Later", "site");
|
||||
fixture.handler.save();
|
||||
assertEquals(2, fixture.handler.getTimeChangeQueue().size());
|
||||
AtomicInteger deliveries = new AtomicInteger();
|
||||
ServerData data = spy(fixture.data);
|
||||
when(fixture.plugin.getServerData()).thenReturn(data);
|
||||
AtomicInteger preparations = new AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
if (preparations.incrementAndGet() == 2) throw new IllegalStateException("synthetic next-checkpoint failure");
|
||||
return call.callRealMethod();
|
||||
}).when(data).prepareTimedVoteDispatch(any(), any(), any());
|
||||
var events = fixture.plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> {
|
||||
PlayerVoteEvent event = call.getArgument(0);
|
||||
YamlConfiguration during = fixture.stored();
|
||||
assertEquals("Later", during.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals(1, during.getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).size());
|
||||
deliveries.incrementAndGet();
|
||||
fixture.handler.addVote("LateAdmission", "site");
|
||||
event.setCancelled(true);
|
||||
return null;
|
||||
}).when(events).callEvent(any());
|
||||
assertThrows(IllegalStateException.class, fixture.handler::processQueue);
|
||||
assertEquals(2, preparations.get(), "cancellation must continue to the next pending occurrence");
|
||||
assertEquals(1, deliveries.get());
|
||||
assertEquals(List.of("Later", "LateAdmission"), fixture.handler.getTimeChangeQueue().stream().map(v -> v.getName()).toList());
|
||||
YamlConfiguration after = fixture.stored();
|
||||
assertEquals("Later", after.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertTrue(after.getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty());
|
||||
Fixture restarted = new Fixture(fixture.path);
|
||||
assertEquals(List.of("Later", "LateAdmission"), restarted.handler.getTimeChangeQueue().stream().map(v -> v.getName()).toList());
|
||||
assertEquals("keep", after.getString("Adjacent.Value"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledDispatchContinuesThroughLateAdmissionsWithRealDurableCheckpoints() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("cancel-and-drain.yml"));
|
||||
fixture.handler.addVote("Cancelled", "site");
|
||||
fixture.handler.addVote("Accepted", "site");
|
||||
fixture.handler.save();
|
||||
var delivered = new java.util.ArrayList<String>();
|
||||
var events = fixture.plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> {
|
||||
PlayerVoteEvent event = call.getArgument(0);
|
||||
delivered.add(event.getPlayer());
|
||||
var uncertain = fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes");
|
||||
assertEquals(1, uncertain.getKeys(false).size());
|
||||
String id = uncertain.getKeys(false).iterator().next();
|
||||
assertEquals(event.getPlayer(), uncertain.getString(id + ".Name"));
|
||||
if (event.getPlayer().equals("Cancelled")) {
|
||||
fixture.handler.addVote("LateAdmission", "site");
|
||||
event.setCancelled(true);
|
||||
}
|
||||
return null;
|
||||
}).when(events).callEvent(any());
|
||||
fixture.handler.processQueue();
|
||||
assertEquals(List.of("Cancelled", "Accepted", "LateAdmission"), delivered);
|
||||
assertTrue(fixture.names().isEmpty());
|
||||
assertTrue(new Fixture(fixture.path).names().isEmpty(), "completed entries must not return after restart");
|
||||
assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty());
|
||||
assertEquals("keep", fixture.stored().getString("Adjacent.Value"));
|
||||
fixture.handler.processQueue();
|
||||
assertEquals(3, delivered.size(), "another drain must not repeat a completed occurrence");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preparationWriteFailureKeepsTheOriginalLiveAndDiskSuffixWithoutDispatch() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("ServerData.yml"));
|
||||
fixture.handler.addVote("First", "site");
|
||||
fixture.handler.addVote("Later", "site");
|
||||
fixture.handler.save();
|
||||
Path saved = fixture.blockWrites();
|
||||
assertThrows(java.io.UncheckedIOException.class, fixture.handler::processQueue);
|
||||
verify(fixture.plugin.getServer().getPluginManager(), never()).callEvent(any());
|
||||
assertEquals(List.of("First", "Later"), fixture.names());
|
||||
assertEquals("First", fixture.yaml.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
UUID originalId = fixture.handler.getTimeChangeQueue().peek().getVoteId();
|
||||
fixture.repair(saved);
|
||||
var events = fixture.plugin.getServer().getPluginManager();
|
||||
var seen = new java.util.ArrayList<UUID>();
|
||||
doAnswer(call -> {
|
||||
seen.addAll(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes")
|
||||
.getKeys(false).stream().map(UUID::fromString).toList());
|
||||
return null;
|
||||
}).when(events).callEvent(any());
|
||||
fixture.handler.processQueue();
|
||||
assertEquals(originalId, seen.getFirst());
|
||||
assertEquals(2, seen.size());
|
||||
assertTrue(fixture.names().isEmpty());
|
||||
assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.TimedVoteCache").getKeys(false).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfullyQuarantinedExceptionAndErrorEachAllowTheUnaffectedSuffixToResume() throws Exception {
|
||||
for (Throwable original : List.of(new IllegalStateException("synthetic event failure"),
|
||||
new AssertionError("synthetic event failure"))) {
|
||||
Fixture fixture = new Fixture(directory.resolve(original.getClass().getSimpleName() + ".yml"));
|
||||
fixture.handler.addVote("Ambiguous", "site");
|
||||
fixture.handler.addVote("Later", "site");
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
var events = fixture.plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> {
|
||||
if (calls.incrementAndGet() == 1) { fixture.handler.addVote("LateAdmission", "site"); throw original; }
|
||||
return null;
|
||||
})
|
||||
.when(events).callEvent(any());
|
||||
assertSame(original, assertThrows(original.getClass(), fixture.handler::processQueue));
|
||||
assertEquals(List.of("Later", "LateAdmission"), fixture.names());
|
||||
Fixture restarted = new Fixture(fixture.path);
|
||||
assertEquals(List.of("Later", "LateAdmission"), restarted.names(), "ambiguous occurrence must not be replayable");
|
||||
fixture.handler.processQueue();
|
||||
assertEquals(3, calls.get());
|
||||
assertTrue(fixture.names().isEmpty());
|
||||
assertEquals(1, fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes")
|
||||
.getKeys(false).size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preparationErrorAfterTheFileWasWrittenDoesNotAuthorizeAnEventOrLoseItsIdentity() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("ServerData.yml"));
|
||||
fixture.handler.addVote("First", "site");
|
||||
fixture.handler.addVote("Later", "site");
|
||||
fixture.handler.save();
|
||||
doAnswer(call -> {
|
||||
call.callRealMethod();
|
||||
throw new java.io.IOException("synthetic failure after file publication");
|
||||
}).when(fixture.yaml).save(fixture.path.toFile());
|
||||
assertThrows(java.io.UncheckedIOException.class, fixture.handler::processQueue);
|
||||
verify(fixture.plugin.getServer().getPluginManager(), never()).callEvent(any());
|
||||
UUID id = fixture.handler.getTimeChangeQueue().peek().getVoteId();
|
||||
assertEquals(List.of("First", "Later"), fixture.names());
|
||||
assertEquals("First", fixture.yaml.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals("First", fixture.stored().getString("VotingPlugin.UncertainTimedVotes." + id + ".Name"));
|
||||
assertEquals(List.of("Later"), new Fixture(fixture.path).names());
|
||||
doCallRealMethod().when(fixture.yaml).save(fixture.path.toFile());
|
||||
var events = fixture.plugin.getServer().getPluginManager();
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
if (calls.incrementAndGet() == 1)
|
||||
assertEquals("First", fixture.stored().getString("VotingPlugin.UncertainTimedVotes." + id + ".Name"));
|
||||
return null;
|
||||
}).when(events).callEvent(any());
|
||||
fixture.handler.processQueue();
|
||||
assertEquals(2, calls.get());
|
||||
assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedQuarantineRetriesItsSameIdentityWithoutRepeatingTheEvent() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("ServerData.yml"));
|
||||
fixture.handler.addVote("Ambiguous", "site");
|
||||
fixture.handler.addVote("Later", "site");
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
var original = new IllegalStateException("synthetic event failure");
|
||||
var saved = new java.util.concurrent.atomic.AtomicReference<Path>();
|
||||
var events = fixture.plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> {
|
||||
if (calls.incrementAndGet() == 1) { saved.set(fixture.blockWrites()); throw original; }
|
||||
return null;
|
||||
}).when(events).callEvent(any());
|
||||
assertSame(original, assertThrows(IllegalStateException.class, fixture.handler::processQueue));
|
||||
assertEquals(1, original.getSuppressed().length);
|
||||
assertEquals(List.of("Ambiguous", "Later"), fixture.names());
|
||||
UUID id = fixture.handler.getTimeChangeQueue().peek().getVoteId();
|
||||
fixture.repair(saved.get());
|
||||
fixture.handler.processQueue();
|
||||
assertEquals(2, calls.get());
|
||||
assertTrue(fixture.names().isEmpty());
|
||||
assertEquals(java.util.Set.of(id.toString()), fixture.stored()
|
||||
.getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false));
|
||||
assertTrue(new Fixture(fixture.path).names().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedCompletionPublicationKeepsTheDeliveredOccurrenceOutOfAutomaticRecovery() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("ServerData.yml"));
|
||||
fixture.handler.addVote("Delivered", "site");
|
||||
fixture.handler.addVote("Later", "site");
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
var saved = new java.util.concurrent.atomic.AtomicReference<Path>();
|
||||
var events = fixture.plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> { if (calls.incrementAndGet() == 1) saved.set(fixture.blockWrites()); return null; })
|
||||
.when(events).callEvent(any());
|
||||
assertThrows(java.io.UncheckedIOException.class, fixture.handler::processQueue);
|
||||
UUID id = fixture.handler.getTimeChangeQueue().peek().getVoteId();
|
||||
assertEquals("Delivered", fixture.yaml.getString("VotingPlugin.UncertainTimedVotes." + id + ".Name"));
|
||||
fixture.repair(saved.get());
|
||||
assertEquals(List.of("Later"), new Fixture(fixture.path).names());
|
||||
fixture.handler.processQueue();
|
||||
assertEquals(2, calls.get(), "successful event must not run again after its completion save failed");
|
||||
assertTrue(fixture.names().isEmpty());
|
||||
assertEquals(java.util.Set.of(id.toString()), fixture.stored()
|
||||
.getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownDuringARealInFlightDispatchRetainsTheSuffixWithoutWaitingForTheEvent() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("ServerData.yml"));
|
||||
fixture.handler.addVote("InFlight", "site");
|
||||
fixture.handler.addVote("Later", "site");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var events = fixture.plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); return null; })
|
||||
.when(events).callEvent(any());
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var delivery = worker.submit(fixture.handler::processQueue);
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
fixture.handler.saveForShutdown();
|
||||
assertEquals(List.of("Later"), new Fixture(fixture.path).names());
|
||||
assertEquals(1, fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes")
|
||||
.getKeys(false).size());
|
||||
assertFalse(delivery.isDone());
|
||||
release.countDown(); delivery.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
verify(events, times(1)).callEvent(any());
|
||||
assertEquals(1, fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes")
|
||||
.getKeys(false).size(), "late success must preserve the conservative shutdown record");
|
||||
} finally {
|
||||
release.countDown(); worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeYamlWrappersKeepLiteralUnicodeAndAdjacentRecordsAcrossLfAndCrLf() throws Exception {
|
||||
for (String newline : List.of("\n", "\r\n")) {
|
||||
Path path = directory.resolve(newline.length() == 1 ? "lf.yml" : "crlf.yml");
|
||||
UUID other = new UUID(1, 1), current = new UUID(1, 2);
|
||||
Files.writeString(path, ("Adjacent: {Value: 'keep'}\nVotingPlugin:\n"
|
||||
+ " Other: {Literal: '%player% [Javascript=1] 🎉'}\n"
|
||||
+ " TimedVoteCache:\n legacy: {Name: 'First', Service: 'Fête 🎉', Time: 123}\n"
|
||||
+ " UncertainTimedVotes:\n '" + other + "': {Name: 'Old', Service: 'site', Time: 1}\n")
|
||||
.replace("\n", newline));
|
||||
Fixture fixture = new Fixture(path);
|
||||
var first = new VoteTimeQueue(current, "First", "%player% [Javascript=1] 🎉", 123L);
|
||||
var later = new VoteTimeQueue("Later", "Fête 🎉", 456L);
|
||||
fixture.data.prepareTimedVoteDispatch(current, first, List.of(later));
|
||||
YamlConfiguration stored = fixture.stored();
|
||||
assertEquals(first.getService(), stored.getString("VotingPlugin.UncertainTimedVotes." + current + ".Service"));
|
||||
assertEquals("Fête 🎉", stored.getString("VotingPlugin.TimedVoteCache.0.Service"));
|
||||
assertEquals(456L, stored.getLong("VotingPlugin.TimedVoteCache.0.Time"));
|
||||
fixture.data.completeTimedVoteDispatch(current);
|
||||
stored = fixture.stored();
|
||||
assertEquals(java.util.Set.of(other.toString()), stored.getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false));
|
||||
assertEquals("%player% [Javascript=1] 🎉", stored.getString("VotingPlugin.Other.Literal"));
|
||||
assertEquals("keep", stored.getString("Adjacent.Value"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final Path path;
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final YamlConfiguration yaml;
|
||||
final ServerData data;
|
||||
final TimeQueueHandler handler;
|
||||
|
||||
Fixture(Path path) throws Exception {
|
||||
this.path = path;
|
||||
yaml = spy(java.nio.file.Files.exists(path) ? YamlConfiguration.loadConfiguration(path.toFile())
|
||||
: new YamlConfiguration());
|
||||
yaml.set("Adjacent.Value", "keep");
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(path.toFile());
|
||||
when(plugin.getServerDataFile()).thenReturn(file);
|
||||
data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
handler = new TimeQueueHandler(plugin);
|
||||
}
|
||||
|
||||
YamlConfiguration stored() {
|
||||
return YamlConfiguration.loadConfiguration(path.toFile());
|
||||
}
|
||||
|
||||
List<String> names() {
|
||||
return handler.getTimeChangeQueue().stream().map(v -> v.getName()).toList();
|
||||
}
|
||||
|
||||
Path blockWrites() throws Exception {
|
||||
Path saved = path.resolveSibling(path.getFileName() + ".saved");
|
||||
Files.move(path, saved);
|
||||
Files.createDirectory(path);
|
||||
return saved;
|
||||
}
|
||||
|
||||
void repair(Path saved) throws Exception {
|
||||
Files.delete(path);
|
||||
Files.move(saved, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.bencodez.votingplugin.tests.timequeue;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.simpleapi.folialib.FoliaLib;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask;
|
||||
|
||||
class TimeQueueSchedulerContractTest {
|
||||
@Test void installedSimpleApiForwardsAsyncDelayAsSecondsToThePlatformImplementation() throws Exception {
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class, CALLS_REAL_METHODS);
|
||||
FoliaLib bridge = mock(FoliaLib.class);
|
||||
ServerImplementation platform = (ServerImplementation) mock(
|
||||
FoliaLib.class.getMethod("getImpl").getReturnType());
|
||||
doReturn(bridge).when(scheduler).getFoliaLib();
|
||||
when(bridge.getImpl()).thenAnswer(ignored -> platform);
|
||||
AtomicBoolean ran = new AtomicBoolean();
|
||||
scheduler.runTaskLaterAsynchronously(mock(org.bukkit.plugin.Plugin.class), () -> ran.set(true), 7L);
|
||||
@SuppressWarnings("unchecked")
|
||||
var callback = (org.mockito.ArgumentCaptor<Consumer<WrappedTask>>) (Object)
|
||||
org.mockito.ArgumentCaptor.forClass(Consumer.class);
|
||||
verify(platform).runLaterAsync(callback.capture(), eq(7L), eq(TimeUnit.SECONDS));
|
||||
assertFalse(ran.get());
|
||||
callback.getValue().accept(mock(WrappedTask.class));
|
||||
assertTrue(ran.get());
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package com.bencodez.votingplugin.tests.timequeue;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
|
||||
class TimeQueueShutdownBoundTest {
|
||||
@Test void storageFailureAfterTimeoutIsReportedAndCanRetryAtTheSameIndex() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
// Resolve logging before parallel callbacks; avoid lazy deep-stub creation racing the interrupted caller.
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
ServerData data = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
|
||||
TimeQueueHandler handler = new TimeQueueHandler(plugin);
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1), failed = new CountDownLatch(1);
|
||||
java.util.concurrent.atomic.AtomicBoolean fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
if (fail.getAndSet(false)) {
|
||||
entered.countDown();
|
||||
assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
failed.countDown();
|
||||
throw new IllegalStateException("synthetic storage failure");
|
||||
}
|
||||
return null;
|
||||
}).when(data).addTimeVoted(anyInt(), any());
|
||||
handler.addVote("First", "site");
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<?> shutdown = caller.submit(handler::saveForShutdown);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
shutdown.get(2, TimeUnit.SECONDS);
|
||||
release.countDown();
|
||||
assertTrue(failed.await(5, TimeUnit.SECONDS));
|
||||
verify(plugin.getLogger(), timeout(5000)).warning(
|
||||
"Timed vote shutdown persistence failed; pending votes remain owned for retry. Recovery is not confirmed.");
|
||||
assertEquals(1, handler.getTimeChangeQueue().size());
|
||||
try { handler.saveForShutdown(); }
|
||||
catch (IllegalStateException observedCompletion) {
|
||||
assertEquals("synthetic storage failure", observedCompletion.getMessage());
|
||||
handler.saveForShutdown();
|
||||
}
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
verify(data, times(2)).addTimeVoted(eq(0), any());
|
||||
} finally {
|
||||
release.countDown();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interruptedWaitPreservesTheFlagAndLeavesPersistenceOwned() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
// Resolve logging before parallel callbacks; avoid lazy deep-stub creation racing the interrupted caller.
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
ServerData data = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
|
||||
TimeQueueHandler handler = new TimeQueueHandler(plugin);
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; })
|
||||
.when(data).addTimeVoted(anyInt(), any());
|
||||
handler.addVote("First", "site");
|
||||
try {
|
||||
Thread.currentThread().interrupt();
|
||||
handler.saveForShutdown();
|
||||
assertTrue(Thread.currentThread().isInterrupted());
|
||||
Thread.interrupted();
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
assertEquals(1, handler.getTimeChangeQueue().size());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
release.countDown();
|
||||
handler.saveForShutdown();
|
||||
}
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
verify(data, times(1)).addTimeVoted(eq(0), any());
|
||||
}
|
||||
|
||||
@Test void stalledPreparationDoesNotBlockShutdownOrAuthorizeAnEventAfterStopping() throws Exception {
|
||||
exerciseStalledWrite("prepare");
|
||||
}
|
||||
|
||||
@Test void stalledCompletionDoesNotBlockShutdownOrRepeatTheCompletedEvent() throws Exception {
|
||||
exerciseStalledWrite("complete");
|
||||
}
|
||||
|
||||
@Test void stalledShutdownWriteRetainsOneOwnerAndDoesNotBlockTheLifecycleCaller() throws Exception {
|
||||
exerciseStalledWrite("shutdown");
|
||||
}
|
||||
|
||||
private void exerciseStalledWrite(String phase) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
// Resolve logging before parallel callbacks; avoid lazy deep-stub creation racing the interrupted caller.
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
ServerData data = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
|
||||
TimeQueueHandler handler = new TimeQueueHandler(plugin);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
org.mockito.stubbing.Answer<Object> stalled = call -> {
|
||||
entered.countDown();
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
while (true) {
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); break; }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
} finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
return null;
|
||||
};
|
||||
if (phase.equals("prepare")) doAnswer(stalled).when(data).prepareTimedVoteDispatch(any(), any(), any());
|
||||
if (phase.equals("complete")) doAnswer(stalled).when(data).completeTimedVoteDispatch(any());
|
||||
if (phase.equals("shutdown")) doAnswer(stalled).when(data).addTimeVoted(anyInt(), any());
|
||||
handler.addVote("First", "site");
|
||||
handler.addVote("Later", "site");
|
||||
ExecutorService callers = Executors.newFixedThreadPool(3);
|
||||
try {
|
||||
Future<?> processing = phase.equals("shutdown") ? null : callers.submit(handler::processQueue);
|
||||
Future<?> shutdown;
|
||||
if (processing == null) {
|
||||
shutdown = callers.submit(handler::saveForShutdown);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
} else {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
shutdown = callers.submit(handler::saveForShutdown);
|
||||
}
|
||||
shutdown.get(2, TimeUnit.SECONDS);
|
||||
// Repeated unload must join the same retained persistence owner.
|
||||
Future<?> repeated = callers.submit(handler::saveForShutdown);
|
||||
repeated.get(2, TimeUnit.SECONDS);
|
||||
assertFalse(handler.getTimeChangeQueue().isEmpty());
|
||||
release.countDown();
|
||||
if (processing != null) processing.get(5, TimeUnit.SECONDS);
|
||||
handler.saveForShutdown();
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
verify(plugin.getServer().getPluginManager(), times(phase.equals("complete") ? 1 : 0)).callEvent(any());
|
||||
if (phase.equals("prepare")) verify(data, times(1)).abortTimedVoteDispatch(any(),
|
||||
argThat(pending -> pending.stream().map(vote -> vote.getName()).toList()
|
||||
.equals(java.util.List.of("First", "Later"))));
|
||||
else verify(data, times(1)).addTimeVoted(eq(phase.equals("shutdown") ? 1 : 0),
|
||||
argThat(vote -> vote.getName().equals("Later")));
|
||||
if (phase.equals("shutdown")) verify(data, times(1)).addTimeVoted(eq(0),
|
||||
argThat(vote -> vote.getName().equals("First")));
|
||||
} finally {
|
||||
release.countDown();
|
||||
callers.shutdownNow();
|
||||
assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
handler.saveForShutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package com.bencodez.votingplugin.tests.timequeue;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
|
||||
|
||||
class TimeQueueShutdownPersistenceTest {
|
||||
private record Fixture(VotingPluginMain plugin, ServerData data, TimeQueueHandler handler) {}
|
||||
private Fixture fixture() {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
ServerData data = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
when(data.getTimedVoteCacheKeys()).thenReturn(Set.of());
|
||||
return new Fixture(plugin, data, new TimeQueueHandler(plugin));
|
||||
}
|
||||
|
||||
@Test void failureRetainsOnlyUnsavedSuffixAndRetriesAtSameIndex() {
|
||||
Fixture f = fixture();
|
||||
Map<Integer, String> saved = new TreeMap<>();
|
||||
java.util.concurrent.atomic.AtomicBoolean fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
int index = call.getArgument(0);
|
||||
VoteTimeQueue vote = call.getArgument(1);
|
||||
if (index == 1 && fail.getAndSet(false)) throw new IllegalStateException("disk unavailable");
|
||||
saved.put(index, vote.getName()); return null;
|
||||
}).when(f.data).addTimeVoted(anyInt(), any());
|
||||
f.handler.addVote("First", "site"); f.handler.addVote("Second", "site");
|
||||
assertThrows(IllegalStateException.class, f.handler::saveForShutdown);
|
||||
assertEquals(Map.of(0, "First"), saved);
|
||||
assertEquals("Second", f.handler.getTimeChangeQueue().peek().getName());
|
||||
f.handler.saveForShutdown(); f.handler.saveForShutdown();
|
||||
f.handler.addVote("Late", "site");
|
||||
assertEquals(Map.of(0, "First", 1, "Second", 2, "Late"), saved);
|
||||
assertTrue(f.handler.getTimeChangeQueue().isEmpty());
|
||||
verify(f.data, times(1)).addTimeVoted(eq(0), any());
|
||||
verify(f.data, times(2)).addTimeVoted(eq(1), any());
|
||||
}
|
||||
|
||||
@Test void lateAdmissionDuringSaveIsAppendedWithoutBeingClearedOrOverwriting() throws Exception {
|
||||
Fixture f = fixture();
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
Map<Integer, String> saved = new ConcurrentHashMap<>();
|
||||
doAnswer(call -> {
|
||||
int index = call.getArgument(0);
|
||||
if (index == 0) { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
saved.put(index, ((VoteTimeQueue) call.getArgument(1)).getName()); return null;
|
||||
}).when(f.data).addTimeVoted(anyInt(), any());
|
||||
f.handler.addVote("First", "site");
|
||||
ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<?> save = workers.submit(f.handler::saveForShutdown);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var adding = new CountDownLatch(1);
|
||||
Future<?> late = workers.submit(() -> { adding.countDown(); f.handler.addVote("Late", "site"); });
|
||||
assertTrue(adding.await(5, TimeUnit.SECONDS)); release.countDown();
|
||||
save.get(5, TimeUnit.SECONDS); late.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(Map.of(0, "First", 1, "Late"), saved);
|
||||
assertTrue(f.handler.getTimeChangeQueue().isEmpty());
|
||||
} finally { release.countDown(); workers.shutdownNow(); assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
|
||||
@Test void admittedProcessingCallbackCannotClearShutdownSnapshot() {
|
||||
Fixture f = fixture();
|
||||
var scheduled = org.mockito.ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(f.plugin.getVoteTimer()).schedule(scheduled.capture(), eq(120L), eq(TimeUnit.SECONDS));
|
||||
f.handler.addVote("First", "site"); f.handler.saveForShutdown();
|
||||
scheduled.getValue().run(); f.handler.processQueue();
|
||||
verify(f.data, never()).clearTimedVoteCache();
|
||||
verify(f.plugin.getServer().getPluginManager(), never()).callEvent(any());
|
||||
}
|
||||
|
||||
@Test void ordinarySaveDoesNotSealFutureProcessing() {
|
||||
Fixture f = fixture();
|
||||
f.handler.addVote("Saved", "site"); f.handler.save();
|
||||
f.handler.addVote("New", "site"); f.handler.processQueue();
|
||||
verify(f.plugin.getServer().getPluginManager(), times(2)).callEvent(any());
|
||||
}
|
||||
|
||||
@Test void shutdownRetainsInFlightEventForReviewWithoutWaitingOrReplaying() throws Exception {
|
||||
Fixture f = fixture();
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var events = f.plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; })
|
||||
.when(events).callEvent(any());
|
||||
f.handler.addVote("InFlight", "site");
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<?> delivery = worker.submit(f.handler::processQueue);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
f.handler.saveForShutdown(); f.handler.saveForShutdown();
|
||||
f.handler.processQueue();
|
||||
var vote = org.mockito.ArgumentCaptor.forClass(VoteTimeQueue.class);
|
||||
verify(f.data, times(1)).addUncertainTimedVote(any(UUID.class), vote.capture());
|
||||
assertEquals("InFlight", vote.getValue().getName());
|
||||
verify(f.data, never()).addTimeVoted(anyInt(), any());
|
||||
assertTrue(f.handler.getTimeChangeQueue().isEmpty());
|
||||
release.countDown(); delivery.get(5, TimeUnit.SECONDS);
|
||||
verify(f.plugin.getServer().getPluginManager(), times(1)).callEvent(any());
|
||||
} finally { release.countDown(); worker.shutdownNow(); assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
|
||||
@Test void shutdownBeforeEventDispatchPersistsVoteForNormalRecovery() throws Exception {
|
||||
Fixture f = fixture();
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var sites = f.plugin.getVoteSiteManager();
|
||||
doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; })
|
||||
.when(sites).getVoteSite(any(), eq(true));
|
||||
f.handler.addVote("NotDispatched", "site");
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<?> delivery = worker.submit(f.handler::processQueue);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
f.handler.saveForShutdown();
|
||||
release.countDown(); delivery.get(5, TimeUnit.SECONDS);
|
||||
verify(f.data).addTimeVoted(eq(0), any());
|
||||
verify(f.data, never()).addUncertainTimedVote(any(), any());
|
||||
verify(f.plugin.getServer().getPluginManager(), never()).callEvent(any());
|
||||
} finally { release.countDown(); worker.shutdownNow(); assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
|
||||
@Test void failedEventAndFailedQuarantineRemainRetryableWithoutRepeatingEvent() {
|
||||
Fixture f = fixture();
|
||||
RuntimeException original = new IllegalStateException("event failure");
|
||||
var events = f.plugin.getServer().getPluginManager();
|
||||
doThrow(original).when(events).callEvent(any());
|
||||
doThrow(new IllegalStateException("disk failure")).doNothing()
|
||||
.when(f.data).addUncertainTimedVote(any(), any());
|
||||
f.handler.addVote("Ambiguous", "site");
|
||||
assertSame(original, assertThrows(IllegalStateException.class, f.handler::processQueue));
|
||||
assertEquals(1, original.getSuppressed().length);
|
||||
assertEquals(1, f.handler.getTimeChangeQueue().size());
|
||||
f.handler.processQueue();
|
||||
f.handler.saveForShutdown(); f.handler.saveForShutdown();
|
||||
var ids = org.mockito.ArgumentCaptor.forClass(UUID.class);
|
||||
verify(f.data, times(2)).addUncertainTimedVote(ids.capture(), any());
|
||||
assertEquals(ids.getAllValues().get(0), ids.getAllValues().get(1));
|
||||
verify(f.plugin.getServer().getPluginManager(), times(1)).callEvent(any());
|
||||
verify(f.data, never()).addTimeVoted(anyInt(), any());
|
||||
}
|
||||
|
||||
@Test void directProcessingPublishesPendingSuffixBeforeHandingOffRecoveredVote() {
|
||||
Fixture f = fixture();
|
||||
f.handler.addVote("Recovered", "site");
|
||||
f.handler.addVote("Later", "site");
|
||||
VoteTimeQueue first = f.handler.getTimeChangeQueue().peek();
|
||||
f.handler.processQueue();
|
||||
var calls = inOrder(f.data, f.plugin.getServer().getPluginManager());
|
||||
calls.verify(f.data).prepareTimedVoteDispatch(eq(first.getVoteId()), eq(first),
|
||||
argThat(pending -> pending.size() == 1 && pending.iterator().next().getName().equals("Later")));
|
||||
calls.verify(f.plugin.getServer().getPluginManager()).callEvent(any());
|
||||
verify(f.data, never()).clearTimedVoteCache();
|
||||
f.handler.addVote("New", "site");
|
||||
f.handler.saveForShutdown();
|
||||
verify(f.data).addTimeVoted(eq(0), any());
|
||||
}
|
||||
|
||||
@TempDir java.nio.file.Path directory;
|
||||
@Test void actualTimedCacheWriteReportsIoFailureAndSucceedsAfterRepair() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(plugin.getServerDataFile()).thenReturn(file);
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
java.nio.file.Path target = directory.resolve("ServerData.yml");
|
||||
java.nio.file.Files.createDirectory(target);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
ServerData data = new ServerData(plugin);
|
||||
VoteTimeQueue vote = new VoteTimeQueue("First", "site", 123L);
|
||||
assertThrows(java.io.UncheckedIOException.class, () -> data.addTimeVoted(0, vote));
|
||||
java.nio.file.Files.delete(target);
|
||||
data.addTimeVoted(0, vote);
|
||||
YamlConfiguration stored = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("First", stored.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals(123L, stored.getLong("VotingPlugin.TimedVoteCache.0.Time"));
|
||||
UUID uncertain = UUID.randomUUID();
|
||||
data.addUncertainTimedVote(uncertain, vote);
|
||||
stored = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("First", stored.getString("VotingPlugin.UncertainTimedVotes." + uncertain + ".Name"));
|
||||
assertEquals(Set.of("0"), data.getTimedVoteCacheKeys());
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package com.bencodez.votingplugin.tests.topvoter;
|
||||
|
||||
import com.bencodez.votingplugin.topvoter.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.advancedcore.AdvancedCoreConfigOptions;
|
||||
import com.bencodez.advancedcore.api.player.UuidLookup;
|
||||
import com.bencodez.advancedcore.api.time.events.DayChangeEvent;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.simpleapi.skull.SkullCache;
|
||||
import com.bencodez.simpleapi.sql.Column;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.backgroundtask.VotingPluginBackgroundTask;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class TopVoterDisplayCompatibilityTest {
|
||||
@Test void backgroundRefreshPreservesBlacklistBanAndIgnorePermissionBeforeDisplayProjection() {
|
||||
VotingPluginMain saved = VotingPluginMain.plugin;
|
||||
try {
|
||||
for (boolean ignorePermission : new boolean[] {true, false}) {
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); VotingPluginMain.plugin = plugin;
|
||||
var config = mock(Config.class); when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(config.getLoadTopVoter(TopVoter.Monthly)).thenReturn(true);
|
||||
when(config.isTopVoterIgnorePermission()).thenReturn(ignorePermission);
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true);
|
||||
when(config.getBlackList()).thenReturn(new ArrayList<>(List.of("SyntheticBlacklisted")));
|
||||
when(plugin.getPlaceholders().getCacheLevel()).thenReturn(PlaceholderCacheLevel.AUTOALL);
|
||||
var storage = mock(ScheduledExecutorService.class);
|
||||
when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(storage);
|
||||
doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }).when(storage).execute(any(Runnable.class));
|
||||
doAnswer(call -> { call.getArgument(0, Consumer.class).accept(java.util.Map.of()); return null; })
|
||||
.when(plugin).captureOnlineTopVoterIgnore(any(), any());
|
||||
var users = new LinkedHashMap<UUID, VotingPluginUser>();
|
||||
String[] names = {"SyntheticNever", "SyntheticJoined", "SyntheticUnknown", "SyntheticIgnored", "SyntheticBlacklisted", "SyntheticBanned"};
|
||||
for (int index = 0; index < names.length; index++) {
|
||||
UUID uuid = new UUID(0, index + 1); var user = mock(VotingPluginUser.class);
|
||||
when(user.getPlayerName()).thenReturn(names[index]);
|
||||
when(user.getTopVoterPlayer()).thenReturn(new TopVoterPlayer(uuid, names[index], 1L));
|
||||
when(user.getTotal(TopVoter.Monthly)).thenReturn(100 - index);
|
||||
when(user.getLastVotes()).thenReturn(new HashMap<>());
|
||||
when(user.isTopVoterIgnore()).thenReturn(index == 3);
|
||||
when(user.isBanned()).thenReturn(index == 5);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false)).thenReturn(user);
|
||||
users.put(uuid, user);
|
||||
}
|
||||
var userManager = plugin.getUserManager();
|
||||
doAnswer(call -> {
|
||||
BiConsumer<UUID, ArrayList<Column>> each = call.getArgument(0);
|
||||
Consumer<Integer> finished = call.getArgument(1);
|
||||
users.forEach((uuid, user) -> each.accept(uuid, new ArrayList<>()));
|
||||
finished.accept(users.size()); return null;
|
||||
}).when(userManager).forEachUserKeys(any(), any());
|
||||
var rankings = new AtomicReference<LinkedHashMap<TopVoter, LinkedHashMap<TopVoterPlayer, Integer>>>();
|
||||
var handler = plugin.getTopVoterHandler();
|
||||
doAnswer(call -> { rankings.set(call.getArgument(0)); return null; })
|
||||
.when(handler).updateTopVoters(any());
|
||||
try (var bukkit = mockStatic(Bukkit.class); var skulls = mockStatic(SkullCache.class)) {
|
||||
var task = new VotingPluginBackgroundTask(plugin); task.setRequested(true); task.run();
|
||||
assertFalse(task.isRunning());
|
||||
}
|
||||
assertNotNull(rankings.get(), "Actual refresh must finish and publish rankings");
|
||||
var raw = rankings.get().get(TopVoter.Monthly);
|
||||
assertEquals(ignorePermission ? 3 : 4, raw.size());
|
||||
List<String> rawNames = raw.keySet().stream().map(TopVoterPlayer::getPlayerName).toList();
|
||||
assertFalse(rawNames.contains("SyntheticBlacklisted")); assertFalse(rawNames.contains("SyntheticBanned"));
|
||||
assertEquals(!ignorePermission, rawNames.contains("SyntheticIgnored"));
|
||||
assertTrue(rawNames.contains("SyntheticNever"));
|
||||
try (var display = new TopVoterDisplayFilter(row -> CompletableFuture.completedStage(
|
||||
row.getPlayerName().equals("SyntheticNever") ? PlayerPresence.NEVER_JOINED
|
||||
: row.getPlayerName().equals("SyntheticUnknown") ? PlayerPresence.UNKNOWN : PlayerPresence.JOINED))) {
|
||||
var visible = display.project(raw, true);
|
||||
assertEquals(raw.size() - 1, visible.size());
|
||||
assertTrue(visible.keySet().stream().anyMatch(row -> row.getPlayerName().equals("SyntheticUnknown")));
|
||||
assertEquals(100, raw.values().iterator().next());
|
||||
}
|
||||
users.values().forEach(user -> verify(user).clearTempCache());
|
||||
verify(plugin, never()).checkPlayerPresenceIdentitiesAsync(anyCollection());
|
||||
}
|
||||
} finally { VotingPluginMain.plugin = saved; }
|
||||
}
|
||||
|
||||
@Test void hiddenDisplayWinnerStillReceivesItsOriginalDailyAward() {
|
||||
VotingPluginMain saved = VotingPluginMain.plugin;
|
||||
try {
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); VotingPluginMain.plugin = plugin;
|
||||
when(plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()).thenReturn(true);
|
||||
when(plugin.getSpecialRewardsConfig().isEnableDailyRewards()).thenReturn(true);
|
||||
when(plugin.getSpecialRewardsConfig().getDailyPossibleRewardPlaces()).thenReturn(java.util.Set.of("1"));
|
||||
when(plugin.getShopFile().getShopIdentifiers()).thenReturn(java.util.Set.of());
|
||||
var user = mock(VotingPluginUser.class);
|
||||
var winner = new TopVoterPlayer(new UUID(0, 1), "SyntheticNever", 1L);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(winner.getUuid(), winner.getPlayerName())).thenReturn(user);
|
||||
var raw = new LinkedHashMap<TopVoterPlayer, Integer>(); raw.put(winner, 100);
|
||||
when(plugin.getTopVoter(TopVoter.Daily)).thenReturn(raw);
|
||||
try (var display = new TopVoterDisplayFilter(row -> CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED))) {
|
||||
assertTrue(display.project(raw, true).isEmpty());
|
||||
var handler = spy(new TopVoterHandler(plugin)); doNothing().when(handler).resetTotals(TopVoter.Daily);
|
||||
handler.onDayChange(mock(DayChangeEvent.class));
|
||||
verify(user).giveDailyTopVoterAward(1, "1");
|
||||
verify(plugin, never()).getTopVoterDisplay(any());
|
||||
assertEquals(100, raw.get(winner));
|
||||
}
|
||||
} finally { VotingPluginMain.plugin = saved; }
|
||||
}
|
||||
|
||||
@Test void replacingAndFailedReplacementCannotReviveOldNegativeCompletion() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
var generationField = VotingPluginMain.class.getDeclaredField("topVoterDisplayGeneration");
|
||||
generationField.setAccessible(true); generationField.set(plugin, new java.util.concurrent.atomic.AtomicLong());
|
||||
doReturn(true).when(plugin).isEnabled(); var config = mock(Config.class);
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true);
|
||||
var options = mock(AdvancedCoreConfigOptions.class); when(options.isOnlineMode()).thenReturn(true);
|
||||
doReturn(options).when(plugin).getOptions(); doNothing().when(plugin).registerEvents(any(TopVoterDisplayFilter.class));
|
||||
doReturn(CompletableFuture.completedStage(PlayerPresence.JOINED)).when(plugin).checkPlayerPresenceIdentitiesAsync(anyCollection(), eq(true));
|
||||
var raw = new LinkedHashMap<TopVoterPlayer, Integer>(); raw.put(new TopVoterPlayer(UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"), "Synthetic", 1L), 100);
|
||||
var state = new TopVoterState(); state.getTopVoters().put(TopVoter.Monthly, raw);
|
||||
var pending = new CompletableFuture<PlayerPresence>(); var predecessor = new TopVoterDisplayFilter(row -> pending);
|
||||
for (var entry : java.util.Map.of("configFile", config, "topVoterState", state, "topVoterDisplayFilter", predecessor).entrySet()) {
|
||||
var field = VotingPluginMain.class.getDeclaredField(entry.getKey()); field.setAccessible(true); field.set(plugin, entry.getValue());
|
||||
}
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
var cache = mock(UuidLookup.class); when(cache.getCachedUUID(anyString())).thenReturn("");
|
||||
try (var lookup = mockStatic(UuidLookup.class)) {
|
||||
lookup.when(UuidLookup::getInstance).thenReturn(cache);
|
||||
plugin.initializeTopVoterDisplayFilter(); pending.complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
doThrow(new IllegalStateException("synthetic registration failure")).when(plugin).registerEvents(any(TopVoterDisplayFilter.class));
|
||||
assertThrows(IllegalStateException.class, plugin::initializeTopVoterDisplayFilter);
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
assertSame(raw, plugin.getTopVoter(TopVoter.Monthly));
|
||||
}
|
||||
}
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
package com.bencodez.votingplugin.tests.topvoter;
|
||||
|
||||
import com.bencodez.votingplugin.topvoter.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresenceService;
|
||||
|
||||
class TopVoterDisplayFilterTest {
|
||||
private TopVoterPlayer row(int id) { return new TopVoterPlayer(new UUID(0, id), "synthetic-" + id, 1L); }
|
||||
private LinkedHashMap<TopVoterPlayer, Integer> board(TopVoterPlayer... rows) {
|
||||
var board = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
for (int index = 0; index < rows.length; index++) board.put(rows[index], 100 - index);
|
||||
return board;
|
||||
}
|
||||
|
||||
@Test void disabledDefaultNeverRequestsEvidenceOrChangesRanksAndTotals() {
|
||||
var first = row(1); var second = row(2); var raw = board(first, second);
|
||||
try (var filter = new TopVoterDisplayFilter(player -> { fail("Disabled filtering must not inspect history"); return null; })) {
|
||||
assertEquals(raw, filter.project(raw, false));
|
||||
assertEquals(java.util.List.of(first, second), java.util.List.copyOf(raw.keySet()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void onlyConfirmedNegativeIsExcludedAndRawAwardRankingIsUntouched() {
|
||||
var first = row(1); var second = row(2); var third = row(3); var raw = board(first, second, third);
|
||||
try (var filter = new TopVoterDisplayFilter(player -> CompletableFuture.completedStage(
|
||||
player.getUuid().equals(first.getUuid()) ? PlayerPresence.NEVER_JOINED
|
||||
: player.getUuid().equals(second.getUuid()) ? PlayerPresence.JOINED : PlayerPresence.UNKNOWN))) {
|
||||
var visible = filter.project(raw, true);
|
||||
assertEquals(java.util.List.of(second, third), java.util.List.copyOf(visible.keySet()));
|
||||
assertEquals(99, visible.get(second)); assertEquals(98, visible.get(third));
|
||||
assertEquals(100, raw.get(first)); assertEquals(3, raw.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void pendingAndFailedReadsStayVisibleAndDuplicateRequestsCoalesce() {
|
||||
var future = new CompletableFuture<PlayerPresence>(); var calls = new AtomicInteger(); var raw = board(row(1));
|
||||
try (var filter = new TopVoterDisplayFilter(player -> { calls.incrementAndGet(); return future; })) {
|
||||
assertEquals(raw, filter.project(raw, true)); assertEquals(raw, filter.project(raw, true));
|
||||
assertEquals(1, calls.get()); future.completeExceptionally(new IllegalStateException("synthetic unavailable"));
|
||||
assertEquals(raw, filter.project(raw, true));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void joinAndRetirementRejectLateNegativeCompletion() {
|
||||
var future = new CompletableFuture<PlayerPresence>(); var player = row(1); var raw = board(player);
|
||||
var fresh = new CompletableFuture<PlayerPresence>();
|
||||
var requests = new java.util.ArrayDeque<CompletableFuture<PlayerPresence>>(); requests.add(future); requests.add(fresh);
|
||||
var filter = new TopVoterDisplayFilter(ignored -> requests.remove());
|
||||
assertEquals(raw, filter.project(raw, true)); filter.joined(player.getUuid(), player.getPlayerName());
|
||||
future.complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals(raw, filter.project(raw, true));
|
||||
filter.close(); fresh.complete(PlayerPresence.NEVER_JOINED); assertEquals(raw, filter.project(raw, true));
|
||||
try (var replacement = new TopVoterDisplayFilter(ignored -> CompletableFuture.completedStage(PlayerPresence.JOINED))) {
|
||||
assertEquals(raw, replacement.project(raw, true));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void staleNegativeIsVisibleAgainUntilRefreshed() {
|
||||
var time = new AtomicLong(); var calls = new AtomicInteger(); var raw = board(row(1));
|
||||
try (var filter = new TopVoterDisplayFilter(player -> calls.incrementAndGet() == 1
|
||||
? CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED) : new CompletableFuture<>(), time::get)) {
|
||||
assertTrue(filter.project(raw, true).isEmpty()); time.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(30));
|
||||
assertEquals(raw, filter.project(raw, true)); assertEquals(2, calls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void anUnobservedRemoteJoinCannotRemainHiddenWhileExpiredEvidenceRefreshes() {
|
||||
var time=new AtomicLong();var calls=new AtomicInteger();var raw=board(row(1));
|
||||
var refresh=new CompletableFuture<PlayerPresence>();
|
||||
try(var filter=new TopVoterDisplayFilter(player->calls.incrementAndGet()==1
|
||||
?CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED):refresh,time::get)) {
|
||||
assertTrue(filter.project(raw,true).isEmpty());
|
||||
time.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(30));
|
||||
// No local joined() event: the shared history may have changed on another server.
|
||||
assertEquals(raw,filter.project(raw,true));
|
||||
assertEquals(raw,filter.project(raw,true));assertEquals(2,calls.get());
|
||||
refresh.complete(PlayerPresence.JOINED);
|
||||
assertEquals(raw,filter.project(raw,true));assertEquals(2,calls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void largeBoardHasBoundedInFlightReadsAndSaturationKeepsRowsVisible() {
|
||||
var calls = new AtomicInteger(); var raw = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
for (int id = 0; id < 10000; id++) raw.put(row(id), id);
|
||||
try (var filter = new TopVoterDisplayFilter(player -> { calls.incrementAndGet(); return new CompletableFuture<>(); })) {
|
||||
assertEquals(raw, filter.project(raw, true)); assertEquals(128, calls.get());
|
||||
assertEquals(raw, filter.project(raw, true)); assertEquals(128, calls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlimitedBoardReusesEveryFreshEvidenceRowWithoutFlickerOrRepeatedReads() {
|
||||
var calls=new AtomicInteger();var pending=new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var raw=new LinkedHashMap<TopVoterPlayer,Integer>();
|
||||
for(int id=0;id<10000;id++)raw.put(row(id),id);
|
||||
try(var filter=new TopVoterDisplayFilter(player->{calls.incrementAndGet();return pending.get()
|
||||
?new CompletableFuture<>():CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED);},()->0L)) {
|
||||
assertTrue(filter.project(raw,true).isEmpty());assertEquals(10000,calls.get());pending.set(true);
|
||||
assertTrue(filter.project(raw,true).isEmpty(),"Fresh negatives must not flicker during an unchanged projection");
|
||||
assertEquals(10000,calls.get());assertEquals(10000,raw.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void loadedCurrentAndHistoricalBoardsShareAStableBudgetAndStillExpireOrInvalidate() {
|
||||
var calls=new AtomicInteger();var time=new AtomicLong();var budget=new AtomicLong(20000);
|
||||
var current=new LinkedHashMap<TopVoterPlayer,Integer>();var historical=new LinkedHashMap<TopVoterPlayer,Integer>();
|
||||
for(int id=0;id<10000;id++){current.put(row(id),id);historical.put(row(id+10000),id);}
|
||||
try(var filter=new TopVoterDisplayFilter(player->java.util.List.of(player.getUuid()),
|
||||
(player,aliases)->{calls.incrementAndGet();return CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED);},
|
||||
time::get,budget::get)) {
|
||||
assertTrue(filter.project(current,true).isEmpty());assertTrue(filter.project(historical,true).isEmpty());
|
||||
assertEquals(20000,calls.get());
|
||||
for(int i=0;i<3;i++){assertTrue(filter.project(current,true).isEmpty());assertTrue(filter.project(historical,true).isEmpty());}
|
||||
assertEquals(20000,calls.get());
|
||||
var joined=current.keySet().iterator().next();filter.joined(joined.getUuid(),joined.getPlayerName());
|
||||
assertTrue(filter.project(current,true).isEmpty());assertEquals(20001,calls.get());
|
||||
time.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(31));
|
||||
assertTrue(filter.project(current,true).isEmpty());assertEquals(30001,calls.get());
|
||||
budget.set(0L);
|
||||
assertTrue(filter.project(board(row(30000)),true).isEmpty());
|
||||
var cache=TopVoterDisplayFilter.class.getDeclaredField("cache");cache.setAccessible(true);
|
||||
assertTrue(((java.util.Map<?,?>)cache.get(filter)).size()<=8192,"Removing loaded boards restores the baseline retention bound");
|
||||
}catch(ReflectiveOperationException failure){throw new AssertionError(failure);}
|
||||
}
|
||||
|
||||
@Test void exactCandidateAsyncPresenceServiceSuppliesCheckedDisplayEvidence() {
|
||||
var tasks = new java.util.ArrayDeque<Runnable>(); var first = row(1); var second = row(2); var raw = board(first, second);
|
||||
try (var service = new PlayerPresenceService(tasks::add,
|
||||
identity -> identity.equals(first.getUuid()) ? PlayerPresence.NEVER_JOINED : PlayerPresence.JOINED);
|
||||
var filter = new TopVoterDisplayFilter(player -> service.checkAsync(player.getUuid()))) {
|
||||
assertEquals(raw, filter.project(raw, true)); tasks.remove().run(); tasks.remove().run();
|
||||
assertEquals(java.util.List.of(second), java.util.List.copyOf(filter.project(raw, true).keySet()));
|
||||
assertEquals(2, raw.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void renamedCheckedAliasRevokesCompletedNegativeForLegacyRow() {
|
||||
var legacy = row(1); UUID current = new UUID(0, 2); var raw = board(legacy);
|
||||
var calls = new AtomicInteger();
|
||||
try (var filter = new TopVoterDisplayFilter(player -> java.util.List.of(player.getUuid(), current),
|
||||
(player, aliases) -> calls.incrementAndGet() == 1
|
||||
? CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED) : new CompletableFuture<>())) {
|
||||
assertTrue(filter.project(raw, true).isEmpty());
|
||||
filter.joined(current, "RenamedSynthetic");
|
||||
assertEquals(raw, filter.project(raw, true));
|
||||
assertEquals(2, calls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void renamedCheckedAliasRejectsPendingNegativeCompletionForLegacyRow() {
|
||||
var legacy = row(1); UUID current = new UUID(0, 2); var raw = board(legacy);
|
||||
var late = new CompletableFuture<PlayerPresence>(); var calls = new AtomicInteger();
|
||||
try (var filter = new TopVoterDisplayFilter(player -> java.util.List.of(player.getUuid(), current),
|
||||
(player, aliases) -> calls.incrementAndGet() == 1 ? late : new CompletableFuture<>())) {
|
||||
assertEquals(raw, filter.project(raw, true));
|
||||
filter.joined(current, "RenamedSynthetic");
|
||||
late.complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals(raw, filter.project(raw, true));
|
||||
assertEquals(2, calls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aliasScopeIsResolvedOnceAndCopiedBeforeProviderAdmission() {
|
||||
var raw = board(row(1)); var calls = new AtomicInteger();
|
||||
var scope = new java.util.ArrayList<UUID>(); scope.add(new UUID(0, 2)); scope.add(null);
|
||||
try (var filter = new TopVoterDisplayFilter(player -> { calls.incrementAndGet(); return scope; },
|
||||
(player, aliases) -> {
|
||||
scope.clear();
|
||||
assertEquals(2, aliases.size()); assertNull(aliases.get(1));
|
||||
assertThrows(UnsupportedOperationException.class, () -> aliases.clear());
|
||||
return CompletableFuture.completedStage(PlayerPresence.UNKNOWN);
|
||||
})) {
|
||||
assertEquals(raw, filter.project(raw, true)); assertEquals(raw, filter.project(raw, true));
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void joinWhileResolvingAliasesCannotPublishStaleNegativeOrConsumeAdmission() {
|
||||
var raw = board(row(1)); var reference = new java.util.concurrent.atomic.AtomicReference<TopVoterDisplayFilter>();
|
||||
var resolutions = new AtomicInteger(); var checks = new AtomicInteger(); UUID alias = new UUID(0, 2);
|
||||
try (var filter = new TopVoterDisplayFilter(player -> {
|
||||
if (resolutions.incrementAndGet() == 1) reference.get().joined(alias, "RenamedSynthetic");
|
||||
return java.util.List.of(player.getUuid(), alias);
|
||||
}, (player, aliases) -> { checks.incrementAndGet(); return CompletableFuture.completedStage(PlayerPresence.JOINED); })) {
|
||||
reference.set(filter);
|
||||
assertEquals(raw, filter.project(raw, true)); assertEquals(0, checks.get());
|
||||
assertEquals(raw, filter.project(raw, true)); assertEquals(1, checks.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unrelatedJoinDoesNotRevokeCompletedAliasEvidence() {
|
||||
var raw = board(row(1)); var checks = new AtomicInteger();
|
||||
try (var filter = new TopVoterDisplayFilter(player -> java.util.List.of(player.getUuid()),
|
||||
(player, aliases) -> { checks.incrementAndGet(); return CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED); })) {
|
||||
assertTrue(filter.project(raw, true).isEmpty()); filter.joined(new UUID(0, 99), "UnrelatedSynthetic");
|
||||
assertTrue(filter.project(raw, true).isEmpty()); assertEquals(1, checks.get());
|
||||
filter.joined(null, "UnrelatedSynthetic"); assertTrue(filter.project(raw, true).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void asyncProjectionAwaitsWorkerWithoutResubmittingExpiredEvidence() {
|
||||
var tasks = new java.util.ArrayDeque<Runnable>(); var first = row(1); var second = row(2);
|
||||
var raw = board(first, second); var time = new AtomicLong();
|
||||
try (var service = new PlayerPresenceService(tasks::add, id -> id.equals(first.getUuid())
|
||||
? PlayerPresence.NEVER_JOINED : PlayerPresence.JOINED);
|
||||
var filter = new TopVoterDisplayFilter(player -> service.checkAsync(player.getUuid()), time::get)) {
|
||||
var result = filter.projectAsync(raw, true).toCompletableFuture();
|
||||
assertFalse(result.isDone()); assertEquals(2, tasks.size());
|
||||
tasks.remove().run(); tasks.remove().run();
|
||||
assertEquals(java.util.List.of(second), java.util.List.copyOf(result.join().keySet()));
|
||||
time.set(java.util.concurrent.TimeUnit.SECONDS.toNanos(31));
|
||||
var refresh = filter.projectAsync(raw, true).toCompletableFuture();
|
||||
assertFalse(refresh.isDone()); tasks.remove().run(); tasks.remove().run();
|
||||
assertEquals(java.util.List.of(second), java.util.List.copyOf(refresh.join().keySet()));
|
||||
assertTrue(tasks.isEmpty()); assertEquals(2, raw.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void joinAndCloseSettleAwaitingSnapshotsWithoutWaitingForStaleWorker() {
|
||||
var raw = board(row(1)); var pending = new CompletableFuture<PlayerPresence>();
|
||||
try (var filter = new TopVoterDisplayFilter(player -> pending)) {
|
||||
var joined = filter.projectAsync(raw, true).toCompletableFuture();
|
||||
filter.joined(row(1).getUuid(), "RenamedSynthetic");
|
||||
assertEquals(raw, joined.join());
|
||||
var retiring = filter.projectAsync(raw, true).toCompletableFuture();
|
||||
filter.close(); assertEquals(raw, retiring.join());
|
||||
pending.complete(PlayerPresence.NEVER_JOINED); assertEquals(raw, retiring.join());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void boundedAsyncDeadlineKeepsUnfinishedRowsVisibleWithoutFinishingProvider() throws Exception {
|
||||
var pending = new CompletableFuture<PlayerPresence>(); var raw = board(row(1));
|
||||
try (var filter = new TopVoterDisplayFilter(player -> pending)) {
|
||||
var result = filter.projectAsync(raw, true).toCompletableFuture();
|
||||
assertFalse(result.isDone());
|
||||
assertEquals(raw, result.get(7, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone());
|
||||
pending.complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals(raw, result.join());
|
||||
}
|
||||
}
|
||||
@Test void pendingNegativeRejectsMappingChangeBeforeCompletion() {
|
||||
var player = row(1); var raw = board(player);
|
||||
var scope = new java.util.concurrent.atomic.AtomicReference<java.util.List<UUID>>(java.util.List.of(player.getUuid()));
|
||||
var request = new CompletableFuture<PlayerPresence>(); var calls = new AtomicInteger();
|
||||
try (var filter = new TopVoterDisplayFilter(ignored -> scope.get(), (ignored, aliases) ->
|
||||
calls.incrementAndGet() == 1 ? request : new CompletableFuture<>())) {
|
||||
var result = filter.projectAsync(raw, true).toCompletableFuture();
|
||||
scope.set(java.util.Arrays.asList(player.getUuid(), new UUID(0, 2), null));
|
||||
request.complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals(raw, result.join());
|
||||
assertEquals(raw, filter.project(raw, true));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void completedNegativeRevalidatesExactScopeIncludingAmbiguityMarker() {
|
||||
var player = row(1); var raw = board(player);
|
||||
var scope = new java.util.concurrent.atomic.AtomicReference<java.util.List<UUID>>(java.util.List.of(player.getUuid()));
|
||||
var calls = new AtomicInteger();
|
||||
try (var filter = new TopVoterDisplayFilter(ignored -> scope.get(), (ignored, aliases) ->
|
||||
calls.incrementAndGet() == 1 ? CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED)
|
||||
: new CompletableFuture<>())) {
|
||||
assertTrue(filter.project(raw, true).isEmpty());
|
||||
scope.set(java.util.Arrays.asList(player.getUuid(), null));
|
||||
assertEquals(raw, filter.project(raw, true));
|
||||
assertEquals(raw, filter.projectAsync(raw, true).toCompletableFuture()
|
||||
.completeOnTimeout(raw, 1, java.util.concurrent.TimeUnit.SECONDS).join());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void asyncProjectionRevalidatesCompletedNegativeWhileOtherRowRemainsPending() {
|
||||
var first = row(1); var second = row(2); var raw = board(first, second);
|
||||
var scope = new java.util.concurrent.atomic.AtomicReference<java.util.List<UUID>>(java.util.List.of(first.getUuid()));
|
||||
var pending = new CompletableFuture<PlayerPresence>();
|
||||
try (var filter = new TopVoterDisplayFilter(player -> player == null ? null
|
||||
: player.getUuid().equals(first.getUuid()) ? scope.get() : java.util.List.of(second.getUuid()),
|
||||
(player, aliases) -> player.getUuid().equals(first.getUuid())
|
||||
? CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED) : pending)) {
|
||||
var result = filter.projectAsync(raw, true).toCompletableFuture();
|
||||
assertFalse(result.isDone());
|
||||
scope.set(java.util.List.of(first.getUuid(), new UUID(0, 3)));
|
||||
pending.complete(PlayerPresence.JOINED);
|
||||
assertEquals(raw, result.join());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void negativeScopeRevalidationFailureKeepsRowVisible() {
|
||||
var player = row(1); var raw = board(player); var failResolution = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
try (var filter = new TopVoterDisplayFilter(ignored -> {
|
||||
if (failResolution.get()) throw new IllegalStateException("synthetic unavailable");
|
||||
return java.util.List.of(player.getUuid());
|
||||
}, (ignored, aliases) -> CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED))) {
|
||||
assertTrue(filter.project(raw, true).isEmpty());
|
||||
failResolution.set(true); assertEquals(raw, filter.project(raw, true));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void negativeCompletionAndProjectionResolveScopeWithoutHoldingEvidenceMonitor() throws Exception {
|
||||
var player = row(1); var raw = board(player); var calls = new AtomicInteger();
|
||||
var reference = new java.util.concurrent.atomic.AtomicReference<TopVoterDisplayFilter>();
|
||||
try (var executor = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
var filter = new TopVoterDisplayFilter(ignored -> {
|
||||
if (calls.incrementAndGet() > 1) {
|
||||
try {
|
||||
executor.submit(() -> reference.get().joined(new UUID(0, 99), "UnrelatedSynthetic"))
|
||||
.get(1, java.util.concurrent.TimeUnit.SECONDS);
|
||||
} catch (Exception failure) { throw new AssertionError("Resolver must run outside evidence monitor", failure); }
|
||||
}
|
||||
return java.util.List.of(player.getUuid());
|
||||
}, (ignored, aliases) -> CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED))) {
|
||||
reference.set(filter);
|
||||
assertTrue(filter.projectAsync(raw, true).toCompletableFuture().get(1, java.util.concurrent.TimeUnit.SECONDS).isEmpty());
|
||||
assertTrue(filter.project(raw, true).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package com.bencodez.votingplugin.tests.topvoter;
|
||||
|
||||
import com.bencodez.votingplugin.topvoter.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.YearMonth;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.advancedcore.AdvancedCoreConfigOptions;
|
||||
import com.bencodez.advancedcore.api.player.UuidLookup;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
|
||||
class TopVoterDisplayIntegrationTest {
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path historyDirectory;
|
||||
private static final UUID NEVER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441");
|
||||
private static final UUID JOINED = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381");
|
||||
|
||||
@Test void shippedAndAbsentConfigurationKeepFilteringDisabled() throws Exception {
|
||||
try (var input = getClass().getClassLoader().getResourceAsStream("Config.yml")) {
|
||||
assertNotNull(input);
|
||||
var yaml = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(
|
||||
new java.io.InputStreamReader(input, java.nio.charset.StandardCharsets.UTF_8));
|
||||
assertFalse(yaml.getBoolean("HideTopVoterIfNotPlayedBefore"));
|
||||
}
|
||||
Config absent = mock(Config.class, CALLS_REAL_METHODS);
|
||||
assertFalse(absent.isHideTopVoterIfNotPlayedBefore());
|
||||
}
|
||||
|
||||
private void field(Object target, String name, Object value) throws Exception {
|
||||
Field field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(target, value);
|
||||
}
|
||||
|
||||
@Test void realPluginBudgetsEvidenceForCurrentLastAndPreviousMonthRankings() throws Exception {
|
||||
var plugin=mock(VotingPluginMain.class,CALLS_REAL_METHODS);
|
||||
field(plugin,"topVoterDisplayGeneration",new java.util.concurrent.atomic.AtomicLong());
|
||||
doReturn(true).when(plugin).isEnabled();var config=mock(Config.class);
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true);field(plugin,"configFile",config);
|
||||
var options=mock(AdvancedCoreConfigOptions.class);when(options.isOnlineMode()).thenReturn(true);
|
||||
doReturn(options).when(plugin).getOptions();doNothing().when(plugin).registerEvents(any(TopVoterDisplayFilter.class));
|
||||
var state=new TopVoterState();field(plugin,"topVoterState",state);
|
||||
var current=state.getTopVoters(TopVoter.Monthly);var last=state.getLastMonthTopVoters();
|
||||
var previous=new LinkedHashMap<TopVoterPlayer,Integer>();var month=YearMonth.of(2024,1);
|
||||
state.getPreviousMonthsTopVoters().put(month,previous);
|
||||
for(int id=0;id<3000;id++) {
|
||||
current.put(new TopVoterPlayer(new UUID(0x4000L,Long.MIN_VALUE|id),"Current"+id,1L),id);
|
||||
last.put(new TopVoterPlayer(new UUID(0x4000L,Long.MIN_VALUE|(id+3000)),"Last"+id,1L),id);
|
||||
previous.put(new TopVoterPlayer(new UUID(0x4000L,Long.MIN_VALUE|(id+6000)),"Previous"+id,1L),id);
|
||||
}
|
||||
var cache=mock(UuidLookup.class);when(cache.getCachedUUID(anyString())).thenReturn("");
|
||||
var calls=new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call->{java.util.Collection<UUID> aliases=call.getArgument(0);assertFalse(aliases.contains(null));
|
||||
calls.incrementAndGet();return CompletableFuture.completedStage(PlayerPresence.NEVER_JOINED);})
|
||||
.when(plugin).checkPlayerPresenceIdentitiesAsync(anyCollection(),eq(true));
|
||||
try(var lookup=mockStatic(UuidLookup.class)) {
|
||||
lookup.when(UuidLookup::getInstance).thenReturn(cache);plugin.initializeTopVoterDisplayFilter();
|
||||
for(int round=0;round<3;round++) {
|
||||
assertTrue(plugin.getTopVoterDisplay(TopVoter.Monthly).isEmpty());
|
||||
assertTrue(plugin.getLastMonthTopVoterDisplay().isEmpty());
|
||||
assertTrue(plugin.getPreviousMonthTopVoterDisplay(month).isEmpty());
|
||||
}
|
||||
assertEquals(9000,calls.get());assertEquals(3000,current.size());
|
||||
assertEquals(3000,last.size());assertEquals(3000,previous.size());
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] nativeHistory(UUID identity) throws Exception {
|
||||
var raw = new java.io.ByteArrayOutputStream();
|
||||
try (var data = new java.io.DataOutputStream(raw)) {
|
||||
data.writeByte(10); data.writeUTF(""); data.writeByte(3); data.writeUTF("DataVersion"); data.writeInt(1);
|
||||
data.writeByte(11); data.writeUTF("UUID"); data.writeInt(4);
|
||||
data.writeInt((int) (identity.getMostSignificantBits() >>> 32)); data.writeInt((int) identity.getMostSignificantBits());
|
||||
data.writeInt((int) (identity.getLeastSignificantBits() >>> 32)); data.writeInt((int) identity.getLeastSignificantBits());
|
||||
data.writeByte(10); data.writeUTF("bukkit"); data.writeByte(8); data.writeUTF("lastKnownName");
|
||||
data.writeUTF("FormerSynthetic\r\n歴史"); data.writeByte(0); data.writeByte(0);
|
||||
}
|
||||
var compressed = new java.io.ByteArrayOutputStream();
|
||||
try (var gzip = new java.util.zip.GZIPOutputStream(compressed)) { gzip.write(raw.toByteArray()); }
|
||||
return compressed.toByteArray();
|
||||
}
|
||||
|
||||
@Test void actualNativeMigrationHistoryPreventsHidingRenamedOnlineRowButOrdinaryOnlineAbsenceWorks() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
field(plugin, "topVoterDisplayGeneration", new java.util.concurrent.atomic.AtomicLong());
|
||||
doReturn(true).when(plugin).isEnabled(); var config = mock(Config.class);
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true); field(plugin, "configFile", config);
|
||||
var options = mock(AdvancedCoreConfigOptions.class); when(options.isOnlineMode()).thenReturn(true);
|
||||
doReturn(options).when(plugin).getOptions(); doNothing().when(plugin).registerEvents(any(TopVoterDisplayFilter.class));
|
||||
var state = new TopVoterState(); field(plugin, "topVoterState", state);
|
||||
var raw = new LinkedHashMap<TopVoterPlayer, Integer>(); raw.put(new TopVoterPlayer(NEVER, "RenamedSynthetic", 1L), 100);
|
||||
state.getTopVoters().put(TopVoter.Monthly, raw);
|
||||
var cache = mock(UuidLookup.class); when(cache.getCachedUUID(anyString())).thenReturn(NEVER.toString());
|
||||
UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
byte[] retained = nativeHistory(former);
|
||||
var tasks = new java.util.ArrayDeque<Runnable>();
|
||||
try (var lookup = mockStatic(UuidLookup.class);
|
||||
var nativeLookup = new com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup(historyDirectory);
|
||||
var service = new com.bencodez.advancedcore.api.user.presence.PlayerPresenceService(tasks::add, nativeLookup)) {
|
||||
lookup.when(UuidLookup::getInstance).thenReturn(cache);
|
||||
doAnswer(call -> service.checkIdentitiesAsync(call.getArgument(0), true))
|
||||
.when(plugin).checkPlayerPresenceIdentitiesAsync(anyCollection(), eq(true));
|
||||
plugin.initializeTopVoterDisplayFilter();
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
while (!tasks.isEmpty()) tasks.remove().run();
|
||||
assertTrue(plugin.getTopVoterDisplay(TopVoter.Monthly).isEmpty(), "Online-only retained scope permits checked negatives");
|
||||
java.nio.file.Path oldRecord = java.nio.file.Files.write(historyDirectory.resolve(former + ".dat"), retained);
|
||||
plugin.initializeTopVoterDisplayFilter();
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
while (!tasks.isEmpty()) tasks.remove().run();
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly), "Current name cache cannot certify former offline names");
|
||||
assertSame(raw, plugin.getTopVoter(TopVoter.Monthly));
|
||||
assertArrayEquals(retained, java.nio.file.Files.readAllBytes(oldRecord));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void offlineExactNameNegativesRemainSupportedButKnownOnlineMigrationRequiresCompleteHistory() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
field(plugin, "topVoterDisplayGeneration", new java.util.concurrent.atomic.AtomicLong());
|
||||
doReturn(true).when(plugin).isEnabled(); var config = mock(Config.class);
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true); field(plugin, "configFile", config);
|
||||
var options = mock(AdvancedCoreConfigOptions.class); when(options.isOnlineMode()).thenReturn(false);
|
||||
doReturn(options).when(plugin).getOptions(); doNothing().when(plugin).registerEvents(any(TopVoterDisplayFilter.class));
|
||||
var state = new TopVoterState(); field(plugin, "topVoterState", state);
|
||||
UUID offline = UUID.nameUUIDFromBytes("OfflinePlayer:RenamedSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var raw = new LinkedHashMap<TopVoterPlayer, Integer>(); raw.put(new TopVoterPlayer(offline, "RenamedSynthetic", 1L), 100);
|
||||
state.getTopVoters().put(TopVoter.Monthly, raw);
|
||||
var cache = mock(UuidLookup.class); when(cache.getCachedUUID(anyString())).thenReturn("");
|
||||
byte[] retained = nativeHistory(former);
|
||||
var oldRecord = java.nio.file.Files.write(historyDirectory.resolve(former + ".dat"), retained);
|
||||
var tasks = new java.util.ArrayDeque<Runnable>();
|
||||
try (var lookup = mockStatic(UuidLookup.class);
|
||||
var nativeLookup = new com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup(historyDirectory);
|
||||
var service = new com.bencodez.advancedcore.api.user.presence.PlayerPresenceService(tasks::add, nativeLookup)) {
|
||||
lookup.when(UuidLookup::getInstance).thenReturn(cache);
|
||||
doAnswer(call -> service.checkIdentitiesAsync(call.getArgument(0)))
|
||||
.when(plugin).checkPlayerPresenceIdentitiesAsync(anyCollection());
|
||||
doAnswer(call -> service.checkIdentitiesAsync(call.getArgument(0), true))
|
||||
.when(plugin).checkPlayerPresenceIdentitiesAsync(anyCollection(), eq(true));
|
||||
plugin.initializeTopVoterDisplayFilter(); plugin.getTopVoterDisplay(TopVoter.Monthly);
|
||||
while (!tasks.isEmpty()) tasks.remove().run();
|
||||
assertTrue(plugin.getTopVoterDisplay(TopVoter.Monthly).isEmpty(), "Pure offline exact-name absence remains checkable");
|
||||
when(cache.getCachedUUID(anyString())).thenReturn(NEVER.toString());
|
||||
plugin.initializeTopVoterDisplayFilter(); plugin.getTopVoterDisplay(TopVoter.Monthly);
|
||||
while (!tasks.isEmpty()) tasks.remove().run();
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly), "Known online migration must account for former offline names");
|
||||
assertSame(raw, plugin.getTopVoter(TopVoter.Monthly));
|
||||
assertArrayEquals(retained, java.nio.file.Files.readAllBytes(oldRecord));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void olderSdkFallbackPreservesPositiveButNeverCertifiesWiderOnlineAbsence() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
field(plugin, "topVoterDisplayGeneration", new java.util.concurrent.atomic.AtomicLong());
|
||||
doReturn(true).when(plugin).isEnabled(); var config = mock(Config.class);
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true); field(plugin, "configFile", config);
|
||||
var options = mock(AdvancedCoreConfigOptions.class); when(options.isOnlineMode()).thenReturn(true);
|
||||
doReturn(options).when(plugin).getOptions(); doNothing().when(plugin).registerEvents(any(TopVoterDisplayFilter.class));
|
||||
var state = new TopVoterState(); field(plugin, "topVoterState", state);
|
||||
var raw = new LinkedHashMap<TopVoterPlayer, Integer>(); raw.put(new TopVoterPlayer(NEVER, "Synthetic", 1L), 100);
|
||||
state.getTopVoters().put(TopVoter.Monthly, raw);
|
||||
doThrow(new NoSuchMethodError("synthetic legacy SDK")).when(plugin).checkPlayerPresenceIdentitiesAsync(anyCollection(), eq(true));
|
||||
var cache = mock(UuidLookup.class); when(cache.getCachedUUID(anyString())).thenReturn(NEVER.toString());
|
||||
try (var lookup = mockStatic(UuidLookup.class)) {
|
||||
lookup.when(UuidLookup::getInstance).thenReturn(cache);
|
||||
for (PlayerPresence legacy : new PlayerPresence[] {PlayerPresence.NEVER_JOINED, PlayerPresence.JOINED}) {
|
||||
doReturn(CompletableFuture.completedStage(legacy)).when(plugin).checkPlayerPresenceIdentitiesAsync(anyCollection());
|
||||
plugin.initializeTopVoterDisplayFilter(); assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
}
|
||||
verify(plugin, times(2)).checkPlayerPresenceIdentitiesAsync(anyCollection());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void runtimeFactoryAndAllDisplayAccessorsKeepAwardMapsUnchanged() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
field(plugin, "topVoterDisplayGeneration", new java.util.concurrent.atomic.AtomicLong());
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
Config config = mock(Config.class); when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true);
|
||||
field(plugin, "configFile", config);
|
||||
var options = mock(AdvancedCoreConfigOptions.class); when(options.isOnlineMode()).thenReturn(true);
|
||||
doReturn(options).when(plugin).getOptions();
|
||||
doNothing().when(plugin).registerEvents(any(TopVoterDisplayFilter.class));
|
||||
doAnswer(call -> {
|
||||
java.util.Collection<UUID> ids = call.getArgument(0);
|
||||
assertFalse(ids.contains(null), "Captured online mode must remain coherent until replacement");
|
||||
return CompletableFuture.completedStage(ids.iterator().next().equals(NEVER)
|
||||
? PlayerPresence.NEVER_JOINED : PlayerPresence.JOINED);
|
||||
}).when(plugin).checkPlayerPresenceIdentitiesAsync(anyCollection(), eq(true));
|
||||
TopVoterState state = new TopVoterState(); field(plugin, "topVoterState", state);
|
||||
var first = new TopVoterPlayer(NEVER, "SyntheticNever", 1L);
|
||||
var second = new TopVoterPlayer(JOINED, "SyntheticJoined", 2L);
|
||||
var raw = new LinkedHashMap<TopVoterPlayer, Integer>(); raw.put(first, 100); raw.put(second, 90);
|
||||
for (TopVoter period : TopVoter.values()) state.getTopVoters().put(period, raw);
|
||||
state.setLastMonthTopVoters(raw); YearMonth historical = YearMonth.of(2026, 1);
|
||||
state.getPreviousMonthsTopVoters().put(historical, raw);
|
||||
UuidLookup cache = mock(UuidLookup.class); when(cache.getCachedUUID(anyString())).thenReturn("");
|
||||
try (var lookup = mockStatic(UuidLookup.class)) {
|
||||
lookup.when(UuidLookup::getInstance).thenReturn(cache);
|
||||
plugin.initializeTopVoterDisplayFilter();
|
||||
when(options.isOnlineMode()).thenReturn(false);
|
||||
for (TopVoter period : TopVoter.values()) {
|
||||
assertEquals(java.util.List.of(second), java.util.List.copyOf(plugin.getTopVoterDisplay(period).keySet()));
|
||||
assertSame(raw, plugin.getTopVoter(period));
|
||||
}
|
||||
assertEquals(java.util.List.of(second), java.util.List.copyOf(plugin.getLastMonthTopVoterDisplay().keySet()));
|
||||
assertEquals(java.util.List.of(second), java.util.List.copyOf(plugin.getPreviousMonthTopVoterDisplay(historical).keySet()));
|
||||
assertSame(raw, plugin.getLastMonthTopVoter()); assertSame(raw, plugin.getPreviousMonthsTopVoters().get(historical));
|
||||
assertEquals(100, raw.get(first)); assertEquals(2, raw.size());
|
||||
verify(plugin, never()).getServer(); verify(plugin, never()).getUserManager();
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(false);
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true);
|
||||
doReturn(false).when(plugin).isEnabled();
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void chatPagesUseVisibleRanksConfiguredPageSizeAndSafeBounds() {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
Config config = mock(Config.class); when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.getFormatPageSize()).thenReturn(3);
|
||||
when(config.getFormatCommandsVoteTopLine()).thenReturn("%num%:%player%:%votes%");
|
||||
when(config.getFormatCommandVoteTopTitle()).thenReturn("%page%/%maxpages%/%Top%");
|
||||
when(config.getFormatTopVoterMonthly()).thenReturn("Monthly");
|
||||
var visible = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
for (int index = 1; index <= 7; index++) visible.put(new TopVoterPlayer(new UUID(0, index), "synthetic" + index, 1L), index);
|
||||
when(plugin.getTopVoterDisplay(any(TopVoter.class))).thenReturn(visible);
|
||||
var handler = new TopVoterHandler(plugin);
|
||||
assertArrayEquals(new String[] {"2/3/Monthly", "4:synthetic4:4", "5:synthetic5:5", "6:synthetic6:6"}, handler.topVoterMonthly(2));
|
||||
assertEquals(4, handler.topVoterMonthly(0).length);
|
||||
assertEquals(1, handler.topVoterMonthly(Integer.MAX_VALUE).length);
|
||||
verify(plugin, never()).getTopVoter(any(TopVoter.class));
|
||||
}
|
||||
|
||||
@Test void failedAndUnavailableRuntimeEvidencePreservesDisplayAndRawState() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
Config config = mock(Config.class); when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true);
|
||||
field(plugin, "configFile", config);
|
||||
var state = new TopVoterState(); field(plugin, "topVoterState", state);
|
||||
var raw = new LinkedHashMap<TopVoterPlayer, Integer>(); raw.put(new TopVoterPlayer(NEVER, "Synthetic", 1L), 1);
|
||||
state.getTopVoters().put(TopVoter.Monthly, raw);
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
field(plugin, "topVoterDisplayFilter", new TopVoterDisplayFilter(row -> {
|
||||
throw new IllegalStateException("synthetic rejected provider");
|
||||
}));
|
||||
assertEquals(raw, plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
assertSame(raw, plugin.getTopVoter(TopVoter.Monthly));
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.bencodez.votingplugin.tests.topvoter;
|
||||
|
||||
import com.bencodez.votingplugin.topvoter.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.block.BlockState;
|
||||
import org.bukkit.block.Sign;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import com.bencodez.advancedcore.api.inventory.BInventory;
|
||||
import com.bencodez.advancedcore.api.inventory.BInventoryButton;
|
||||
import com.bencodez.advancedcore.api.item.ItemBuilder;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.simpleapi.dialog.MultiActionDialogBuilder;
|
||||
import com.bencodez.simpleapi.dialog.UniDialogService;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.commands.gui.player.VoteTopVoter;
|
||||
import com.bencodez.votingplugin.commands.gui.player.VoteTopVoterLastMonth;
|
||||
import com.bencodez.votingplugin.commands.gui.player.VoteTopVoterPreviousMonths;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.config.GUI;
|
||||
import com.bencodez.votingplugin.discord.DiscordHandler;
|
||||
import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.entities.MessageEmbed;
|
||||
|
||||
class TopVoterDisplaySinksTest {
|
||||
private VotingPluginMain previousPlugin;
|
||||
@BeforeEach void savePlugin() { previousPlugin = VotingPluginMain.plugin; }
|
||||
@AfterEach void restorePlugin() { VotingPluginMain.plugin = previousPlugin; }
|
||||
private static final YearMonth MONTH = YearMonth.of(2026, 1);
|
||||
private static final class Fixture {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
final Config config = mock(Config.class);
|
||||
final GUI gui = mock(GUI.class);
|
||||
final Player viewer = mock(Player.class);
|
||||
final VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
final LinkedHashMap<TopVoterPlayer, Integer> raw = new LinkedHashMap<>();
|
||||
Fixture() throws Exception {
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
doReturn(0L).when(plugin).getTopVoterDisplayGeneration();
|
||||
when(config.isHideTopVoterIfNotPlayedBefore()).thenReturn(true);
|
||||
when(gui.getChestVoteTopPlayerItemMaterial()).thenReturn("PAPER");
|
||||
when(gui.getChestVoteTopSwitchItemTopVoters()).thenReturn(new ArrayList<>());
|
||||
when(gui.getChestGUIExtraItems(anyString())).thenReturn(java.util.Set.of());
|
||||
doReturn(gui).when(plugin).getGui();
|
||||
when(user.getPlayerName()).thenReturn("SyntheticViewer");
|
||||
raw.put(new TopVoterPlayer(new UUID(0, 1), "SyntheticNever", 1L), 100);
|
||||
raw.put(new TopVoterPlayer(new UUID(0, 2), "SyntheticJoined", 2L), 90);
|
||||
raw.put(new TopVoterPlayer(new UUID(0, 3), "SyntheticUnknown", 3L), 80);
|
||||
var state = new TopVoterState();
|
||||
for (TopVoter period : TopVoter.values()) state.getTopVoters().put(period, raw);
|
||||
state.setLastMonthTopVoters(raw); state.getPreviousMonthsTopVoters().put(MONTH, raw);
|
||||
set("configFile", config); set("topVoterState", state);
|
||||
VotingPluginMain.plugin = plugin;
|
||||
set("topVoterDisplayFilter", new TopVoterDisplayFilter(row -> CompletableFuture.completedStage(
|
||||
row.getPlayerName().equals("SyntheticNever") ? PlayerPresence.NEVER_JOINED
|
||||
: row.getPlayerName().equals("SyntheticJoined") ? PlayerPresence.JOINED : PlayerPresence.UNKNOWN)));
|
||||
}
|
||||
void set(String name, Object value) throws Exception {
|
||||
var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, value);
|
||||
}
|
||||
void assertRaw() {
|
||||
assertEquals(3, raw.size()); assertEquals(100, raw.values().iterator().next());
|
||||
assertSame(raw, plugin.getTopVoter(TopVoter.Monthly));
|
||||
assertSame(raw, plugin.getLastMonthTopVoter());
|
||||
assertSame(raw, plugin.getPreviousMonthsTopVoters().get(MONTH));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void currentAndHistoricalDialogsUseVisibleContiguousRanks() throws Exception {
|
||||
var f = new Fixture(); var service = mock(UniDialogService.class);
|
||||
doReturn(service).when(f.plugin).getDialogService();
|
||||
for (int board = 0; board < 3; board++) {
|
||||
var dialog = mock(MultiActionDialogBuilder.class, RETURNS_SELF);
|
||||
when(service.multiAction(f.viewer)).thenReturn(dialog);
|
||||
if (board == 0) new VoteTopVoter(f.plugin, f.viewer, f.user, TopVoter.Monthly, 0).onDialog(f.viewer);
|
||||
if (board == 1) new VoteTopVoterLastMonth(f.plugin, f.viewer, f.user).onDialog(f.viewer);
|
||||
if (board == 2) new VoteTopVoterPreviousMonths(f.plugin, f.viewer, f.user, 0).onDialog(f.viewer);
|
||||
verify(dialog).placeholder("position", "1"); verify(dialog).placeholder("position", "2");
|
||||
verify(dialog).placeholder("player", "SyntheticJoined"); verify(dialog).placeholder("player", "SyntheticUnknown");
|
||||
verify(dialog, never()).placeholder("player", "SyntheticNever");
|
||||
verify(dialog).placeholder("votes", "90"); verify(dialog).placeholder("votes", "80");
|
||||
verify(dialog).open(); f.assertRaw();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void currentAndHistoricalChestButtonsUseVisibleRowsAndRanks() throws Exception {
|
||||
var f = new Fixture();
|
||||
for (int board = 0; board < 3; board++) {
|
||||
try (var builders = mockConstruction(ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF));
|
||||
var inventories = mockConstruction(BInventory.class, (inv, context) -> {
|
||||
when(inv.getPageButtons()).thenReturn(new ArrayList<>());
|
||||
})) {
|
||||
if (board == 0) new VoteTopVoter(f.plugin, f.viewer, f.user, TopVoter.Monthly, 0).onChest(f.viewer);
|
||||
if (board == 1) new VoteTopVoterLastMonth(f.plugin, f.viewer, f.user).onChest(f.viewer);
|
||||
if (board == 2) new VoteTopVoterPreviousMonths(f.plugin, f.viewer, f.user, 0).onChest(f.viewer);
|
||||
assertEquals(1, inventories.constructed().size()); var inv = inventories.constructed().getFirst();
|
||||
var buttons = ArgumentCaptor.forClass(BInventoryButton.class);
|
||||
verify(inv, times(2)).addButton(buttons.capture());
|
||||
for (int index = 0; index < 2; index++) {
|
||||
var builder = buttons.getAllValues().get(index).getBuilder();
|
||||
verify(builder).addPlaceholder("position", Integer.toString(index + 1));
|
||||
verify(builder).addPlaceholder("player", index == 0 ? "SyntheticJoined" : "SyntheticUnknown");
|
||||
verify(builder).addPlaceholder("votes", index == 0 ? "90" : "80");
|
||||
}
|
||||
verify(inv).openInventory(f.viewer); f.assertRaw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void signsRenderOneVisibleSnapshotAndNoPlayerAfterLastRank() throws Exception {
|
||||
var f = new Fixture(); var scheduler = mock(BukkitScheduler.class);
|
||||
doReturn(scheduler).when(f.plugin).getBukkitScheduler();
|
||||
Location location = mock(Location.class, RETURNS_DEEP_STUBS);
|
||||
when(location.clone()).thenReturn(location);
|
||||
Location skull = mock(Location.class, RETURNS_DEEP_STUBS);
|
||||
Sign sign = mock(Sign.class); when(location.getBlock().getState()).thenReturn(sign);
|
||||
when(skull.getBlock().getState()).thenReturn(mock(BlockState.class));
|
||||
doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
|
||||
.when(scheduler).runTask(eq(f.plugin), any(Runnable.class), eq(location));
|
||||
doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
|
||||
.when(scheduler).runTaskLater(eq(f.plugin), any(Runnable.class), anyLong(), eq(location));
|
||||
when(f.config.getFormatSignTopVoterSignLine1()).thenReturn("%position%:%player%");
|
||||
when(f.config.getFormatSignTopVoterSignLine2()).thenReturn("%votes%");
|
||||
when(f.config.getFormatSignTopVoterSignLine3()).thenReturn("%SiteName%");
|
||||
when(f.config.getFormatSignTopVoterSignLine4()).thenReturn("");
|
||||
for (String board : List.of("all", "monthly", "weekly", "daily")) {
|
||||
var handler = new SignHandler(f.plugin, "synthetic", location, skull, board, 1);
|
||||
handler.updateLines(); handler.updateSign(0);
|
||||
verify(sign).setLine(0, "1:SyntheticJoined"); verify(sign).setLine(1, "90");
|
||||
clearInvocations(sign);
|
||||
handler = new SignHandler(f.plugin, "synthetic", location, skull, board, 3);
|
||||
handler.updateLines(); handler.updateSign(0);
|
||||
verify(sign).setLine(0, "3:No Player"); verify(sign).setLine(1, "0");
|
||||
clearInvocations(sign); f.assertRaw();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void discordEmbedContainsOnlyVisibleRowsAndRetainsUnknown() throws Exception {
|
||||
var f = new Fixture();
|
||||
var render = DiscordHandler.class.getDeclaredMethod("buildTopVoterEmbed", String.class, LinkedHashMap.class);
|
||||
render.setAccessible(true);
|
||||
var builder = (github.scarsz.discordsrv.dependencies.jda.api.EmbedBuilder) render.invoke(null,
|
||||
"Synthetic board", f.plugin.getTopVoterDisplay(TopVoter.Monthly));
|
||||
MessageEmbed embed = builder.build();
|
||||
String text = embed.getDescription();
|
||||
assertNotNull(text); assertFalse(text.contains("SyntheticNever"));
|
||||
assertTrue(text.contains("SyntheticJoined")); assertTrue(text.contains("SyntheticUnknown"));
|
||||
assertTrue(text.indexOf("SyntheticJoined") < text.indexOf("SyntheticUnknown"));
|
||||
assertTrue(text.contains("90 votes")); assertTrue(text.contains("80 votes")); f.assertRaw();
|
||||
var empty = (github.scarsz.discordsrv.dependencies.jda.api.EmbedBuilder) render.invoke(null,
|
||||
"Synthetic board", new LinkedHashMap<>());
|
||||
assertEquals("No votes recorded yet.", empty.build().getDescription());
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.bencodez.votingplugin.tests.topvoter;
|
||||
|
||||
import com.bencodez.votingplugin.topvoter.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import java.util.ArrayDeque;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresenceService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TopVoterPresenceIdentityTest {
|
||||
private static final UUID ONLINE = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441");
|
||||
private UUID offline(String name) {
|
||||
return UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test void onlineIdentityKeepsExactOfflineMigrationEvidenceWithoutProfileLookup() {
|
||||
var row = new TopVoterPlayer(ONLINE, "Synthetic", 1L);
|
||||
var identities = TopVoterPresenceIdentity.identities(row, true, name -> "");
|
||||
assertEquals(java.util.List.of(ONLINE, offline("Synthetic")), identities);
|
||||
var tasks = new ArrayDeque<Runnable>();
|
||||
try (var service = new PlayerPresenceService(tasks::add,
|
||||
id -> id.equals(offline("Synthetic")) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED)) {
|
||||
var result = service.checkIdentitiesAsync(identities);
|
||||
while (!tasks.isEmpty()) tasks.remove().run();
|
||||
assertEquals(PlayerPresence.JOINED, result.toCompletableFuture().join());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeOfflineUuidUsesExactNameAndAmbiguousNormalizedIdentityStaysUnknown() {
|
||||
var exact = new TopVoterPlayer(offline("Synthetic"), "Synthetic", 1L);
|
||||
assertEquals(java.util.List.of(exact.getUuid()), TopVoterPresenceIdentity.identities(exact, false, name -> ""));
|
||||
var normalized = new TopVoterPlayer(offline("synthetic"), "Synthetic", 1L);
|
||||
var aliases = TopVoterPresenceIdentity.identities(normalized, false, name -> normalized.getUuid().toString());
|
||||
assertEquals(normalized.getUuid(), aliases.getFirst()); assertTrue(aliases.contains(offline("Synthetic")));
|
||||
assertTrue(aliases.contains(null));
|
||||
try (var service = new PlayerPresenceService(Runnable::run, id -> PlayerPresence.NEVER_JOINED)) {
|
||||
assertEquals(PlayerPresence.UNKNOWN, service.checkIdentitiesAsync(aliases).toCompletableFuture().join());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void incompleteIdentityMappingsDoNotBecomeNegativeHistory() {
|
||||
var row = new TopVoterPlayer(ONLINE, "Synthetic", 1L);
|
||||
assertTrue(TopVoterPresenceIdentity.identities(row, true, name -> UUID.randomUUID().toString()).contains(null));
|
||||
assertTrue(TopVoterPresenceIdentity.identities(row, true, name -> "1-1-1-1-1").contains(null));
|
||||
assertTrue(TopVoterPresenceIdentity.identities(row, true, name -> { throw new IllegalStateException("synthetic"); }).contains(null));
|
||||
assertTrue(TopVoterPresenceIdentity.identities(new TopVoterPlayer(offline("Synthetic"), "Synthetic", 1L), true, name -> "").contains(null));
|
||||
assertTrue(TopVoterPresenceIdentity.identities(new TopVoterPlayer(new UUID(0, 1), "Synthetic", 1L), true, name -> "").contains(null));
|
||||
assertTrue(TopVoterPresenceIdentity.identities(new TopVoterPlayer(
|
||||
UUID.fromString("00000000-0000-4000-0000-000000000001"), "Synthetic", 1L), true, name -> "").contains(null));
|
||||
assertTrue(TopVoterPresenceIdentity.identities(null, true, name -> "").contains(null));
|
||||
assertTrue(TopVoterPresenceIdentity.identities(new TopVoterPlayer(ONLINE, " Synthetic ", 1L), true, name -> "").contains(null));
|
||||
}
|
||||
}
|
||||
+30
@@ -32,6 +32,36 @@ class OfflineUuidCompatibilityTest {
|
||||
private static final UUID SERVER = UUID.fromString("0ed7aeff-acc9-337e-8bcb-9ed9388b16d2");
|
||||
private static final UUID STORAGE = UUID.fromString("5fb82a90-e348-3e0a-b7d8-d3f2f8a1cf1b");
|
||||
|
||||
@Test void onlyUuidOnlyOverloadsAreDeprecated() throws Exception {
|
||||
assertTrue(UserManager.class.getMethod("getVotingPluginUser", UUID.class)
|
||||
.isAnnotationPresent(Deprecated.class));
|
||||
assertTrue(UserManager.class.getMethod("getVotingPluginUser", UUID.class, boolean.class)
|
||||
.isAnnotationPresent(Deprecated.class));
|
||||
assertFalse(UserManager.class.getMethod("getVotingPluginUser", UUID.class, String.class)
|
||||
.isAnnotationPresent(Deprecated.class));
|
||||
assertFalse(UserManager.class.getMethod("getVotingPluginUser", Player.class)
|
||||
.isAnnotationPresent(Deprecated.class));
|
||||
assertFalse(UserManager.class.getMethod("getVotingPluginUser", org.bukkit.OfflinePlayer.class)
|
||||
.isAnnotationPresent(Deprecated.class));
|
||||
assertFalse(UserManager.class.getMethod("getVotingPluginUser", String.class)
|
||||
.isAnnotationPresent(Deprecated.class));
|
||||
}
|
||||
|
||||
@Test void uuidAndNameLookupRetainsOfflineNormalization() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
var constructor = UuidLookup.class.getDeclaredConstructor(com.bencodez.advancedcore.AdvancedCorePlugin.class);
|
||||
constructor.setAccessible(true);
|
||||
UuidLookup lookup = constructor.newInstance(f.plugin);
|
||||
try (var staticLookup = mockStatic(UuidLookup.class); var bukkit = mockStatic(Bukkit.class)) {
|
||||
staticLookup.when(UuidLookup::getInstance).thenReturn(lookup);
|
||||
var user = f.users.getVotingPluginUser(SERVER, NAME);
|
||||
assertEquals(STORAGE, user.getJavaUUID());
|
||||
assertEquals(NAME, user.getPlayerName());
|
||||
bukkit.verifyNoInteractions();
|
||||
}
|
||||
verifyNoInteractions(f.logger);
|
||||
}
|
||||
|
||||
@Test void issue1721ResolvesToExistingPointsWithoutCreatingServerUuidData() {
|
||||
Fixture f = new Fixture();
|
||||
f.join();
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package com.bencodez.votingplugin.tests.user;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.advancedcore.api.user.UserData;
|
||||
import com.bencodez.advancedcore.api.user.UserStorage;
|
||||
import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueInt;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueString;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceHolders;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class VotingPluginUserPointReadCompatibilityTest {
|
||||
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
@Test
|
||||
void coldSharedMySqlSnapshotReturnsFailClosedZeroAndQueuesOneRecovery() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
verify(fixture.user, never()).getUserData();
|
||||
verify(fixture.timer, times(1)).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishedBalanceAndGenuineZeroRemainDistinguishableFromLoading() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
UserDataCache cache = published(37);
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
assertEquals(OptionalInt.of(37), fixture.user.getPointsIfLoaded());
|
||||
assertEquals(37, fixture.user.getPoints());
|
||||
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(0))));
|
||||
assertEquals(OptionalInt.of(0), fixture.user.getPointsIfLoaded());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
verify(fixture.timer, never()).execute(any(Runnable.class));
|
||||
verify(fixture.user, never()).getUserData();
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeRowWithoutPointColumnUsesHistoricalZeroDefault() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
UserDataCache cache = new UserDataCache(null, USER_ID);
|
||||
cache.updateCache(new HashMap<>());
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
assertEquals(OptionalInt.of(0), fixture.user.getPointsIfLoaded());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishedSnapshotCannotExposeRetainedOrInvalidatedPointValues() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
UserDataCache cache = published(28);
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
// Retiring even a different column must not make an unverified snapshot readable.
|
||||
cache.invalidateStorageSnapshot("OtherColumn");
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(19))));
|
||||
assertEquals(OptionalInt.of(19), fixture.user.getPointsIfLoaded());
|
||||
|
||||
cache.invalidateStorageSnapshot("Points");
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(6))));
|
||||
assertEquals(6, fixture.user.getPoints());
|
||||
verify(fixture.user, never()).getUserData();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingMutationCannotExposePreviouslyPublishedBalance() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
fixture.caches.put(USER_ID, published(55));
|
||||
|
||||
fixture.placeholders.beginSharedPointDebit(USER_ID);
|
||||
try {
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
verify(fixture.user, never()).getUserData();
|
||||
} finally {
|
||||
fixture.placeholders.finishSharedPointDebit(USER_ID, true, true);
|
||||
}
|
||||
|
||||
assertEquals(OptionalInt.of(55), fixture.user.getPointsIfLoaded());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedPublishedPointValueIsNotTreatedAsKnownZero() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
UserDataCache cache = new UserDataCache(null, USER_ID);
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueString("invalid"))));
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
assertTrue(fixture.user.getPointsIfLoaded().isEmpty());
|
||||
assertEquals(0, fixture.user.getPoints());
|
||||
verify(fixture.timer, never()).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void perServerPointsUseTheirOwnColumn() throws Exception {
|
||||
Fixture fixture = fixture("lobby_Points");
|
||||
UserDataCache cache = new UserDataCache(null, USER_ID);
|
||||
cache.updateCache(new HashMap<>(Map.of(
|
||||
"Points", new DataValueInt(99), "lobby_Points", new DataValueInt(12))));
|
||||
fixture.caches.put(USER_ID, cache);
|
||||
|
||||
assertEquals(OptionalInt.of(12), fixture.user.getPointsIfLoaded());
|
||||
assertEquals(12, fixture.user.getPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mysqlWorkerRetainsTheExistingAuthoritativeReadPath() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
when(fixture.plugin.getUserManager().getDataManager().mustDeferSharedStorageAccess()).thenReturn(false);
|
||||
UserData data = mock(UserData.class);
|
||||
doReturn(data).when(fixture.user).getUserData();
|
||||
when(data.getInt("Points")).thenReturn(63);
|
||||
|
||||
assertEquals(63, fixture.user.getPoints());
|
||||
verify(data).getInt("Points");
|
||||
verify(fixture.timer, never()).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonMysqlStorageKeepsItsExistingReadBehavior() throws Exception {
|
||||
Fixture fixture = fixture("Points");
|
||||
when(fixture.plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
|
||||
UserData data = mock(UserData.class);
|
||||
doReturn(data).when(fixture.user).getUserData();
|
||||
when(data.getInt("Points")).thenReturn(18);
|
||||
|
||||
assertEquals(18, fixture.user.getPoints());
|
||||
assertEquals(OptionalInt.of(18), fixture.user.getPointsIfLoaded());
|
||||
verify(data, times(2)).getInt("Points");
|
||||
verify(fixture.timer, never()).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
private static UserDataCache published(int amount) {
|
||||
UserDataCache cache = new UserDataCache(null, USER_ID);
|
||||
cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(amount))));
|
||||
return cache;
|
||||
}
|
||||
|
||||
private static Fixture fixture(String pointColumn) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
VotingPluginUser user = mock(VotingPluginUser.class, CALLS_REAL_METHODS);
|
||||
Field field = VotingPluginUser.class.getDeclaredField("plugin");
|
||||
field.setAccessible(true);
|
||||
field.set(user, plugin);
|
||||
doReturn(USER_ID.toString()).when(user).getUUID();
|
||||
doReturn(USER_ID).when(user).getJavaUUID();
|
||||
doReturn(pointColumn).when(user).getPointsPath();
|
||||
|
||||
when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getUserManager().getDataManager().mustDeferSharedStorageAccess()).thenReturn(true);
|
||||
when(plugin.getConfigFile().getPlaceholderCacheLevel()).thenReturn(PlaceholderCacheLevel.AUTOALL);
|
||||
|
||||
ConcurrentHashMap<UUID, UserDataCache> caches = new ConcurrentHashMap<>();
|
||||
when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(caches);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
when(plugin.getTimer()).thenReturn(timer);
|
||||
|
||||
PlaceHolders placeholders = new PlaceHolders(plugin);
|
||||
when(plugin.getPlaceholders()).thenReturn(placeholders);
|
||||
return new Fixture(plugin, user, placeholders, caches, timer);
|
||||
}
|
||||
|
||||
private record Fixture(VotingPluginMain plugin, VotingPluginUser user, PlaceHolders placeholders,
|
||||
ConcurrentHashMap<UUID, UserDataCache> caches, ScheduledExecutorService timer) { }
|
||||
}
|
||||
+65
-7
@@ -58,10 +58,11 @@ import com.bencodez.advancedcore.api.user.UserData;
|
||||
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;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
import com.bencodez.simpleapi.folialib.impl.PlatformScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.events.PlayerReceivePointsEvent;
|
||||
|
||||
@@ -108,6 +109,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
void sharedBulkPointMutationsUseOnePersistenceSubmission() throws Exception {
|
||||
PointFixture fixture = pointFixture();
|
||||
VotingPluginUser second = mock(VotingPluginUser.class);
|
||||
when(second.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
|
||||
java.util.List<VotingPluginUser> users = java.util.List.of(fixture.user, second);
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
|
||||
@@ -137,6 +139,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
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<Boolean> results = new java.util.ArrayList<>();
|
||||
@@ -161,6 +164,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
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 @@ class VotingPluginUserPointSchedulingTest {
|
||||
verify(fixture.sql.getConnectionManager(), never()).getConnection();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedDebitCallbackDoesNotRunAfterDisable() throws Exception {
|
||||
PointFixture fixture = pointFixture();
|
||||
AtomicReference<Boolean> 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<Runnable> 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 @@ class VotingPluginUserPointSchedulingTest {
|
||||
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.<String>getArgument(0).startsWith("SELECT `player_uuid`") ? lookup : fixture.statement);
|
||||
AtomicReference<Boolean> result = new AtomicReference<>();
|
||||
fixture.user.removePoints(16, result::set);
|
||||
verify(fixture.plugin.getPlaceholders()).beginSharedPointDebit(uuid);
|
||||
verify(fixture.sql.getConnectionManager(), never()).getConnection();
|
||||
ArgumentCaptor<Runnable> 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<Runnable> 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();
|
||||
@@ -1967,7 +2022,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
|
||||
/** Runs the gate, off-thread journal claim, and Bukkit approval callback in order. */
|
||||
private static void runTransferApprovalGate(ScheduledExecutorService persistence, BukkitScheduler scheduler,
|
||||
VotingPluginMain plugin, ServerImplementation entityScheduler, Player player) {
|
||||
VotingPluginMain plugin, PlatformScheduler entityScheduler, Player player) {
|
||||
ArgumentCaptor<Runnable> gate = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(scheduler).runTask(eq(plugin), gate.capture());
|
||||
gate.getValue().run();
|
||||
@@ -1985,6 +2040,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
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 @@ class VotingPluginUserPointSchedulingTest {
|
||||
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();
|
||||
@@ -2106,9 +2164,9 @@ class VotingPluginUserPointSchedulingTest {
|
||||
return fixture;
|
||||
}
|
||||
|
||||
private static ServerImplementation configureEntityScheduler(VotingPluginMain plugin, BukkitScheduler scheduler) {
|
||||
private static PlatformScheduler configureEntityScheduler(VotingPluginMain plugin, BukkitScheduler scheduler) {
|
||||
FoliaLib folia = mock(FoliaLib.class);
|
||||
ServerImplementation entityScheduler = mock(ServerImplementation.class);
|
||||
PlatformScheduler entityScheduler = mock(PlatformScheduler.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
when(folia.getImpl()).thenReturn(entityScheduler);
|
||||
@@ -2223,7 +2281,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
VotingPluginMain plugin;
|
||||
ScheduledExecutorService persistence;
|
||||
BukkitScheduler scheduler;
|
||||
ServerImplementation entityScheduler;
|
||||
PlatformScheduler entityScheduler;
|
||||
Player player;
|
||||
Player targetPlayer;
|
||||
MySQL table;
|
||||
@@ -2237,7 +2295,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
VotingPluginMain plugin;
|
||||
ScheduledExecutorService persistence;
|
||||
BukkitScheduler scheduler;
|
||||
ServerImplementation entityScheduler;
|
||||
PlatformScheduler entityScheduler;
|
||||
Player player;
|
||||
Player targetPlayer;
|
||||
MySQL table;
|
||||
@@ -2263,7 +2321,7 @@ class VotingPluginUserPointSchedulingTest {
|
||||
VotingPluginMain plugin;
|
||||
ScheduledExecutorService persistence;
|
||||
BukkitScheduler scheduler;
|
||||
ServerImplementation entityScheduler;
|
||||
PlatformScheduler entityScheduler;
|
||||
Player player;
|
||||
Player targetPlayer;
|
||||
MySQL table;
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.simpleapi.folialib.FoliaLib;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
import com.bencodez.simpleapi.folialib.impl.PlatformScheduler;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
@@ -105,7 +105,7 @@ class BukkitCompletionSchedulerTest {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
FoliaLib folia = mock(FoliaLib.class);
|
||||
ServerImplementation entityScheduler = mock(ServerImplementation.class);
|
||||
PlatformScheduler entityScheduler = mock(PlatformScheduler.class);
|
||||
Player player = mock(Player.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
@@ -118,6 +118,6 @@ class BukkitCompletionSchedulerTest {
|
||||
}
|
||||
|
||||
private record Fixture(VotingPluginMain plugin, BukkitScheduler scheduler,
|
||||
ServerImplementation entityScheduler, Player player) {
|
||||
PlatformScheduler entityScheduler, Player player) {
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.bencodez.votingplugin.tests.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class ServerDataPublicationFenceTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void activeGenerationAndRetiringLeasesBlockReplacementOnlyInTheirOwnScope() {
|
||||
Path file = directory.resolve("ServerData.yml");
|
||||
var owner = ServerDataPublicationFence.beginGeneration(file);
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file));
|
||||
var other = ServerDataPublicationFence.beginGeneration(directory.resolve("Other.yml"));
|
||||
var lease = owner.acquire();
|
||||
owner.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file));
|
||||
lease.close(); lease.close();
|
||||
var successor = ServerDataPublicationFence.beginGeneration(file);
|
||||
assertThrows(IllegalStateException.class, owner::acquire);
|
||||
owner.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file));
|
||||
successor.retire(); other.retire();
|
||||
}
|
||||
|
||||
@Test void retiredOwnerCanRetryUntilASuccessorActuallyOwnsThePath() {
|
||||
Path file = directory.resolve("ServerData.yml");
|
||||
var owner = ServerDataPublicationFence.beginGeneration(file);
|
||||
owner.retire();
|
||||
try (var retry = owner.acquire()) {
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file));
|
||||
}
|
||||
var successor = ServerDataPublicationFence.beginGeneration(file);
|
||||
successor.retire();
|
||||
assertThrows(IllegalStateException.class, owner::acquire,
|
||||
"even a stopped successor must fence an older writer");
|
||||
}
|
||||
|
||||
@Test void participatingSeparateClassLoadersShareTheActualOwnershipBoundary() throws Exception {
|
||||
Path file = directory.resolve("ServerData.yml");
|
||||
var owner = ServerDataPublicationFence.beginGeneration(file);
|
||||
var lease = owner.acquire();
|
||||
owner.retire();
|
||||
try (URLClassLoader loader = new URLClassLoader(new java.net.URL[] {
|
||||
ServerDataPublicationFence.class.getProtectionDomain().getCodeSource().getLocation()
|
||||
}, ClassLoader.getPlatformClassLoader())) {
|
||||
Class<?> isolated = Class.forName(ServerDataPublicationFence.class.getName(), true, loader);
|
||||
assertNotSame(ServerDataPublicationFence.class, isolated);
|
||||
var begin = isolated.getMethod("beginGeneration", Path.class);
|
||||
InvocationTargetException blocked = assertThrows(InvocationTargetException.class,
|
||||
() -> begin.invoke(null, file));
|
||||
assertInstanceOf(IllegalStateException.class, blocked.getCause());
|
||||
lease.close();
|
||||
Object successor = begin.invoke(null, file);
|
||||
try (AutoCloseable held = (AutoCloseable) isolated.getMethod("acquire").invoke(successor)) {
|
||||
assertThrows(IllegalStateException.class, owner::acquire);
|
||||
}
|
||||
isolated.getMethod("retire").invoke(successor);
|
||||
} finally { lease.close(); }
|
||||
}
|
||||
|
||||
@Test void completeRegistryWrappersRejectMalformedOwnerDataWithoutTouchingAdjacentContent() throws Exception {
|
||||
Path file = directory.resolve("Événement 🎉").resolve("ServerData.yml");
|
||||
var owner = ServerDataPublicationFence.beginGeneration(file);
|
||||
var field = ServerDataPublicationFence.class.getDeclaredField("key");
|
||||
field.setAccessible(true);
|
||||
String key = (String) field.get(owner);
|
||||
var registry = System.getProperties();
|
||||
String original = registry.getProperty(key);
|
||||
String token = original.substring(0, original.indexOf(':'));
|
||||
String adjacent = "synthetic.fence.adjacent." + token;
|
||||
registry.setProperty(adjacent, "strings/data\nUnicode 🎉\r\nowner:0:false");
|
||||
try {
|
||||
for (Object malformed : List.of(token + ":-1:false", token + ":1025:false", token + ":00:false",
|
||||
token + ":+0:false", token + ":٠:false",
|
||||
token + ":0:False", token + ":0:false:extra", token + ":0:false\n",
|
||||
"\uFEFF" + original, "not-a-uuid:0:false", new java.util.Properties())) {
|
||||
registry.put(key, malformed);
|
||||
assertThrows(IllegalStateException.class, owner::acquire);
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file));
|
||||
assertSame(malformed, registry.get(key));
|
||||
assertEquals("strings/data\nUnicode 🎉\r\nowner:0:false", registry.getProperty(adjacent));
|
||||
}
|
||||
} finally { registry.setProperty(key, original); registry.remove(adjacent); owner.retire(); }
|
||||
}
|
||||
|
||||
@Test void failedReleaseCanRetryAfterOwnershipStateIsRepaired() throws Exception {
|
||||
var owner = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var lease = owner.acquire();
|
||||
owner.retire();
|
||||
var field = ServerDataPublicationFence.class.getDeclaredField("key");
|
||||
field.setAccessible(true);
|
||||
String key = (String) field.get(owner);
|
||||
var registry = System.getProperties();
|
||||
String original = registry.getProperty(key);
|
||||
registry.put(key, "synthetic malformed record");
|
||||
assertThrows(IllegalStateException.class, lease::close);
|
||||
registry.setProperty(key, original);
|
||||
lease.close();
|
||||
var successor = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
successor.retire();
|
||||
}
|
||||
}
|
||||
+7
@@ -16,6 +16,13 @@ import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.util.VoteTaskAdmission;
|
||||
|
||||
class VoteTaskAdmissionTest {
|
||||
@Test
|
||||
void unavailableExecutorIsRejectionWithoutRunningTheTask() {
|
||||
var ran = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
assertFalse(VoteTaskAdmission.trySubmit(null, () -> ran.set(true)));
|
||||
assertFalse(VoteTaskAdmission.trySchedule(null, () -> ran.set(true), 5, TimeUnit.SECONDS));
|
||||
assertFalse(ran.get());
|
||||
}
|
||||
@Test
|
||||
void rejectedSubmissionIsReportedWithoutPropagating() {
|
||||
ScheduledExecutorService executor = mock(ScheduledExecutorService.class);
|
||||
|
||||
+20
-22
@@ -664,8 +664,8 @@ class VoteShopPurchaseServiceTest {
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
|
||||
mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
when(folia.getImpl()).thenReturn(entityScheduler);
|
||||
@@ -745,8 +745,8 @@ class VoteShopPurchaseServiceTest {
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
|
||||
mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class);
|
||||
RewardHandler rewardHandler = mock(RewardHandler.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
@@ -816,8 +816,8 @@ class VoteShopPurchaseServiceTest {
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
|
||||
mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
@@ -850,8 +850,8 @@ class VoteShopPurchaseServiceTest {
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
|
||||
mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
@@ -892,8 +892,8 @@ class VoteShopPurchaseServiceTest {
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
|
||||
mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class);
|
||||
RewardHandler rewardHandler = mock(RewardHandler.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
@@ -955,8 +955,8 @@ class VoteShopPurchaseServiceTest {
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
|
||||
mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class);
|
||||
RewardHandler rewardHandler = mock(RewardHandler.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
@@ -1004,8 +1004,8 @@ class VoteShopPurchaseServiceTest {
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
|
||||
mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class);
|
||||
RewardHandler rewardHandler = mock(RewardHandler.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
@@ -1163,8 +1163,8 @@ class VoteShopPurchaseServiceTest {
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
|
||||
mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
@@ -1197,8 +1197,7 @@ class VoteShopPurchaseServiceTest {
|
||||
void failedCompensationMarkerIsRetriedByPeriodicRecovery(@TempDir Path temporaryDirectory) throws Exception {
|
||||
VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory);
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = plugin.getBukkitScheduler();
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
plugin.getBukkitScheduler().getFoliaLib().getImpl();
|
||||
var entityScheduler = plugin.getBukkitScheduler().getFoliaLib().getImpl();
|
||||
when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class)))
|
||||
.thenReturn(EntityTaskResultTestCompat.schedulerRetired());
|
||||
SharedMysqlPurchaseJournalAccess journal = SharedMysqlPurchaseJournalAccess.mock();
|
||||
@@ -1232,8 +1231,7 @@ class VoteShopPurchaseServiceTest {
|
||||
private static VotingPluginMain mockPluginForCompensation(Path dataDirectory) {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile());
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
plugin.getBukkitScheduler().getFoliaLib().getImpl();
|
||||
var entityScheduler = plugin.getBukkitScheduler().getFoliaLib().getImpl();
|
||||
when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class)))
|
||||
.thenReturn(EntityTaskResultTestCompat.schedulerRetired());
|
||||
return plugin;
|
||||
@@ -1541,8 +1539,8 @@ class VoteShopPurchaseServiceTest {
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
|
||||
mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler entityScheduler =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
AtomicReference<Runnable> claimWork = new AtomicReference<>();
|
||||
doAnswer(invocation -> {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Reconciling uncertain cooldown rewards
|
||||
|
||||
Cooldown flags record that the cooldown event was raised. They do not prove that every reward effect completed. A reward may contain several effects, and a failure can occur after some effects have already happened.
|
||||
|
||||
For configured cooldown rewards, the server log records `Cooldown reward status=attempted` with an occurrence ID, player UUID, site (or `all`), and time. This means the reward listener attempted dispatch; it is not proof of delivery. An asynchronous terminal failure produces a warning with the same context, `outcome=unknown/partial`, and an injection count when available. That count is a diagnostic, not a list of commands or proof that the other effects did not happen. More than one warning can refer to the same occurrence when a configured reward list has multiple failures. A timer failure has a separate correlation ID and may happen before or after an event.
|
||||
|
||||
Before considering any manual correction:
|
||||
|
||||
1. Preserve the relevant server logs, configuration and reward definitions, and the occurrence ID, UUID, site, and time. Check logs from the server that processed the event.
|
||||
2. For each configured effect, inspect independent records from the affected system. A failed command can have an uncertain outcome; the log alone cannot establish whether it applied. An unavailable or ambiguous record means the effect remains unknown.
|
||||
3. After identifying an independently confirmed missing effect, an authorized operator can issue only that effect and record the correction. Do not reset cooldown flags or replay the complete reward merely because a warning appeared: earlier effects may already have completed.
|
||||
|
||||
These logs are best-effort diagnostics, not a durable reward journal. An event cancelled by another plugin, disabled reward processing, a deferred reward, an internally caught legacy requirement error, logger failure, shutdown, or a crash can leave no terminal warning. A crash between the flag change and the listener's attempted-dispatch log can leave no occurrence entry at all. Missing logs do not establish success or failure, and a logged attempt does not establish the outcome. Keep external transaction evidence for reconciliation; do not assume exactly-once delivery.
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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.
|
||||
|
||||
## Direct Java API balances
|
||||
|
||||
`VotingPluginUser.getPoints()` remains an `int` for existing integrations such as
|
||||
EconomyShopGUI. When called on a Bukkit/Folia-owned thread with MySQL storage,
|
||||
it reads only a published point snapshot and returns **0** instead of throwing
|
||||
`Shared user data is still loading` when the snapshot is unavailable. This
|
||||
matches the legacy integer default but is a **fail-closed compatibility value**:
|
||||
0 does not establish that the player really has no points.
|
||||
|
||||
New integrations should call `VotingPluginUser.getPointsIfLoaded()` to
|
||||
distinguish a known zero (`OptionalInt.of(0)`) from loading or uncertain state
|
||||
(`OptionalInt.empty()`). In MySQL mode this method is a snapshot-only read,
|
||||
never a blocking SQL lookup; a cache miss schedules bounded worker recovery
|
||||
when that state can safely be refreshed. Other storage types retain their
|
||||
existing read behavior.
|
||||
|
||||
An invalidated snapshot, a pending debit or an indeterminate mutation must
|
||||
not be replaced with a remembered balance. The direct API shares the
|
||||
point-placeholder mutation fence. Display balances are advisory and are not
|
||||
reservations: purchases must use confirmed, transaction-backed debits before
|
||||
granting their rewards. An unchanged shop integration may now reject a click
|
||||
as insufficient funds while the user's points load, but should not crash on
|
||||
the transient read failure. Retry after the snapshot is published.
|
||||
|
||||
Avoid using `UserDataFetchMode.CACHE_ONLY` as a publication check: it may
|
||||
return the integer default even when the shared snapshot is not published,
|
||||
without distinguishing missing data from a genuine zero.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Redis dependency compatibility
|
||||
|
||||
The dependency versions in SimpleAPI, AdvancedCore and VotingPlugin describe different packaging boundaries. Equal version strings alone do not prove that a consumer can link or that a shaded JAR contains the intended implementation.
|
||||
|
||||
- SimpleAPI's full artifact preserves the established Jedis 7 API, including `JedisPooled`, `JedisSentineled` and the public `UnifiedJedis` constructors. Its existing full-artifact test compiles and links a legacy consumer using only that JAR. Jedis 8 removes those APIs; replacing the producer dependency without a migration would break the full-artifact contract.
|
||||
- AdvancedCore excludes the ordinary transitive Jedis dependency from SimpleAPI. This exclusion does not strip classes already embedded in SimpleAPI's full JAR. The published AdvancedCore artifact therefore still contains that producer's Redis implementation.
|
||||
- VotingPlugin filters `redis/clients/**` from both producer JARs and explicitly selects its own Jedis client. These filters must remain together: removing only a Maven transitive dependency does not prevent a fat producer JAR from shadowing the selected client.
|
||||
|
||||
VotingPlugin's package checks verify the selected implementation's class bytes, including an anchor that distinguishes the 8.0.1 and 8.0.2 patch artifacts. They compile and load the relocated SimpleAPI Redis handler using only the downloadable plugin JAR and the platform logging API. The same constructor, listener, TLS configuration and close checks run separately against the actual normal AdvancedCore and SimpleAPI dependency JARs; their legacy constructors remain checked in isolated class loaders. Maven's test dependency classpath cannot supply a missing Redis class to these probes.
|
||||
|
||||
Keep the SimpleAPI legacy exclusion until a separately reviewed compatibility-preserving migration exists. Evaluate each dependency update against its actual public API, supported Java version, resolved artifacts and downstream packaged linkage. A passed constructor/linkage check is not a Redis broker integration test: the probes do not publish, subscribe, borrow a connection or start a service. Native and live transport acceptance remain separate.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Startup failure and shutdown
|
||||
|
||||
VotingPlugin may be unloaded after a partial startup. Shutdown therefore treats each optional subsystem independently:
|
||||
a failed close does not prevent the remaining queues, reminders, cooldown checks, signs, handlers, or executors from being
|
||||
cleaned up. Each unload step is marked complete only after it returns successfully. A repeated unload retries steps
|
||||
that propagated a failure and skips successful steps, while timed persistence continues through its existing coalesced
|
||||
retry owner. A timer whose shutdown fails retains its reference for the next cleanup attempt. The vote timer uses a bounded termination wait, forces cancellation if it does not terminate, and restores
|
||||
the interrupt flag when shutdown is interrupted. Concurrent unload callbacks do not start a second cleanup pass; repeated callbacks remain safe.
|
||||
|
||||
## Building the complete SQL failure fix
|
||||
|
||||
The MySQL authentication-failure regression exercises the corrected SimpleAPI and AdvancedCore constructors.
|
||||
SimpleAPI PR #111 and AdvancedCore PR #361 supply the merged constructor corrections. The generation-fenced final
|
||||
publication integration additionally requires the AdvancedCore publication and shutdown APIs from PR #360.
|
||||
An AdvancedCore snapshot without those APIs cannot compile this candidate. The POM retains the normal
|
||||
AdvancedCore and SimpleAPI snapshot coordinates; it does not pin the earlier SimpleAPI snapshot 40.
|
||||
|
||||
Validate the exact candidate AdvancedCore artifact and its SimpleAPI scheduler inputs, including existing consumer
|
||||
binary compatibility. Repeat consumer validation against compatible normally published dependencies before marking
|
||||
the integration ready. A passing build with a locally reconstructed candidate does not establish normal dependency
|
||||
delivery or native server acceptance. CI no longer builds an older AdvancedCore source checkout before testing VotingPlugin.
|
||||
|
||||
Run the focused lifecycle and timed-vote regressions, then the required clean package:
|
||||
|
||||
```shell
|
||||
mvn -B -f VotingPlugin/pom.xml -Dtest=VotingPluginMainShutdownTest,TimeQueueShutdownPersistenceTest,TimeQueueRecoveryCheckpointTest,TimeQueueHandlerRejectionTest,VoteTaskAdmissionTest,VotiferEventDisabledVoteSiteTest,VotifierVoteOverflowQueueTest,CorePlatformIsolationTest test
|
||||
mvn -B -f VotingPlugin/pom.xml clean package
|
||||
```
|
||||
|
||||
The distributable VotingPlugin JAR includes the required libraries. Server owners do not need a separate SimpleAPI or
|
||||
AdvancedCore installation, storage fallback, configuration migration or schema migration. Invalid MySQL credentials
|
||||
still abort startup. The automated SQL test injects an in-process JDBC authentication rejection; it does not establish
|
||||
live external MySQL or native Paper/Folia startup behavior.
|
||||
|
||||
Timed-vote shutdown publication shares an admission fence with queue additions and cache-clearing callbacks. A task that
|
||||
outlives the executor's bounded termination wait persists its late timed vote through that same owner. Shutdown saves retire successfully saved prefixes; an unsuccessful write retains its vote and cache index for a later
|
||||
unload retry, without repeating unrelated cleanup. Timed-cache writes report I/O failures rather than relying on the legacy file helper's
|
||||
log-only error handling. Ordinary `TimeQueueHandler.save()` checkpoints pending votes while keeping their live queue entries; `saveForShutdown()` seals processing.
|
||||
The existing ServerData file format and persistence model are retained: this is not a new transactional reward system or
|
||||
an exactly-once guarantee across arbitrary vote event handlers, chat, rewards and disk writes.
|
||||
|
||||
A timed vote remains owned while its event is being prepared. If shutdown wins before event dispatch, it is saved for ordinary timed-cache recovery. Once event dispatch starts, external handlers may already have acted: shutdown or a thrown dispatch error retains that occurrence under `VotingPlugin.UncertainTimedVotes.<id>` in `ServerData.yml` and logs a warning. These records contain the original name, service and timestamp and are **not automatically replayed**. An administrator must compare them with voting data/logs before deciding whether any recovery is needed; a completed event can still leave a conservative review record when shutdown raced it. Repeated persistence attempts use the same ID. Shutdown does not wait indefinitely for external event handlers.
|
||||
|
||||
Before each event handoff, the queue saves its undispatched suffix and a conservative in-flight review record together.
|
||||
A confirmed normal event return clears only that occurrence's review record. Cancellation also checkpoints any later
|
||||
admissions, then continues draining other queued votes. A later checkpoint failure retains the undispatched suffix
|
||||
both live and recoverable. Direct and scheduled processing use the same recovery boundary; neither
|
||||
clears the whole recovery cache before the first event. Failed preparation does not invoke the event, and failed
|
||||
completion publication does not automatically repeat an event that already returned. These writes use the existing
|
||||
ServerData YAML persistence model, not a crash-atomic transaction or an exactly-once external-effects guarantee.
|
||||
|
||||
A successful quarantine releases the finished dispatch's live guard so unaffected votes can resume. A failed quarantine
|
||||
keeps its original identity until a later processing or unload attempt can retain it; that attempt does not invoke the
|
||||
ambiguous event again. In-flight records are transient during normal processing, and a crash before actual dispatch can
|
||||
leave a conservative record too. Stop processing before manually reconciling retained records; their presence alone does
|
||||
not prove a reward ran or failed.
|
||||
|
||||
The stopped vote timer remains visible until overflow and event-handler cleanup finishes. A missing timer is treated as
|
||||
rejected admission, allowing a late Votifier callback to use the existing overflow path rather than fail on a null
|
||||
reference. No accepted task is run inline on the event thread as a fallback.
|
||||
|
||||
Timed-vote shutdown persistence has one retained daemon worker per handler. Each shutdown call waits at most one second
|
||||
for that worker, including when a preparation/completion write holds the queue monitor or the shutdown write itself
|
||||
stalls. Repeated calls join the same pending work; a completed failure remains retryable with the original queue/index.
|
||||
An interrupted wait preserves the caller's interrupt flag and does not cancel persistence. A timeout reports pending
|
||||
ownership, **not confirmed recovery**: if the process exits before storage resumes, new pending writes may not finish.
|
||||
Stopping is published before waiting, so a stalled preparation cannot authorize a new event when it resumes after stop.
|
||||
This bound covers the timed-queue persistence handoff; it does not promise a deadline for every other subsystem's I/O.
|
||||
|
||||
Participating VotingPlugin generations reserve the server-data path in the JVM before AdvancedCore initializes or reads
|
||||
its file. A replacement fails before startup if the previous generation still owns a write or shutdown handoff. It does
|
||||
not wait for that filesystem operation on the lifecycle thread. The pending worker keeps its reservation through the
|
||||
entire write and until stopped vote producers terminate, including an empty initial snapshot followed by late admission
|
||||
and an initial publication failure. Disable also retains one coalesced retirement reservation independently of the timed
|
||||
queue, covering AdvancedCore's heartbeat, login, inventory and time-checker executors until they actually terminate.
|
||||
Failed startup remains reserved until its disable cleanup runs; a missing timed queue does not imply those writers stopped.
|
||||
SDK server-data saves also retain the generation directly at both normal and durable time-transition publication hooks,
|
||||
so platform-scheduled callbacks outside those executors, including the SDK version update, cannot overwrite a successor.
|
||||
The adapter delegates to the existing SDK configuration/file rather than keeping a copied YAML state. VotingPlugin's
|
||||
server-data wrapper remains bound to its original file and generation even if the plugin object is enabled again.
|
||||
Lazy first access validates ownership before exposing mutable configuration. A retained SDK file keeps its predecessor
|
||||
fence during re-enable until AdvancedCore actually creates the replacement file.
|
||||
After that work ends, normal startup can proceed. Stale generations cannot publish through these server-data save methods
|
||||
after a successor owns the path; failed shutdown writes remain retryable until such replacement.
|
||||
This coordinates participating generations at the same normalized path within a JVM, including separate class loaders;
|
||||
it is not a multi-process or filesystem-alias coordination contract. Existing YAML, dependencies and configuration remain
|
||||
unchanged. A full process restart clears the old JVM's workers and in-memory reservations.
|
||||
|
||||
Normal preparation, completion and quarantine/checkpoint failures retain their existing vote/identity and request one
|
||||
coalesced retry with bounded backoff. Retry admission does not reset failure backoff; successful processing does. The
|
||||
processing guard is released before requesting another attempt. Shutdown fences those callbacks, and ambiguous completed
|
||||
events are quarantined without being dispatched again. The installed SimpleAPI asynchronous delay is in seconds; retries
|
||||
start at one second and cap at sixty seconds. Rejected retry scheduling reports retained ownership, not durable
|
||||
recovery that has not been confirmed.
|
||||
|
||||
VoteParty current, required and remaining placeholders read an immutable live display
|
||||
snapshot. Startup initializes it before placeholder registration. Ordinary mutations
|
||||
refresh it through the backing SDK owner before disk publication, including when a
|
||||
callback changes live counters and then fails. Setup, load, reload and case-mode
|
||||
changes refresh it from the same authoritative file. Native final publication keeps
|
||||
staged counters hidden until the real owner completes; cancelling a caller's
|
||||
observation does not cancel that owner or release its generation. A stalled writer therefore cannot hold a placeholder request on
|
||||
the file publication monitor. This projection represents live counters, including
|
||||
mutations whose save fails; it is not a durable-write acknowledgement. Reward and
|
||||
vote accounting continue to use authoritative state rather than this projection.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Optional top-voter presence display
|
||||
|
||||
`HideTopVoterIfNotPlayedBefore: false` in `Config.yml` preserves existing displays by default.
|
||||
When enabled, only identities with confirmed `NEVER_JOINED` history are omitted from current and
|
||||
historical top-voter displays. Joined, pending and unknown history remain visible. This setting
|
||||
does not remove vote totals, modify stored rankings or change the internal top-voter award inputs.
|
||||
Existing blacklist and ignore-permission decisions still apply upstream.
|
||||
|
||||
Chest/dialog menus, chat pages, top-list/rank placeholders, signs and Discord use display snapshots.
|
||||
Visible rows have consecutive display positions and keep their original vote totals. Global total
|
||||
placeholders and the raw ranking APIs remain unchanged. Display-specific positions should not be
|
||||
treated as award eligibility: internal awards continue using the raw ranking.
|
||||
|
||||
Checks run through AdvancedCore's checked asynchronous presence API. Display requests do not load
|
||||
user storage or invoke server/profile history loaders on the server owner. Offline identity evidence
|
||||
uses the exact native name spelling; uncertain normalized, migrated or unsupported identities stay
|
||||
visible. Current UUID and retained offline UUID history can preserve positive migration evidence.
|
||||
This cannot reconstruct deleted history or establish ownership of unrelated historical names.
|
||||
Online negatives and offline rows with a known online migration alias additionally require a readable stable history scope without unmapped offline/non-online
|
||||
records or observed joins. A current-name cache entry alone cannot prove that a former offline name never
|
||||
joined. Ordinary online-only history still supports negative results; unmapped migration evidence remains
|
||||
visible. Older SDKs preserve positive history but cannot certify this wider negative scope.
|
||||
|
||||
Pending results remain visible until a later display refresh. After 30 seconds, expired negative evidence also becomes visible while it is rechecked. GUI, chat and placeholder requests do not wait for those checks, so the first view after expiry can include previously hidden rows. At most 128 checks run concurrently; a large expired board may require several refreshes. Retaining expired negatives could hide a player whose join occurred on another server without a local join event.
|
||||
|
||||
Evidence is cached for up to 30 seconds,
|
||||
with at most 128 requests in flight. Evidence capacity starts at 8,192 identities and grows to the
|
||||
row count already loaded across current, last-month and historical ranking maps. Unlimited boards
|
||||
therefore retain fresh checked evidence without cycling it out on each projection; removed boards
|
||||
reduce the capacity on later projections. Saturation and failures retain rows.
|
||||
Negative evidence revalidates the exact current cache-only identity scope, including uncertainty markers,
|
||||
at completion and display. Joins invalidate matching negative/pending evidence; reload and shutdown retire the display generation.
|
||||
Checked historical aliases are included in join invalidation even after a name change. Discord prepares
|
||||
its snapshot asynchronously before publishing, with a five-second evidence deadline and one pending
|
||||
preparation per leaderboard. Overlapping requests coalesce without restarting that deadline; their current visible projection is published
|
||||
when the original preparation finishes. Stale reload callbacks are discarded, and
|
||||
unavailable evidence stays visible without a duplicate preliminary post.
|
||||
One snapshot supplies each rendered row's name and vote count. Historical menus inspect only their
|
||||
selected month rather than resolving every saved board.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
mvn -B -f VotingPlugin/pom.xml -Dtest=TopVoterDisplayFilterTest,TopVoterPresenceIdentityTest,TopVoterDisplayIntegrationTest,TopVoterStateTest,TopVoterLoaderTest test
|
||||
```
|
||||
|
||||
Before enabling on a server, check current and historical menus with synthetic joined, never-joined,
|
||||
unknown and migrated identities. Confirm chest/dialog navigation, chat page size, placeholders, signs
|
||||
and Discord positions; verify ordinary awards and blacklist/ignore behavior still use their original
|
||||
rankings. Join with an identity previously omitted, reload the option, and confirm stale callbacks do
|
||||
not re-hide it. Automated focused tests are separate from a live server/gameplay check.
|
||||
|
||||
Sign refreshes wait once for their admitted presence checks (with the same bounded preparation deadline), then render current evidence on the sign location's owning scheduler. Overlapping refreshes coalesce; reload, disable or changed sign metadata discards the older completion. A sign-only interval longer than the evidence TTL therefore still repaints after checks complete. Sign lines use a detached render. Skull updates run on each skull location’s own scheduler, recheck the current projected position, and discard superseded frames or changed metadata. Preferred and nearby skull discovery also dispatches each block access to its owning location.
|
||||
|
||||
Discord also rechecks current visible evidence when queued publication actually runs, including when no second update requested coalescing. A join or identity-scope change between preparation and publication cannot preserve a stale omitted-row snapshot. These final checks do not restart the asynchronous deadline or alter raw rankings and awards.
|
||||
|
||||
When filtering is enabled, the five player-position placeholders project current evidence on each request instead of reusing a persistent rank result. Their work remains bounded and cache-only; the configured placeholder cache behavior remains unchanged for other placeholders and when filtering is disabled. This keeps ranks aligned with list displays after completion, evidence expiry, joins and filter retirement.
|
||||
|
||||
Discord deleted-message recovery returns to the existing worker, rebuilds the replacement from current evidence and checks the display generation. Initial sends, edits and recovery share distinct publication ownership, so late or duplicate callbacks cannot overwrite a successor’s message ID or retire its request. Pending sends and ordinary edits coalesce another refresh; a stored-message edit keeps its bounded slot until the network callback, including across reload, so refreshes do not submit overlapping edits for that message. An explicitly requested replacement generation then prepares its own presence evidence before publishing. Clearing an ID instead detaches the old target and permits a new post. Recovery reprojects after blocking ID persistence, and persistence rechecks ownership after acquiring the save monitor. New-message mode keeps its existing persistence policy. Publication is checked when admitted to the Discord request; joins or lifecycle changes after the external request is sent cannot atomically cancel Discord's network delivery.
|
||||
|
||||
An enabled filtered sign with no row at its position clears only its explicitly configured skull or automatic locations successfully painted by that sign in the current display scope. It does not discover and clear unknown neighboring skulls for an empty row. Missing cached profiles use the checked name fallback instead of clearing the existing profile. Fallback name assignment and the block-state update must both succeed before an automatic association is recorded; both painting and clearing recheck the current display after profile assignment. Automatic associations are bounded, reset with changed sign metadata or display generations, and retired after a successful clear; failed updates can retry. Block access and profile changes stay on each location's owning scheduler. Visible-row discovery and existing empty-sign behavior when the option is false remain unchanged.
|
||||
|
||||
Successful persistent Discord sends retain their created-message receipt independently of display retirement. If completion is discarded before its ID can be recorded, the handler requests asynchronous deletion through JDA, including when the plugin worker rejects completion. A successful local ID write remains tracked across retirement; a later recorded replacement retires its predecessor without overwriting a newer receipt. Duplicate callbacks do not delete a retained message. Append/new-message mode intentionally keeps its posts. Discord deletion can fail independently of local storage: a redacted warning asks the operator to remove the unwanted post manually; this is not a transaction across Discord and the local ID store or a hard-crash recovery guarantee.
|
||||
|
||||
|
||||
Persistent Discord sends register a bounded process-owned receipt before handing completion to the worker. Reload retires obsolete generations’ queued receipts before work can be cancelled; current-generation preparation, queued refreshes and successful-send receipts survive the older generation’s retirement; disable/unload closes receipt admission and reconciles abandoned successes through JDA. A write already in progress retains ownership and reports uncertainty rather than deleting a possibly saved post. Clearing an ID deliberately detaches the existing posts without deleting them, and its storage work runs on the existing worker before reporting a confirmed result. This includes sends already reported successful but still awaiting worker completion; clearing one ranking period does not detach another period’s pending post.
|
||||
|
||||
The existing ServerData writer remains authoritative. A separate saved-file read checks whether its ID is actually observable, because legacy void saves can swallow I/O failures and custom writers can defer work. An unconfirmed outcome keeps the receipt in memory, emits a redacted storage warning, and retries the save after a later successful edit. A confirmed replacement reclaims older owned receipts; a failed or uncertain replacement leaves them owned. At most 128 unresolved/current persistent-send receipts are retained; at capacity a new successful send is reclaimed without admitting another storage write. Check storage and reconcile posts before restarting after a warning. File readback is not an fsync guarantee or a transaction with Discord, and an external deletion can still fail independently.
|
||||
Reference in new issue
Block a user