mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-04 09:02:00 -05:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a6e47a80 | ||
|
|
5fc7d5a67b | ||
|
|
f5b8c669a3 | ||
|
|
22ba18f481 | ||
|
|
1a19feebbb | ||
|
|
a39d2cf77f | ||
|
|
21204495bd | ||
|
|
b510322a09 | ||
|
|
7821ccaaff | ||
|
|
229c096086 | ||
|
|
b5c759570a | ||
|
|
7321ea5da4 | ||
|
|
832772f382 | ||
|
|
fda4b09909 | ||
|
|
6cbc180d7b | ||
|
|
51e2396f0d | ||
|
|
6a5c124ec3 | ||
|
|
54cffb5f95 | ||
|
|
a1905322ae | ||
|
|
fa39ec9b7c | ||
|
|
0be5deedfe | ||
|
|
9865b50938 | ||
|
|
65498873f7 | ||
|
|
4615be0eaa | ||
|
|
c2446c5151 |
No files matched your search
@@ -357,3 +357,7 @@ Control behavior in production.
|
||||
- After a MEX write, say exactly what changed and its sharing boundary: a local draft is checkout-only and nothing is shared; a canonical artifact is written to the working tree and requires commit/push to share.
|
||||
- Skill activation is not approval for canonical actions.
|
||||
<!-- mex-agent:skills:end -->
|
||||
|
||||
## Guided proxy session confirmation
|
||||
|
||||
Guided sessions never award votes or rewards. Proxy confirmation requires a bounded versioned session-fence reply matching the player, backend and random session nonce; keep the first fence for each source. New original proxy ingress assigns an opaque UUIDv8 before asynchronous handoff, with random source incarnation and monotonically increasing sequence. Preserve that canonical ID through every journal, retry, cache and multi-proxy hop; forwarding or replay must never mint new occurrence order. Do not compare proxy clocks with backend clocks or infer freshness from delivery/queue flags. New post-fence delayed/queued votes can confirm only through the existing credited pipeline; older, legacy, fake/test and unsupported-source occurrences remain unconfirmed without changing normal accounting. Unknown peers must preserve ordinary voting. No cache schema migration or new reward path.
|
||||
@@ -52,3 +52,7 @@ or disabling it; inspections gate disabled state immediately and report a newly
|
||||
```
|
||||
|
||||
`LATEST` resolves to the latest stable release.
|
||||
|
||||
## Guided voting session
|
||||
|
||||
Optional `/vote session` chat navigation tracks real accepted votes without changing existing menus or rewards. See [configuration, controls and timing limits](docs/guided-voting-session.md).
|
||||
+4
-11
@@ -271,7 +271,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.6.1</version>
|
||||
<version>3.5.4</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-test</id>
|
||||
@@ -316,13 +316,6 @@
|
||||
<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>
|
||||
@@ -408,7 +401,7 @@
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
<version>2.7</version>
|
||||
<version>2.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.spigotmc</groupId>
|
||||
@@ -431,7 +424,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.15.0</version>
|
||||
<version>2.14.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -482,7 +475,7 @@
|
||||
<dependency>
|
||||
<groupId>redis.clients</groupId>
|
||||
<artifactId>jedis</artifactId>
|
||||
<version>8.0.2</version>
|
||||
<version>8.0.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.clip</groupId>
|
||||
|
||||
@@ -156,6 +156,13 @@ import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private final com.bencodez.votingplugin.session.GuidedVotingSessions guidedVotingSessions =
|
||||
new com.bencodez.votingplugin.session.GuidedVotingSessions(this);
|
||||
|
||||
public com.bencodez.votingplugin.session.GuidedVotingSessions getGuidedVotingSessions() {
|
||||
return guidedVotingSessions;
|
||||
}
|
||||
|
||||
|
||||
@Getter
|
||||
public static VotingPluginMain plugin;
|
||||
@@ -287,7 +294,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Getter
|
||||
private TopVoterState topVoterState;
|
||||
private volatile com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter topVoterDisplayFilter;
|
||||
|
||||
|
||||
@Getter
|
||||
@@ -339,9 +345,6 @@ 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();
|
||||
@@ -530,84 +533,6 @@ 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();
|
||||
}
|
||||
@@ -797,7 +722,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onPostLoad() {
|
||||
serverData.refreshVotePartyDisplay();
|
||||
ensureCommunicationSecret();
|
||||
// auto conversion for Shop.yml
|
||||
if (plugin.getShopFile().isJustCreated()) {
|
||||
@@ -885,7 +809,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
});
|
||||
|
||||
topVoterState = new TopVoterState();
|
||||
initializeTopVoterDisplayFilter();
|
||||
topVoterHandler = new TopVoterHandler(this);
|
||||
|
||||
new AdminGUI(this).loadHook();
|
||||
@@ -2015,95 +1938,9 @@ 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"))) {
|
||||
@@ -2145,68 +1982,16 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
stopAutoCreateVoteSitesSetupReminder();
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
|
||||
try {
|
||||
runShutdownStep("offline vote handoffs", () -> {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
|
||||
});
|
||||
guidedVotingSessions.clear();
|
||||
shutdownVoteReminders();
|
||||
} finally {
|
||||
try { shutdownVoteReminders(); }
|
||||
finally {
|
||||
try { super.onDisable(); }
|
||||
finally { runShutdownStep("server-data publication owner", () -> {
|
||||
if (serverDataPublicationFence != null) serverDataPublicationFence.retire();
|
||||
}); }
|
||||
}
|
||||
super.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -2234,107 +2019,50 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onUnLoad() {
|
||||
runShutdownStep("server-data process drain", this::beginServerDataProcessDrain);
|
||||
if (!unloadInProgress.compareAndSet(false, true)) {
|
||||
runShutdownStep("time queue retry", () -> {
|
||||
if (timeQueueHandler != null) timeQueueHandler.saveForShutdown();
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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); }
|
||||
}
|
||||
|
||||
private void beginServerDataProcessDrain() {
|
||||
var owner = serverDataPublicationFence;
|
||||
if (owner != null) owner.beginShutdown(System.nanoTime());
|
||||
}
|
||||
|
||||
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) {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.close();
|
||||
stopAutoCreateVoteSitesSetupReminder();
|
||||
placeholderPlayerPresence.clear();
|
||||
stopBackendHostedControlLifecycle();
|
||||
stopBackendControlConnectorLifecycle();
|
||||
if (getBackendProxyHandler() != null) {
|
||||
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.
|
||||
getBackendProxyHandler().closeForShutdown();
|
||||
} catch (Exception e) {
|
||||
debug(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void shutdownVoteTimer() {
|
||||
if (voteTimer == null) return;
|
||||
if (backendOrderedVoteOverflowQueue != null) {
|
||||
backendOrderedVoteOverflowQueue.close();
|
||||
backendOrderedVoteOverflowQueue = null;
|
||||
}
|
||||
if (webhookManager != null) {
|
||||
webhookManager.shutdown();
|
||||
}
|
||||
voteTimer.shutdown();
|
||||
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();
|
||||
voteTimer.awaitTermination(1, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
voteTimer.shutdownNow();
|
||||
if (votifierVoteOverflowQueue != null) {
|
||||
votifierVoteOverflowQueue.close();
|
||||
votifierVoteOverflowQueue = null;
|
||||
}
|
||||
if (timeQueueHandler != null) {
|
||||
timeQueueHandler.save();
|
||||
}
|
||||
|
||||
shutdownVoteReminders();
|
||||
|
||||
if (coolDownCheck != null) {
|
||||
coolDownCheck.shutdown();
|
||||
coolDownCheck = null;
|
||||
}
|
||||
|
||||
getSigns().storeSigns();
|
||||
HandlerList.unregisterAll(plugin);
|
||||
plugin = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2378,10 +2106,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
pm.registerEvents(new PlayerJoinEvent(this), this);
|
||||
if (isVotifierLoaded()) {
|
||||
VotiferEvent votifierEvent = new VotiferEvent(this);
|
||||
votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, votifierEvent::processVote);
|
||||
votifierVoteOverflowQueue = VotifierVoteOverflowQueue.withObservations(this, votifierEvent::processVote);
|
||||
pm.registerEvents(votifierEvent, this);
|
||||
}
|
||||
pm.registerEvents(new PlayerVoteListener(this), this);
|
||||
pm.registerEvents(guidedVotingSessions, this);
|
||||
pm.registerEvents(new PlayerPostVoteLoggerListener(this), this);
|
||||
pm.registerEvents(new PlayerSpecialRewardLoggerListener(this), this);
|
||||
pm.registerEvents(new VoteShopPurchaseLoggerListener(this), this);
|
||||
@@ -2436,7 +2165,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
}, () -> { }, () -> { }));
|
||||
}
|
||||
|
||||
static UUID placeholderStorageUuid(Player player, boolean onlineMode) {
|
||||
/** Snapshot-only UUID resolution; call while owning the player, before storage work. */
|
||||
public static UUID placeholderStorageUuid(Player player, boolean onlineMode) {
|
||||
if (onlineMode) return player.getUniqueId();
|
||||
String resolvedUuid = UuidLookup.getInstance().getUUIDWithoutStorage(player.getName());
|
||||
try {
|
||||
@@ -2456,11 +2186,12 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl,
|
||||
boolean updateActiveBackendRuntime) {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.pause();
|
||||
retireTopVoterDisplayFilter();
|
||||
try {
|
||||
guidedVotingSessions.clear();
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.suspend();
|
||||
configFile.reloadData();
|
||||
configFile.loadValues();
|
||||
if (commandLoader != null) commandLoader.refreshGuidedSessionCommands();
|
||||
AutoCreateVoteSitesReminderService setupReminder = autoCreateVoteSitesSetupReminder;
|
||||
if (setupReminder != null) setupReminder.reload(autoCreateVoteSitesReminderSettings());
|
||||
|
||||
@@ -2477,7 +2208,6 @@ 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();
|
||||
|
||||
+12
-3
@@ -57,7 +57,12 @@ public class BackendProxyMessageRouter {
|
||||
}
|
||||
|
||||
public void register(GlobalMessageHandler messages, BungeeMethod method) {
|
||||
this.messages = messages;
|
||||
this.messages = messages;
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REPLY) {
|
||||
@Override public void onReceive(JsonEnvelope message) {
|
||||
plugin.getGuidedVotingSessions().proxyFence(message);
|
||||
}
|
||||
});
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE) {
|
||||
@Override public void onReceive(JsonEnvelope msg) { handleWireVote(msg); }
|
||||
});
|
||||
@@ -715,11 +720,15 @@ public class BackendProxyMessageRouter {
|
||||
user.cache();
|
||||
boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : user.isOnline();
|
||||
boolean queuedDelivery = vote.queuedDeliveryKnown ? vote.queuedDelivery : vote.delayValidated;
|
||||
// Even an immediate proxy send can arrive after a guide opened on this backend.
|
||||
// Transport freshness flags cannot establish original occurrence order. The guide
|
||||
// separately validates the canonical ingress ID against its negotiated source fence.
|
||||
var event = user.bungeeVotePluginMessagingEvent(vote.service, vote.time, totals, !vote.manageTotals,
|
||||
wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown,
|
||||
vote.queuedDeliveryKnown, voteId,
|
||||
VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()));
|
||||
if ((event == null || !event.isCancelled()) && plugin.getBungeeSettings().isPerServerPoints()) {
|
||||
VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()),
|
||||
true, vote.realVote);
|
||||
if ((event == null || !event.isCancelled()) && plugin.getBungeeSettings().isPerServerPoints()) {
|
||||
user.addPoints(plugin.getConfigFile().getPointsOnVote());
|
||||
}
|
||||
if (vote.service != null && !vote.service.isEmpty()) {
|
||||
|
||||
@@ -104,6 +104,10 @@ import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
|
||||
public class CommandLoader {
|
||||
private static final int BULK_PLAYER_CAPTURE_BATCH_SIZE = 64;
|
||||
private final Set<CommandHandler> guidedSessionCommands = new HashSet<>();
|
||||
private volatile CommandHandler guidedSessionPermissionHandler;
|
||||
private final java.util.concurrent.atomic.AtomicLong guidedPermissionRevision = new java.util.concurrent.atomic.AtomicLong();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean guidedPermissionsManaged = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final MaintenanceConfirmation maintenanceConfirmation =
|
||||
new MaintenanceConfirmation();
|
||||
|
||||
@@ -607,28 +611,6 @@ 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<>());
|
||||
|
||||
@@ -865,7 +847,7 @@ public class CommandLoader {
|
||||
@Override
|
||||
public void executeSinglePlayer(CommandSender sender, String[] args) {
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]);
|
||||
if (!UserStorage.MYSQL.equals(plugin.getStorageType())) user.cache();
|
||||
user.cache();
|
||||
user.removePoints(Integer.parseInt(args[3]), removed -> {
|
||||
if (!removed) {
|
||||
runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize(
|
||||
@@ -2969,7 +2951,9 @@ public class CommandLoader {
|
||||
public void execute(CommandSender sender, String[] args) {
|
||||
String topVoter = args[1];
|
||||
TopVoter top = TopVoter.getTopVoter(topVoter);
|
||||
clearDiscordMessageId(sender, top);
|
||||
plugin.getServerData().setTopVoterMessageId(top, 0);
|
||||
plugin.getDiscordHandler().getTopVoterMessageIds().put(top, 0L);
|
||||
sendMessage(sender, "&cCleared discord message ID for " + top.toString());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3294,6 +3278,7 @@ public class CommandLoader {
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshGuidedSessionPermissionIndex();
|
||||
}
|
||||
|
||||
static void registerAdditionalPermissions(PluginManager manager, CommandHandler handler) {
|
||||
@@ -3649,7 +3634,83 @@ public class CommandLoader {
|
||||
/**
|
||||
* Load vote command.
|
||||
*/
|
||||
/** Refresh only guide-owned handlers; preserve production/custom registrations and command-map aliases. */
|
||||
public void refreshGuidedSessionCommands() {
|
||||
boolean previouslyRegistered = !guidedSessionCommands.isEmpty();
|
||||
long permissionRevision = guidedPermissionRevision.incrementAndGet();
|
||||
ArrayList<CommandHandler> handlers = new ArrayList<>(plugin.getVoteCommand());
|
||||
handlers.removeAll(guidedSessionCommands);
|
||||
guidedSessionCommands.clear();
|
||||
if (plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)) {
|
||||
for (String action : java.util.List.of("", "next", "previous", "skip", "check", "finish", "restart")) {
|
||||
String[] path = action.isEmpty() ? new String[] { "Session" } : new String[] { "Session", action };
|
||||
var sessionCommand = plugin.getGuidedVotingSessions().commandHandler(action);
|
||||
CommandHandler handler = new CommandHandler(plugin, path,
|
||||
"VotingPlugin.Commands.Vote.Session|" + playerPerm, "Guided voting session", false) {
|
||||
@Override public void execute(CommandSender sender, String[] args) {
|
||||
sessionCommand.accept((Player) sender);
|
||||
}
|
||||
};
|
||||
if (plugin.getConfigFile().getDisabledCommands().stream().noneMatch(handler.getPerm()::contains)) {
|
||||
if (plugin.getConfigFile().getDisabledDefaultPermissions().stream()
|
||||
.anyMatch(disabled -> handler.getPerm().contains(disabled + "|"))) {
|
||||
handler.setPerm(handler.getPerm().replace("|" + playerPerm, ""));
|
||||
}
|
||||
guidedSessionCommands.add(handler);
|
||||
handlers.add(guidedSessionCommands.size() - 1, handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
plugin.setVoteCommand(handlers);
|
||||
refreshGuidedSessionPermissionIndex();
|
||||
if (previouslyRegistered || !guidedSessionCommands.isEmpty()) guidedPermissionsManaged.set(true);
|
||||
if (guidedPermissionsManaged.get()) {
|
||||
String inheritedPermission = "VotingPlugin.Commands.Vote.Session|" + playerPerm;
|
||||
boolean inherit = plugin.getConfigFile().getDisabledDefaultPermissions().stream()
|
||||
.noneMatch(disabled -> inheritedPermission.contains(disabled + "|"))
|
||||
&& handlers.stream().anyMatch(handler -> handler.getPerm().contains(inheritedPermission));
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
if (guidedPermissionRevision.get() != permissionRevision) return;
|
||||
Permission parent = Bukkit.getPluginManager().getPermission(playerPerm);
|
||||
if (parent == null) return;
|
||||
if (inherit) parent.getChildren().put("VotingPlugin.Commands.Vote.Session", true);
|
||||
else parent.getChildren().remove("VotingPlugin.Commands.Vote.Session");
|
||||
parent.recalculatePermissibles();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Use the registered built-in command policy for guide-owned callbacks and refreshes. */
|
||||
public boolean canUseGuidedSession(Player player) {
|
||||
CommandHandler handler = guidedSessionPermissionHandler;
|
||||
return handler != null && handler.hasPerm(player);
|
||||
}
|
||||
|
||||
/** Keep the session lookup consistent with routing without registering or replacing Bukkit aliases. */
|
||||
private void refreshGuidedSessionPermissionIndex() {
|
||||
CommandHandler selected = null;
|
||||
for (CommandHandler handler : plugin.getVoteCommand()) {
|
||||
if (!handler.isCommand("Session")) continue;
|
||||
if (guidedSessionCommands.contains(handler)) {
|
||||
if (handler.getArgs().length == 1) {
|
||||
selected = handler;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Preserve the existing alias index's last-handler policy when the guide is disabled.
|
||||
selected = handler;
|
||||
}
|
||||
}
|
||||
guidedSessionPermissionHandler = selected != null && guidedSessionCommands.contains(selected) ? selected : null;
|
||||
if (commands == null) return;
|
||||
final CommandHandler indexed = selected;
|
||||
commands.entrySet().removeIf(entry -> entry.getKey().equalsIgnoreCase("voteSession"));
|
||||
if (indexed != null) commands.put("voteSession", indexed);
|
||||
}
|
||||
|
||||
private void loadVoteCommand() {
|
||||
guidedSessionPermissionHandler = null;
|
||||
guidedSessionCommands.clear();
|
||||
plugin.setVoteCommand(new ArrayList<>());
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" },
|
||||
"VotingPlugin.Commands.Vote.Help|" + playerPerm, "View help page") {
|
||||
@@ -3716,6 +3777,8 @@ public class CommandLoader {
|
||||
}
|
||||
});
|
||||
|
||||
refreshGuidedSessionCommands();
|
||||
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "URL", "(SiteName)" },
|
||||
"VotingPlugin.Commands.Vote.URL.VoteSite", "Open VoteURL GUI for VoteSite", false) {
|
||||
|
||||
|
||||
+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.getTopVoterDisplay(top)
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin.getTopVoter(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
|
||||
.getTopVoterDisplay(top).clone();
|
||||
.getTopVoter(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.getLastMonthTopVoterDisplay().entrySet();
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getLastMonthTopVoter().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.getLastMonthTopVoterDisplay().entrySet();
|
||||
users = plugin.getLastMonthTopVoter().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.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getPreviousMonthsTopVoters().get(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.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
|
||||
users = plugin.getPreviousMonthsTopVoters().get(yearMonth).entrySet();
|
||||
|
||||
BInventory inv = new BInventory(plugin.getGui().getChestVoteTopName());
|
||||
inv.addPlaceholder("topvoter", yearMonth.toString());
|
||||
|
||||
@@ -809,10 +809,6 @@ 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,13 +11,10 @@ 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;
|
||||
@@ -39,14 +36,12 @@ 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.
|
||||
@@ -79,8 +74,6 @@ 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()) {
|
||||
@@ -154,7 +147,6 @@ public class CoolDownCheck implements Listener {
|
||||
user.updateTempCacheWithColumns(cols);
|
||||
cols.clear(); // help GC (only useful if reused)
|
||||
}
|
||||
capturePlayerName(user);
|
||||
|
||||
checkPerSite(site, user);
|
||||
} finally {
|
||||
@@ -227,20 +219,13 @@ 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 coolDownChecks;
|
||||
return;
|
||||
}
|
||||
|
||||
if (coolDownChecks == null) {
|
||||
coolDownChecks = user.getCoolDownCheckSiteList();
|
||||
}
|
||||
HashMap<String, Boolean> coolDownChecks = user.getCoolDownCheckSiteList();
|
||||
if (Boolean.TRUE.equals(coolDownChecks.get(site.getKey()))) {
|
||||
return coolDownChecks;
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.canVoteSite(site)) {
|
||||
@@ -252,7 +237,6 @@ public class CoolDownCheck implements Listener {
|
||||
PlayerVoteSiteCoolDownEndEvent event = new PlayerVoteSiteCoolDownEndEvent(user, site);
|
||||
plugin.getServer().getPluginManager().callEvent(event);
|
||||
}
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,8 +267,8 @@ public class CoolDownCheck implements Listener {
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCoolDownEnd(PlayerVoteCoolDownEndEvent event) {
|
||||
dispatchCooldownReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
|
||||
"VoteCoolDownEndedReward", "all", new RewardOptions());
|
||||
plugin.getRewardHandler().giveReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
|
||||
"VoteCoolDownEndedReward", new RewardOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,73 +278,11 @@ public class CoolDownCheck implements Listener {
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCoolDownEnd(PlayerVoteSiteCoolDownEndEvent event) {
|
||||
dispatchCooldownReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
|
||||
"site:" + event.getSite().getKey(), new RewardOptions()
|
||||
.addPlaceholder("sitename", event.getSite().getDisplayName())
|
||||
plugin.getRewardHandler().giveReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
|
||||
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.
|
||||
*
|
||||
@@ -402,7 +324,7 @@ public class CoolDownCheck implements Listener {
|
||||
* @param force whether to force the schedule
|
||||
*/
|
||||
public void schedulePerSite(VotingPluginUser user, boolean force) {
|
||||
if (stopped || !plugin.getConfigFile().isPerSiteCoolDownEvents()) {
|
||||
if (!plugin.getConfigFile().isPerSiteCoolDownEvents()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -411,7 +333,6 @@ public class CoolDownCheck implements Listener {
|
||||
}
|
||||
|
||||
final UUID uuid = user.getJavaUUID();
|
||||
final String scheduledName = user.getPlayerName();
|
||||
|
||||
final NextSite next = user.getNextSiteAvailable();
|
||||
|
||||
@@ -419,13 +340,8 @@ 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
|
||||
}
|
||||
@@ -433,58 +349,19 @@ public class CoolDownCheck implements Listener {
|
||||
plugin.devDebug("PerSiteCoolDownEvent schedule: site=" + next.getSite().getKey() + " time="
|
||||
+ next.getSecondsUntilAvailable() + " 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;
|
||||
return timer.schedule(() -> {
|
||||
if (!plugin.isEnabled()) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -504,14 +381,6 @@ 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.
|
||||
*/
|
||||
@@ -552,11 +421,9 @@ 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++) {
|
||||
coolDownChecks = checkPerSite(dailySites.get(i), user, coolDownChecks);
|
||||
checkPerSite(dailySites.get(i), user);
|
||||
}
|
||||
|
||||
check(user);
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Ephemeral, observation-only progress. Never invokes vote or reward processing. */
|
||||
public final class GuidedVoteSession {
|
||||
public enum Status { AWAITING, RECEIVED, UNAVAILABLE, SKIPPED }
|
||||
public record Site(String key, String name, String url, boolean eligible, long lastVote) { }
|
||||
public record Entry(Site site, Status status) { }
|
||||
public record View(List<Entry> entries, int index, boolean finished) {
|
||||
public Entry current() { return entries.isEmpty() ? null : entries.get(index); }
|
||||
public long received() { return entries.stream().filter(e -> e.status() == Status.RECEIVED).count(); }
|
||||
public boolean complete() { return !entries.isEmpty() && entries.stream().allMatch(e -> e.status() == Status.RECEIVED); }
|
||||
}
|
||||
private final long started;
|
||||
private final long observationOrder;
|
||||
private final Map<String, Site> candidates = new LinkedHashMap<>();
|
||||
private boolean candidatesBound;
|
||||
private final Map<String, Site> sites = new LinkedHashMap<>();
|
||||
private final Map<String, Long> received = new LinkedHashMap<>();
|
||||
private final java.util.Set<String> skipped = new java.util.HashSet<>();
|
||||
private final Map<UUID, String> occurrences = new LinkedHashMap<>();
|
||||
private final Map<UUID, ProxyVoteSequence.Fence> proxyFences = new LinkedHashMap<>();
|
||||
private final Map<UUID, String> pendingProxy = new LinkedHashMap<>();
|
||||
private boolean initialized;
|
||||
private boolean finished;
|
||||
private int index;
|
||||
|
||||
public GuidedVoteSession(long started) { this(started, VoteObservationSequence.next()); }
|
||||
public GuidedVoteSession(long started, long observationOrder) { this.started = started; this.observationOrder = observationOrder; }
|
||||
public long started() { return started; }
|
||||
|
||||
/** Bind visible identities on the player context before asynchronous eligibility sampling. */
|
||||
public synchronized void bindCandidates(List<Site> available) {
|
||||
if (candidatesBound) return;
|
||||
for (Site site : available) if (candidates.size() < 100) candidates.put(site.key(), site);
|
||||
candidatesBound = true;
|
||||
}
|
||||
public synchronized void refresh(List<Site> available) {
|
||||
bindCandidates(available);
|
||||
if (!initialized) {
|
||||
// A cooldown timestamp alone does not establish original ingress order.
|
||||
for (Site site : available) if (candidates.containsKey(site.key()) && (site.eligible() || received.containsKey(site.key())) && sites.size() < 100) sites.put(site.key(), site);
|
||||
// A temporarily hidden/removed site can be absent from the worker sample.
|
||||
// Keep its confirmed identity so visibility restoration can recover progress.
|
||||
for (Site candidate : candidates.values()) if (received.containsKey(candidate.key()))
|
||||
sites.putIfAbsent(candidate.key(), candidate);
|
||||
initialized = true;
|
||||
}
|
||||
Map<String, Site> current = new LinkedHashMap<>();
|
||||
for (Site site : available) current.put(site.key(), site);
|
||||
sites.replaceAll((key, old) -> {
|
||||
Site fresh = current.get(key);
|
||||
return fresh == null ? new Site(key, old.name(), old.url(), false, old.lastVote()) : fresh;
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized boolean candidate(String key) { return candidates.containsKey(key); }
|
||||
/** The accepted pipeline supplies stable IDs and its authoritative occurrence time. */
|
||||
public synchronized void accepted(String site, UUID occurrence, long time, boolean real, boolean cancelled) {
|
||||
if (real && !cancelled && occurrence != null && time > started && candidates.containsKey(site)
|
||||
&& !received.containsKey(site) && !occurrences.containsKey(occurrence)) {
|
||||
received.put(site, time);
|
||||
occurrences.put(occurrence, site);
|
||||
// A credited vote may make the first asynchronous eligibility sample unavailable.
|
||||
// Its identity was already bound, so a later notification can restore that entry.
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
}
|
||||
/** Fresh local ingress uses backend-local order; proxy occurrences require their source fence. */
|
||||
public synchronized void acceptedObserved(String site, UUID occurrence, long order) {
|
||||
if (order > observationOrder && occurrence != null && candidates.containsKey(site)
|
||||
&& !received.containsKey(site) && !occurrences.containsKey(occurrence)) {
|
||||
received.put(site, order);
|
||||
occurrences.put(occurrence, site);
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
}
|
||||
/** A negotiated source fence is fixed for the entire session, not advanced by status checks. */
|
||||
public synchronized void bindProxyFence(ProxyVoteSequence.Fence fence) {
|
||||
if (!proxyFences.containsKey(fence.incarnation()) && proxyFences.size() < 32) {
|
||||
proxyFences.put(fence.incarnation(), fence);
|
||||
var pending = new ArrayList<>(pendingProxy.entrySet());
|
||||
for (var receipt : pending) acceptedProxy(receipt.getValue(), receipt.getKey());
|
||||
}
|
||||
}
|
||||
public synchronized boolean hasProxyFence() { return !proxyFences.isEmpty(); }
|
||||
/** The normal credited pipeline supplies the original canonical identity, including delayed votes. */
|
||||
public synchronized void acceptedProxy(String site, UUID occurrence) {
|
||||
if (occurrence == null || occurrence.version() != 8 || occurrence.variant() != 2
|
||||
|| !candidates.containsKey(site) || received.containsKey(site) || occurrences.containsKey(occurrence)) return;
|
||||
UUID source = new UUID(occurrence.getMostSignificantBits(), 0x8000000000000000L);
|
||||
ProxyVoteSequence.Fence fence = proxyFences.get(source);
|
||||
if (fence == null) {
|
||||
// Bounded provisional receipts cover a new occurrence overtaking its fence reply.
|
||||
if (pendingProxy.size() < 400) pendingProxy.putIfAbsent(occurrence, site);
|
||||
return;
|
||||
}
|
||||
pendingProxy.remove(occurrence);
|
||||
if (!fence.newer(occurrence)) return;
|
||||
received.put(site, occurrence.getLeastSignificantBits() & 0x3fffffffffffffffL);
|
||||
occurrences.put(occurrence, site);
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
/**
|
||||
* Select the next eligible site for automatic guidance after the current site
|
||||
* can no longer be the active target. Explicit navigation remains owned by
|
||||
* {@link #view(String)}; this method only advances from a received,
|
||||
* unavailable, or skipped current entry.
|
||||
*
|
||||
* @return whether the active index changed
|
||||
*/
|
||||
public synchronized boolean selectNextAwaiting() {
|
||||
if (finished || sites.isEmpty()) return false;
|
||||
int start = Math.floorMod(index, sites.size());
|
||||
String current = new ArrayList<>(sites.keySet()).get(start);
|
||||
Status currentStatus = status(sites.get(current));
|
||||
if (currentStatus == Status.AWAITING) return false;
|
||||
List<Site> ordered = new ArrayList<>(sites.values());
|
||||
for (int offset = 1; offset <= ordered.size(); offset++) {
|
||||
int candidateIndex = (start + offset) % ordered.size();
|
||||
Site candidate = ordered.get(candidateIndex);
|
||||
if (status(candidate) == Status.AWAITING && candidate.eligible() && !skipped.contains(candidate.key())) {
|
||||
if (candidateIndex == index) return false;
|
||||
index = candidateIndex;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Status status(Site site) {
|
||||
if (received.containsKey(site.key())) return Status.RECEIVED;
|
||||
if (!site.eligible()) return Status.UNAVAILABLE;
|
||||
return skipped.contains(site.key()) ? Status.SKIPPED : Status.AWAITING;
|
||||
}
|
||||
public synchronized View view(String action) {
|
||||
if (!sites.isEmpty()) {
|
||||
String current = new ArrayList<>(sites.keySet()).get(index);
|
||||
if (action.equals("skip")) { skipped.add(current); index = (index + 1) % sites.size(); }
|
||||
if (action.equals("next")) index = (index + 1) % sites.size();
|
||||
if (action.equals("previous")) index = Math.floorMod(index - 1, sites.size());
|
||||
}
|
||||
if (action.equals("finish")) finished = true;
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
for (Site site : sites.values()) {
|
||||
entries.add(new Entry(site, status(site)));
|
||||
}
|
||||
return new View(List.copyOf(entries), index, finished);
|
||||
}
|
||||
public static String httpUrl(String input) {
|
||||
if (input == null || input.length() > 2048) return null;
|
||||
try {
|
||||
String destination = input.trim();
|
||||
if (destination.startsWith("[")) {
|
||||
var wrapper = java.util.regex.Pattern.compile("(?i)\\[Text=\"[^\"\\r\\n]*\",\\s*url=\"([^\"\\r\\n]*)\"\\]").matcher(destination);
|
||||
if (!wrapper.matches()) return null;
|
||||
destination = wrapper.group(1);
|
||||
}
|
||||
URI uri = URI.create(destination);
|
||||
return ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|
||||
&& uri.getHost() != null && uri.getUserInfo() == null ? uri.toASCIIString() : null;
|
||||
} catch (IllegalArgumentException invalid) { return null; }
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Original proxy ingress order, carried by the existing opaque canonical vote UUID.
|
||||
* UUIDv8: random 60-bit source incarnation and a monotonically increasing 62-bit sequence.
|
||||
* The process/classloader owns one source across runtime replacement; a restart creates
|
||||
* another. Never assign a new ID when forwarding, retrying or replaying an occurrence. */
|
||||
public final class ProxyVoteSequence {
|
||||
private static final long MAX_SEQUENCE = 0x3fffffffffffffffL;
|
||||
private static final ProxyVoteSequence LIVE = new ProxyVoteSequence(UUID.randomUUID());
|
||||
private final long source;
|
||||
private long sequence;
|
||||
|
||||
public ProxyVoteSequence(UUID incarnation) {
|
||||
source = (incarnation.getMostSignificantBits() & 0xffffffffffff0fffL) | 0x8000L;
|
||||
}
|
||||
public static UUID nextVoteId() { return LIVE.next(); }
|
||||
public static Fence currentFence() { return LIVE.fence(); }
|
||||
public synchronized UUID next() {
|
||||
if (sequence == MAX_SEQUENCE) throw new IllegalStateException("Proxy vote sequence exhausted");
|
||||
return new UUID(source, 0x8000000000000000L | ++sequence);
|
||||
}
|
||||
public synchronized Fence fence() { return new Fence(new UUID(source, 0x8000000000000000L), sequence); }
|
||||
|
||||
public record Fence(UUID incarnation, long watermark) {
|
||||
public Fence {
|
||||
if (incarnation == null || incarnation.version() != 8 || incarnation.variant() != 2
|
||||
|| incarnation.getLeastSignificantBits() != 0x8000000000000000L
|
||||
|| watermark < 0 || watermark > MAX_SEQUENCE)
|
||||
throw new IllegalArgumentException("Invalid proxy occurrence fence");
|
||||
}
|
||||
public boolean newer(UUID occurrence) {
|
||||
return occurrence != null && occurrence.version() == 8 && occurrence.variant() == 2
|
||||
&& occurrence.getMostSignificantBits() == incarnation.getMostSignificantBits()
|
||||
&& (occurrence.getLeastSignificantBits() & MAX_SEQUENCE) > watermark;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/** Process-local total ordering shared by guide creation and vote ingress; never a remote timestamp. */
|
||||
public final class VoteObservationSequence {
|
||||
private static final AtomicLong SEQUENCE = new AtomicLong();
|
||||
private VoteObservationSequence() { }
|
||||
public static long next() { return SEQUENCE.incrementAndGet(); }
|
||||
}
|
||||
-247
@@ -1,247 +0,0 @@
|
||||
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,182 +18,9 @@ import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
|
||||
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"));
|
||||
}
|
||||
public class ServerData {
|
||||
|
||||
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.
|
||||
@@ -202,7 +29,6 @@ public class ServerData {
|
||||
*/
|
||||
public ServerData(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
this.generationFence = plugin.getServerDataPublicationFence();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,15 +36,13 @@ public class ServerData {
|
||||
*
|
||||
* @param placeholder the placeholder to add
|
||||
*/
|
||||
public void addAutoCachedPlaceholder(String placeholder) {
|
||||
mutateData(() -> {
|
||||
List<String> p = getAutoCachedPlaceholder();
|
||||
|
||||
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
|
||||
p.add(placeholder);
|
||||
setAutoCachedPlaceholder(p);
|
||||
}
|
||||
});
|
||||
public void addAutoCachedPlaceholder(String placeholder) {
|
||||
List<String> p = getAutoCachedPlaceholder();
|
||||
|
||||
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
|
||||
p.add(placeholder);
|
||||
setAutoCachedPlaceholder(p);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,14 +50,12 @@ public class ServerData {
|
||||
*
|
||||
* @param site the service site to add
|
||||
*/
|
||||
public void addServiceSite(String site) {
|
||||
mutateData(() -> {
|
||||
ArrayList<String> l = getServiceSites();
|
||||
if (!getServiceSites().contains(site)) {
|
||||
l.add(site);
|
||||
}
|
||||
setServiceSites(ArrayUtils.removeDuplicates(l));
|
||||
});
|
||||
public synchronized void addServiceSite(String site) {
|
||||
ArrayList<String> l = getServiceSites();
|
||||
if (!getServiceSites().contains(site)) {
|
||||
l.add(site);
|
||||
}
|
||||
setServiceSites(ArrayUtils.removeDuplicates(l));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,20 +65,19 @@ public class ServerData {
|
||||
* @param data the data
|
||||
* @param position the position
|
||||
*/
|
||||
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)));
|
||||
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)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,13 +86,11 @@ public class ServerData {
|
||||
* @param num the vote number
|
||||
* @param vote the vote time queue entry
|
||||
*/
|
||||
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();
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,20 +98,16 @@ public class ServerData {
|
||||
*
|
||||
* @param ident the vote shop identifier
|
||||
*/
|
||||
public void addVoteShopPurchase(String ident) {
|
||||
mutateData(() -> {
|
||||
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
|
||||
});
|
||||
public void addVoteShopPurchase(String ident) {
|
||||
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the timed vote cache.
|
||||
*/
|
||||
public void clearTimedVoteCache() {
|
||||
mutateData(() -> {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
public void clearTimedVoteCache() {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,15 +142,13 @@ public class ServerData {
|
||||
*
|
||||
* @return the 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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,10 +165,9 @@ public class ServerData {
|
||||
*
|
||||
* @return the service sites
|
||||
*/
|
||||
public ArrayList<String> getServiceSites() {
|
||||
synchronized (publicationMonitor()) {
|
||||
return new ArrayList<>(getData().getStringList("GottenServiceSites"));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
public ArrayList<String> getServiceSites() {
|
||||
return (ArrayList<String>) getData().getList("GottenServiceSites", new ArrayList<>());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,11 +176,9 @@ public class ServerData {
|
||||
*
|
||||
* @return persisted service-site observations
|
||||
*/
|
||||
public List<String> getServiceSitesReadOnly() {
|
||||
synchronized (publicationMonitor()) {
|
||||
ConfigurationSection root = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
|
||||
}
|
||||
public synchronized List<String> getServiceSitesReadOnly() {
|
||||
ConfigurationSection root = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -529,11 +339,8 @@ public class ServerData {
|
||||
/**
|
||||
* Reload data.
|
||||
*/
|
||||
public void reloadData() {
|
||||
mutateData(() -> {
|
||||
getFile().reloadData();
|
||||
captureVotePartyDisplay();
|
||||
});
|
||||
public void reloadData() {
|
||||
plugin.getServerDataFile().reloadData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -552,164 +359,36 @@ public class ServerData {
|
||||
* @param top the top voter type
|
||||
* @param messageId the message ID
|
||||
*/
|
||||
public void setTopVoterMessageId(TopVoter top, long messageId) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
|
||||
});
|
||||
public void setTopVoterMessageId(TopVoter top, long messageId) {
|
||||
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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);
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
/** 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();
|
||||
});
|
||||
/**
|
||||
* Save data.
|
||||
*/
|
||||
public synchronized void saveData() {
|
||||
plugin.getServerDataFile().saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -717,10 +396,13 @@ 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());
|
||||
setStringList("DisabledReminders", uuids);
|
||||
public void saveDisabledReminders(ArrayList<UUID> disabledReminders) {
|
||||
ArrayList<String> uuids = new ArrayList<>();
|
||||
for (UUID uuid : disabledReminders) {
|
||||
uuids.add(uuid.toString());
|
||||
}
|
||||
getData().set("DisabledReminders", uuids);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -728,8 +410,9 @@ public class ServerData {
|
||||
*
|
||||
* @param placeholders the list of placeholders
|
||||
*/
|
||||
public void setAutoCachedPlaceholder(List<String> placeholders) {
|
||||
setStringList("AutoCachePlaceholders", placeholders);
|
||||
public void setAutoCachedPlaceholder(List<String> placeholders) {
|
||||
getData().set("AutoCachePlaceholders", placeholders);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -737,8 +420,9 @@ public class ServerData {
|
||||
*
|
||||
* @param current the current vote count
|
||||
*/
|
||||
public void setBungeeVotePartyCurrent(int current) {
|
||||
setInt("BungeeVotePartyCurrent", current);
|
||||
public void setBungeeVotePartyCurrent(int current) {
|
||||
getData().set("BungeeVotePartyCurrent", current);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -746,8 +430,9 @@ public class ServerData {
|
||||
*
|
||||
* @param required the required vote count
|
||||
*/
|
||||
public void setBungeeVotePartyRequired(int required) {
|
||||
setInt("BungeeVotePartyRequired", required);
|
||||
public void setBungeeVotePartyRequired(int required) {
|
||||
getData().set("BungeeVotePartyRequired", required);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -755,8 +440,9 @@ public class ServerData {
|
||||
*
|
||||
* @param list the service sites list
|
||||
*/
|
||||
public void setServiceSites(ArrayList<String> list) {
|
||||
setStringList("GottenServiceSites", list);
|
||||
public void setServiceSites(ArrayList<String> list) {
|
||||
getData().set("GottenServiceSites", list);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -764,10 +450,9 @@ public class ServerData {
|
||||
*
|
||||
* @param value the converted status
|
||||
*/
|
||||
public void setShopConverted(boolean value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VoteShopConverted", value);
|
||||
});
|
||||
public void setShopConverted(boolean value) {
|
||||
getData().set("VoteShopConverted", value);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -779,15 +464,21 @@ 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) {
|
||||
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");
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -796,20 +487,21 @@ public class ServerData {
|
||||
* @param count the sign count
|
||||
* @param skullLocation the skull location
|
||||
*/
|
||||
public void setSkullLocation(String count, Location skullLocation) {
|
||||
if (skullLocation == null) return;
|
||||
var coordinates = SignCoordinates.capture(skullLocation);
|
||||
mutateData(() -> coordinates.store(getData(), "Signs." + count + ".Skull"));
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the version.
|
||||
*/
|
||||
public void setVersion() {
|
||||
String version = Bukkit.getVersion();
|
||||
mutateSavedData(() -> {
|
||||
getData().set("Version", version);
|
||||
});
|
||||
public void setVersion() {
|
||||
getData().set("Version", Bukkit.getVersion());
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -817,10 +509,9 @@ public class ServerData {
|
||||
*
|
||||
* @param value the extra required votes
|
||||
*/
|
||||
public void setVotePartyExtraRequired(int value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VotePartyExtraRequired", value);
|
||||
});
|
||||
public void setVotePartyExtraRequired(int value) {
|
||||
getData().set("VotePartyExtraRequired", value);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -829,45 +520,42 @@ public class ServerData {
|
||||
* @param ident the vote shop identifier
|
||||
* @param amount the purchase amount
|
||||
*/
|
||||
public void setVoteShopPurchases(String ident, int amount) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VoteShopPurchases." + ident, amount);
|
||||
});
|
||||
public void setVoteShopPurchases(String ident, int amount) {
|
||||
getData().set("VoteShopPurchases." + ident, amount);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last vote party timestamp to today.
|
||||
*/
|
||||
public void updateLastVoteParty() {
|
||||
int day = plugin.getTimeChecker().getTime().getDayOfYear();
|
||||
setInt("LastVoteParty", day);
|
||||
public void updateLastVoteParty() {
|
||||
getData().set("LastVoteParty", plugin.getTimeChecker().getTime().getDayOfYear());
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last vote party week timestamp to this week.
|
||||
*/
|
||||
public void updateLastVotePartyWeek() {
|
||||
int week = plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear());
|
||||
setInt("LastVotePartyWeek", week);
|
||||
public void updateLastVotePartyWeek() {
|
||||
getData().set("LastVotePartyWeek",
|
||||
plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear()));
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates placeholders to lowercase format.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,10 @@ 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;
|
||||
|
||||
@@ -25,7 +27,6 @@ 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;
|
||||
@@ -39,45 +40,8 @@ public class DiscordHandler {
|
||||
private final VotingPluginMain plugin;
|
||||
@Getter
|
||||
private final HashMap<TopVoter, Long> topVoterMessageIds = new HashMap<TopVoter, Long>();
|
||||
/** 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 Set<TopVoter> recoveringTopVoters = new HashSet<>();
|
||||
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.
|
||||
@@ -185,347 +149,14 @@ public class DiscordHandler {
|
||||
* @param top the top voter
|
||||
*/
|
||||
public void updateTopVoterMessageId(TopVoter top) {
|
||||
prepareTopVoterMessage(top, plugin.getTopVoterDisplayGeneration());
|
||||
}
|
||||
plugin.extraDebug("Updating Discord Top Voter message for: " + 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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
long channelId = plugin.getConfigFile().getDiscordSRVTopVoterChannel(top);
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVoters = plugin.getTopVoter(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;
|
||||
String title = plugin.getConfigFile().getDiscordSRVTopVoterTitle(top);
|
||||
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()) {
|
||||
@@ -556,7 +187,34 @@ public class DiscordHandler {
|
||||
eb.setDescription(sb.toString()).setFooter("Top 10 • Updated");
|
||||
}
|
||||
|
||||
return eb;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -566,65 +224,41 @@ public class DiscordHandler {
|
||||
* condition as recoverable so one deleted message cannot permanently stop
|
||||
* leaderboard updates.
|
||||
*/
|
||||
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(); }
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
try { if (currentGeneration(generation)) warnLeaderboardFailure("worker admission"); }
|
||||
finally { discarded.run(); }
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, Publication publication,
|
||||
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, EmbedBuilder eb, long existingId,
|
||||
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;
|
||||
}
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
if (again) finish(top, publication);
|
||||
});
|
||||
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());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
plugin.getLogger().warning("Error editing Top Voters " + top + ": " + error.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-1
@@ -12,7 +12,20 @@ import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class PlayerPostVoteEvent extends Event {
|
||||
public class PlayerPostVoteEvent extends Event {
|
||||
@Getter @Setter private boolean proxySessionRealVote = true;
|
||||
/** Local recovered ingress cannot prove ordering relative to a new guide. */
|
||||
@Getter @Setter private boolean unconfirmedLocalSessionDelivery;
|
||||
|
||||
/** Backend-local ingress order forwarded by the accepted pipeline. Zero means unknown. */
|
||||
@Getter @Setter private long backendObservationOrder;
|
||||
@Getter @Setter private boolean proxyQueueClassificationKnown;
|
||||
@Getter @Setter private boolean queuedProxyVote;
|
||||
@Getter @Setter private boolean proxySessionDelivery;
|
||||
/** Guide-only provenance; independent of normal reward queue/delay policy. */
|
||||
@Getter @Setter private boolean unconfirmedProxySessionDelivery;
|
||||
/** Fresh local ingress with no supplied historical occurrence timestamp. */
|
||||
@Getter @Setter private boolean liveLocalSessionDelivery;
|
||||
|
||||
/** The Constant handlers. */
|
||||
private static final HandlerList handlers = new HandlerList();
|
||||
|
||||
@@ -20,6 +20,16 @@ import lombok.Setter;
|
||||
* Integrations must cancel before the MONITOR vote consumer runs.</p>
|
||||
*/
|
||||
public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
@Getter @Setter private boolean proxySessionRealVote = true;
|
||||
|
||||
/** Backend-local strictly increasing observation, never a timestamp from another node. */
|
||||
@Getter
|
||||
@Setter
|
||||
private long backendObservationOrder = com.bencodez.votingplugin.core.session.VoteObservationSequence.next();
|
||||
/** Guide-only marker; preserves normal original vote timestamp and accounting semantics. */
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean timeQueuedLocalSessionDelivery;
|
||||
|
||||
/** The Constant handlers. */
|
||||
private static final HandlerList handlers = new HandlerList();
|
||||
@@ -96,6 +106,11 @@ public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
@Setter
|
||||
private boolean proxyQueueClassificationKnown;
|
||||
|
||||
/** Guide-only provenance: original ingress is forwarded, replayed, or unknown. */
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean unconfirmedProxySessionDelivery;
|
||||
|
||||
/** Whether the proxy explicitly supplied its delay-validation decision. */
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
+12
@@ -184,6 +184,7 @@ public class PlayerVoteListener implements Listener {
|
||||
long voteTime, UUID voteId, boolean cached) {
|
||||
PlayerPostVoteEvent post = new PlayerPostVoteEvent(site, user, event.isRealVote(), event.isForceBungee(),
|
||||
voteTime, cached, site.getServiceSite(), user.getJavaUUID(), playerName, voteId);
|
||||
copySessionObservation(post, event);
|
||||
plugin.getServer().getPluginManager().callEvent(post);
|
||||
}
|
||||
@Override public boolean placeholderCacheAlways() { return plugin.getConfigFile().getPlaceholderCacheLevel().isCacheAlways(); }
|
||||
@@ -192,6 +193,17 @@ public class PlayerVoteListener implements Listener {
|
||||
@Override public void setUpdate() { plugin.setUpdate(true); }
|
||||
}
|
||||
|
||||
static void copySessionObservation(PlayerPostVoteEvent post, PlayerVoteEvent input) {
|
||||
post.setProxySessionDelivery(input.isBungee());
|
||||
post.setProxySessionRealVote(input.isProxySessionRealVote());
|
||||
post.setUnconfirmedProxySessionDelivery(input.isUnconfirmedProxySessionDelivery());
|
||||
post.setLiveLocalSessionDelivery(!input.isBungee() && (input.getTime() == 0 || input.isTimeQueuedLocalSessionDelivery()) && input.getBackendObservationOrder() > 0);
|
||||
post.setUnconfirmedLocalSessionDelivery(!input.isBungee() && (input.getTime() == 0 || input.isTimeQueuedLocalSessionDelivery()) && input.getBackendObservationOrder() <= 0);
|
||||
post.setBackendObservationOrder(input.getBackendObservationOrder());
|
||||
post.setProxyQueueClassificationKnown(input.isProxyQueueClassificationKnown());
|
||||
post.setQueuedProxyVote(input.isQueuedProxyVote());
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
static UUID resolveProxyVoteId(PlayerVoteEvent event) {
|
||||
if (event.getProxyVoteId() != null) return event.getProxyVoteId();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.bencodez.votingplugin.listeners;
|
||||
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -35,6 +36,10 @@ public class VotiferEvent implements Listener {
|
||||
* @param voteUsername the validated player name
|
||||
*/
|
||||
public void processVote(String voteSite, String voteUsername) {
|
||||
processVote(voteSite, voteUsername, 0L);
|
||||
}
|
||||
/** Original process-local ingress order; zero identifies recovered or unknown ordering. */
|
||||
public void processVote(String voteSite, String voteUsername, long observationOrder) {
|
||||
try {
|
||||
plugin.getServerData().addServiceSite(voteSite);
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass()
|
||||
@@ -76,7 +81,7 @@ public class VotiferEvent implements Listener {
|
||||
if (plugin.getTimeChecker().isActiveProcessing()
|
||||
&& plugin.getConfigFile().isQueueVotesDuringTimeChange()) {
|
||||
plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite);
|
||||
plugin.getTimeQueueHandler().addVote(voteUsername, voteSite);
|
||||
plugin.getTimeQueueHandler().addVote(voteUsername, voteSite, observationOrder);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,6 +89,7 @@ public class VotiferEvent implements Listener {
|
||||
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true);
|
||||
voteEvent.setBackendObservationOrder(observationOrder);
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
|
||||
if (voteEvent.isCancelled()) {
|
||||
@@ -134,10 +140,12 @@ public class VotiferEvent implements Listener {
|
||||
plugin.debug("VoteSite: " + voteSite);
|
||||
plugin.debug("IP: " + IP);
|
||||
|
||||
if (!com.bencodez.votingplugin.util.VoteTaskAdmission.trySubmit(plugin.getVoteTimer(),
|
||||
() -> processVote(voteSite, voteUsername))) {
|
||||
final long observationOrder = com.bencodez.votingplugin.core.session.VoteObservationSequence.next();
|
||||
try {
|
||||
plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername, observationOrder));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue();
|
||||
if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) {
|
||||
if (overflow == null || !overflow.enqueue(voteUsername, voteSite, observationOrder)) {
|
||||
plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for "
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(voteUsername));
|
||||
} else {
|
||||
|
||||
+19
-5
@@ -42,7 +42,9 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
private static final String QUEUE_FILE = "VotifierVoteQueue.yml";
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final BiConsumer<String, String> processor;
|
||||
@FunctionalInterface
|
||||
public interface ObservedVoteProcessor { void accept(String site, String player, long order); }
|
||||
private final ObservedVoteProcessor processor;
|
||||
private final Path file;
|
||||
private final ScheduledThreadPoolExecutor worker;
|
||||
private final Object lock = new Object();
|
||||
@@ -63,6 +65,12 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
* @param processor callback receiving service site and player name
|
||||
*/
|
||||
public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer<String, String> processor) {
|
||||
this(plugin, (site, player, order) -> processor.accept(site, player));
|
||||
}
|
||||
public static VotifierVoteOverflowQueue withObservations(VotingPluginMain plugin, ObservedVoteProcessor processor) {
|
||||
return new VotifierVoteOverflowQueue(plugin, processor);
|
||||
}
|
||||
private VotifierVoteOverflowQueue(VotingPluginMain plugin, ObservedVoteProcessor processor) {
|
||||
this.plugin = plugin;
|
||||
this.processor = processor;
|
||||
this.file = new File(plugin.getDataFolder(), QUEUE_FILE).toPath();
|
||||
@@ -97,10 +105,13 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
* @return false when the bounded overflow is full or shutting down
|
||||
*/
|
||||
public boolean enqueue(String username, String serviceSite) {
|
||||
return enqueue(username, serviceSite, com.bencodez.votingplugin.core.session.VoteObservationSequence.next());
|
||||
}
|
||||
public boolean enqueue(String username, String serviceSite, long observationOrder) {
|
||||
if (username == null || serviceSite == null) return false;
|
||||
synchronized (lock) {
|
||||
if (closed || entries.size() >= MAX_ENTRIES) return false;
|
||||
entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis()));
|
||||
entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis(), observationOrder));
|
||||
stateVersion++;
|
||||
requestPersistenceLocked();
|
||||
scheduleDrainLocked();
|
||||
@@ -153,7 +164,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
// above cannot change in the gap before submit accepts this vote.
|
||||
plugin.getVoteTimer().submit(() -> {
|
||||
try {
|
||||
processor.accept(pending.serviceSite, pending.username);
|
||||
processor.accept(pending.serviceSite, pending.username, pending.observationOrder);
|
||||
} finally {
|
||||
acknowledge(pending);
|
||||
}
|
||||
@@ -265,7 +276,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
entries.addLast(new PendingVote(name, site, timestamp.longValue()));
|
||||
entries.addLast(new PendingVote(name, site, timestamp.longValue(), 0L));
|
||||
}
|
||||
if (skipped) {
|
||||
synchronized (lock) {
|
||||
@@ -361,7 +372,10 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
private final long time;
|
||||
private boolean submitted;
|
||||
|
||||
private PendingVote(String username, String serviceSite, long time) {
|
||||
// Process-local ordering is intentionally not restored across server restarts.
|
||||
private final long observationOrder;
|
||||
private PendingVote(String username, String serviceSite, long time, long observationOrder) {
|
||||
this.observationOrder = observationOrder;
|
||||
this.username = username;
|
||||
this.serviceSite = serviceSite;
|
||||
this.time = time;
|
||||
|
||||
+33
-240
@@ -67,8 +67,6 @@ 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;
|
||||
@@ -187,103 +185,6 @@ 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);
|
||||
@@ -304,12 +205,6 @@ 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)) {
|
||||
@@ -597,8 +492,7 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
Integer points = pointBalance(user);
|
||||
return points == null ? "..." : Integer.toString(points);
|
||||
return Integer.toString(user.getPoints());
|
||||
}
|
||||
}.withDescription("User points").updateDataKey("Points"));
|
||||
|
||||
@@ -607,8 +501,7 @@ public class PlaceHolders {
|
||||
@Override
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.US);
|
||||
Integer points = pointBalance(user);
|
||||
return points == null ? "..." : numberFormat.format(points);
|
||||
return numberFormat.format(user.getPoints());
|
||||
}
|
||||
}.withDescription("User points").updateDataKey("Points"));
|
||||
|
||||
@@ -716,7 +609,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.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -734,9 +627,7 @@ public class PlaceHolders {
|
||||
return "invalid";
|
||||
}
|
||||
int number = Integer.parseInt(identifier.split("_")[1]);
|
||||
Integer points = pointBalance(user);
|
||||
if (points == null) return "...";
|
||||
if (points >= number) {
|
||||
if (user.getPoints() >= number) {
|
||||
return "true";
|
||||
}
|
||||
return "false";
|
||||
@@ -814,7 +705,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.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -833,7 +724,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -849,7 +740,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.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -866,7 +757,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.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -884,7 +775,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -900,7 +791,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.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -921,7 +812,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -937,7 +828,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.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -953,7 +844,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.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -972,7 +863,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -988,7 +879,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.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -1004,7 +895,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.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -1023,7 +914,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -1047,7 +938,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.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -1063,7 +954,7 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay().total());
|
||||
return Integer.toString(plugin.getVoteParty().getTotalVotes());
|
||||
}
|
||||
}.withDescription("Current amount of voteparty votes"));
|
||||
|
||||
@@ -1071,8 +962,7 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay()
|
||||
.needed(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()));
|
||||
return Integer.toString(plugin.getVoteParty().getNeededVotes());
|
||||
}
|
||||
}.withDescription("Voteparty votes needed"));
|
||||
|
||||
@@ -1080,8 +970,7 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay()
|
||||
.required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()));
|
||||
return Integer.toString(plugin.getVoteParty().getVotesRequired());
|
||||
}
|
||||
}.withDescription("Amount of votes needed for voteparty"));
|
||||
|
||||
@@ -1518,13 +1407,6 @@ 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;
|
||||
@@ -1545,11 +1427,7 @@ public class PlaceHolders {
|
||||
}
|
||||
} else {
|
||||
String value = placeholder.placeholderRequest(user, ident);
|
||||
if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey())) {
|
||||
pointFence.publish(uuid, pointGeneration, () -> {
|
||||
if (plugin.isEnabled()) values.put(uuid, value);
|
||||
});
|
||||
} else values.put(uuid, value);
|
||||
values.put(uuid, value);
|
||||
if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) {
|
||||
values.remove(uuid);
|
||||
continue;
|
||||
@@ -1558,9 +1436,6 @@ public class PlaceHolders {
|
||||
+ matchingKey(placeholder, keys) + " with " + value);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pointFence.release(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private void publishCachedValue(PlaceHolder<VotingPluginUser> placeholder, VotingPluginUser user, UUID uuid,
|
||||
@@ -1585,52 +1460,6 @@ 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
|
||||
@@ -1676,12 +1505,11 @@ public class PlaceHolders {
|
||||
/**
|
||||
* Update placeholders for specific user.
|
||||
* @param user the voting plugin user
|
||||
* @param login whether to include uncached entries, such as a login or worker warmup
|
||||
* @param login whether this is a login update
|
||||
*/
|
||||
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<>();
|
||||
@@ -1723,8 +1551,6 @@ public class PlaceHolders {
|
||||
* Reload placeholders.
|
||||
*/
|
||||
public void reload() {
|
||||
pointFence.invalidateLifecycle();
|
||||
pointRecoveryWorkers.clear();
|
||||
platformUpdateGenerations.clear();
|
||||
plugin.refreshPlaceholderPlayerPresence();
|
||||
cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel();
|
||||
@@ -1758,47 +1584,14 @@ public class PlaceHolders {
|
||||
* Schedule placeholder check for user.
|
||||
* @param user the voting plugin user
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
public void schedulePlaceholderCheck(VotingPluginUser user) {
|
||||
plugin.getTimer().execute(new Runnable() {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
checkNonCachedPlaceholders();
|
||||
onUpdate(user, plugin.getPlaceholderPlayerPresence().isOnline(user.getJavaUUID()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
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
-1
@@ -14,7 +14,7 @@ public final class PendingIncomingVoteQueue {
|
||||
|
||||
public synchronized PendingIncomingVote admit(String player, String service) {
|
||||
if (!accepting || votes.size() >= MAX_PENDING) return null;
|
||||
PendingIncomingVote vote = new PendingIncomingVote(UUID.randomUUID(), player, service,
|
||||
PendingIncomingVote vote = new PendingIncomingVote(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId(), player, service,
|
||||
System.currentTimeMillis());
|
||||
votes.put(vote.getVoteId(), vote);
|
||||
return vote;
|
||||
|
||||
+11
-1
@@ -74,6 +74,7 @@ final class ReliableVoteDeliveryOutbox {
|
||||
long deferredCount = entries.values().stream().filter(Entry::deferredUpdate).count();
|
||||
if (deferred ? !update || deferredCount >= MAX_DEFERRED_UPDATE_ENTRIES
|
||||
: update ? readyUpdates() >= MAX_UPDATE_ENTRIES : entries.size() - updates >= MAX_ENTRIES) return false;
|
||||
envelope = retryEnvelope(envelope);
|
||||
String record = addRecord(server, envelope, deferred);
|
||||
Entry entry = new Entry(server, envelope, false, false, false, deferred);
|
||||
long remainingReserve = terminalRecordReserve() + terminalRecordReserve(entry);
|
||||
@@ -238,7 +239,7 @@ final class ReliableVoteDeliveryOutbox {
|
||||
try {
|
||||
if (parts.length == 3 && (ADD.equals(parts[0]) || DEFERRED.equals(parts[0]))) {
|
||||
String server = decode(parts[1]);
|
||||
JsonEnvelope envelope = JsonEnvelopeCodec.decode(decode(parts[2]));
|
||||
JsonEnvelope envelope = retryEnvelope(JsonEnvelopeCodec.decode(decode(parts[2])));
|
||||
String key = key(server, envelope);
|
||||
if (key == null) throw new IllegalArgumentException("Invalid vote envelope");
|
||||
boolean deferred = DEFERRED.equals(parts[0]);
|
||||
@@ -395,6 +396,15 @@ final class ReliableVoteDeliveryOutbox {
|
||||
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
/** A retained delivery is no longer an immediate live observation, including pre-upgrade rows. */
|
||||
private static JsonEnvelope retryEnvelope(JsonEnvelope envelope) {
|
||||
if (!VotingPluginWire.SUB_VOTE.equals(envelope.getSubChannel())
|
||||
&& !VotingPluginWire.SUB_VOTE_ONLINE.equals(envelope.getSubChannel())) return envelope;
|
||||
var copy = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
envelope.getFields().forEach(copy::put);
|
||||
return copy.put(VotingPluginWire.K_SESSION_DELIVERY_FRESH, false).build();
|
||||
}
|
||||
|
||||
private static String key(String server, JsonEnvelope envelope) {
|
||||
if (server == null || server.isBlank() || envelope == null) return null;
|
||||
String subChannel = envelope.getSubChannel();
|
||||
|
||||
@@ -2253,7 +2253,10 @@ public abstract class VotingPluginProxy {
|
||||
votePartyVotes = getVoteCacheCurrentVotePartyVotes();
|
||||
|
||||
globalMessageProxyHandler = new VotingPluginGlobalMessageProxyHandler();
|
||||
registerControlEnrollmentListener(globalMessageProxyHandler);
|
||||
registerControlEnrollmentListener(globalMessageProxyHandler);
|
||||
globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REQUEST) {
|
||||
@Override public void onReceive(JsonEnvelope message) { handleSessionFenceRequest(message); }
|
||||
});
|
||||
|
||||
globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_LOGIN) {
|
||||
@Override
|
||||
@@ -2744,7 +2747,14 @@ public abstract class VotingPluginProxy {
|
||||
// encryption policy during a soft reload.
|
||||
SharedTransportEnvelopeAuthenticator multiProxyAuthenticator = getConfig().getMultiProxySupport()
|
||||
? sharedTransportAuthenticator() : sharedTransportAuthenticator;
|
||||
multiProxyHandler = new MultiProxyHandler() {
|
||||
multiProxyHandler = new MultiProxyHandler() {
|
||||
@Override protected void onSessionFenceReply(JsonEnvelope message) {
|
||||
var reply = VotingPluginWire.readSessionFenceReply(message);
|
||||
if (reply != null && reply.request().origin().equals(getConfig().getProxyServerName())
|
||||
&& isServerValid(reply.request().server()))
|
||||
globalMessageProxyHandler.sendMessage(reply.request().server(), 0, message);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void addNonVotedPlayerCache(String uuid, String player) {
|
||||
@@ -2895,6 +2905,8 @@ public abstract class VotingPluginProxy {
|
||||
@Override
|
||||
public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
|
||||
VoteTotalsSnapshot text, String uuid) {
|
||||
// Legacy callbacks have no ACK/retirement owner. Preserve their ordinary
|
||||
// delivery path rather than creating permanent multi-proxy completion files.
|
||||
vote(player, service, realVote, timeQueue, queueTime, text, uuid);
|
||||
}
|
||||
|
||||
@@ -5992,6 +6004,18 @@ public abstract class VotingPluginProxy {
|
||||
return sendProxyBroadcastEnvelopeNow(server, envelope);
|
||||
}
|
||||
|
||||
/** Read-only session negotiation uses existing encrypted/authenticated transports. */
|
||||
protected void handleSessionFenceRequest(JsonEnvelope message) {
|
||||
var request = VotingPluginWire.readSessionFenceRequest(message);
|
||||
if (request == null || !request.origin().isEmpty() || !isServerValid(request.server())) return;
|
||||
var fence = com.bencodez.votingplugin.core.session.ProxyVoteSequence.currentFence();
|
||||
globalMessageProxyHandler.sendMessage(request.server(), 0, VotingPluginWire.sessionFenceReply(request, fence));
|
||||
if (multiProxyHandler != null && getConfig().getMultiProxySupport()) {
|
||||
var relayed = new VotingPluginWire.SessionFenceRequest(request.player(), request.nonce(),
|
||||
request.server(), getConfig().getProxyServerName());
|
||||
multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.sessionFenceRequest(relayed));
|
||||
}
|
||||
}
|
||||
protected void handleStatusOkay(JsonEnvelope message) {
|
||||
String server = message.getFields().getOrDefault(VotingPluginWire.K_SERVER, "");
|
||||
String request = message.getFields().getOrDefault(VotingPluginWire.K_REQUEST_ID, "");
|
||||
@@ -6049,6 +6073,14 @@ public abstract class VotingPluginProxy {
|
||||
return sendVoteEnvelopeAccepted(playerServer, 1, envelope);
|
||||
}
|
||||
|
||||
/** Backend ingress cannot establish original freshness after a forwarding hop or replay. */
|
||||
private JsonEnvelope withSessionDeliveryProvenance(JsonEnvelope envelope, UUID voteId, VoteTimeQueue queuedVote) {
|
||||
MultiProxyVoteRetry forwarded = multiProxyVoteRetries.get(voteId);
|
||||
if (forwarded != null) return VotingPluginWire.unconfirmedSessionDelivery(envelope, forwarded.origin);
|
||||
if (queuedVote != null) return VotingPluginWire.unconfirmedSessionDelivery(envelope, queuedVote.getMultiProxyOrigin());
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private JsonEnvelope cachedVoteEnvelope(OfflineBungeeVote vote, boolean online, boolean broadcast,
|
||||
int num, int numberOfVotes) {
|
||||
if (vote.hasCachedVoteBatch()) {
|
||||
@@ -6073,6 +6105,9 @@ public abstract class VotingPluginProxy {
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(),
|
||||
getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes);
|
||||
}
|
||||
// Cached delivery cannot prove fresh original ingress in the backend's clock
|
||||
// domain, even when the reward policy correctly classifies it as non-queued.
|
||||
envelope = VotingPluginWire.unconfirmedSessionDelivery(envelope, "");
|
||||
// A cache entry is only known to have passed the proxy's delay gate when
|
||||
// that decision was persisted with it. Unknown legacy entries must still
|
||||
// be checked by the receiving backend.
|
||||
@@ -6851,6 +6886,7 @@ public abstract class VotingPluginProxy {
|
||||
authoritativeDelayValidated)
|
||||
: VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, text.toString(),
|
||||
voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
rewardEnvelope = withSessionDeliveryProvenance(rewardEnvelope, voteId, queuedVote);
|
||||
boolean rewardAccepted = sendVoteEnvelopeAccepted(s, 2, rewardEnvelope, pendingVote);
|
||||
if (!rewardAccepted) {
|
||||
pendingVote.setRewardDelivered(false);
|
||||
@@ -6910,6 +6946,7 @@ public abstract class VotingPluginProxy {
|
||||
authoritativeDelayValidated)
|
||||
: VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote,
|
||||
text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
rewardEnvelope = withSessionDeliveryProvenance(rewardEnvelope, voteId, queuedVote);
|
||||
rewardAccepted = sendVoteEnvelopeAccepted(server, 1, rewardEnvelope, pendingVote);
|
||||
if (rewardAccepted) {
|
||||
retryState.deliveredRewardServers.add(server);
|
||||
|
||||
@@ -35,10 +35,59 @@ public final class VotingPluginWire {
|
||||
|
||||
public static final int SCHEMA_VERSION = 1;
|
||||
|
||||
/** Additive request/reply negotiation; old peers simply ignore these subchannels. */
|
||||
public record SessionFenceRequest(UUID player, UUID nonce, String server, String origin) { }
|
||||
public record SessionFenceReply(SessionFenceRequest request,
|
||||
com.bencodez.votingplugin.core.session.ProxyVoteSequence.Fence fence) { }
|
||||
public static JsonEnvelope sessionFenceRequest(SessionFenceRequest request) {
|
||||
return sessionFenceBuilder(SUB_VOTE_SESSION_FENCE_REQUEST, request).build();
|
||||
}
|
||||
public static JsonEnvelope sessionFenceReply(SessionFenceRequest request,
|
||||
com.bencodez.votingplugin.core.session.ProxyVoteSequence.Fence fence) {
|
||||
return sessionFenceBuilder(SUB_VOTE_SESSION_FENCE_REPLY, request)
|
||||
.put(K_SESSION_INCARNATION, fence.incarnation().toString())
|
||||
.put(K_SESSION_WATERMARK, Long.toString(fence.watermark())).build();
|
||||
}
|
||||
private static JsonEnvelope.Builder sessionFenceBuilder(String sub, SessionFenceRequest request) {
|
||||
return base(sub).put(K_SESSION_FENCE_VERSION, 1).put(K_UUID, request.player().toString())
|
||||
.put(K_SESSION_NONCE, request.nonce().toString()).put(K_SERVER, request.server())
|
||||
.put(K_SESSION_ORIGIN, request.origin());
|
||||
}
|
||||
public static SessionFenceRequest readSessionFenceRequest(JsonEnvelope message) {
|
||||
if (message == null || message.getSchema() != SCHEMA_VERSION
|
||||
|| !(SUB_VOTE_SESSION_FENCE_REQUEST.equals(message.getSubChannel())
|
||||
|| SUB_VOTE_SESSION_FENCE_REPLY.equals(message.getSubChannel()))
|
||||
|| !"1".equals(message.getFields().get(K_SESSION_FENCE_VERSION))) return null;
|
||||
var fields = message.getFields();
|
||||
try {
|
||||
String server = fields.getOrDefault(K_SERVER, "");
|
||||
String origin = fields.getOrDefault(K_SESSION_ORIGIN, "");
|
||||
if (server.isBlank() || server.length() > 128 || origin.length() > 128) return null;
|
||||
return new SessionFenceRequest(UUID.fromString(fields.get(K_UUID)),
|
||||
UUID.fromString(fields.get(K_SESSION_NONCE)), server, origin);
|
||||
} catch (IllegalArgumentException | NullPointerException invalid) { return null; }
|
||||
}
|
||||
public static SessionFenceReply readSessionFenceReply(JsonEnvelope message) {
|
||||
SessionFenceRequest request = readSessionFenceRequest(message);
|
||||
if (request == null || !SUB_VOTE_SESSION_FENCE_REPLY.equals(message.getSubChannel())) return null;
|
||||
try {
|
||||
return new SessionFenceReply(request, new com.bencodez.votingplugin.core.session.ProxyVoteSequence.Fence(
|
||||
UUID.fromString(message.getFields().get(K_SESSION_INCARNATION)),
|
||||
Long.parseLong(message.getFields().get(K_SESSION_WATERMARK))));
|
||||
} catch (IllegalArgumentException | NullPointerException invalid) { return null; }
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Subchannels (canonical)
|
||||
// =========================
|
||||
public static final String SUB_VOTE = "Vote";
|
||||
public static final String SUB_VOTE_SESSION_FENCE_REQUEST = "VoteSessionFenceRequest";
|
||||
public static final String SUB_VOTE_SESSION_FENCE_REPLY = "VoteSessionFenceReply";
|
||||
public static final String K_SESSION_FENCE_VERSION = "sessionFenceVersion";
|
||||
public static final String K_SESSION_NONCE = "sessionNonce";
|
||||
public static final String K_SESSION_ORIGIN = "sessionOrigin";
|
||||
public static final String K_SESSION_INCARNATION = "sessionIncarnation";
|
||||
public static final String K_SESSION_WATERMARK = "sessionWatermark";
|
||||
public static final String SUB_VOTE = "Vote";
|
||||
public static final String SUB_VOTE_ONLINE = "VoteOnline";
|
||||
public static final String SUB_VOTE_UPDATE = "VoteUpdate";
|
||||
/** Internal queue metadata; never a backend delivery target. */
|
||||
@@ -121,6 +170,8 @@ public final class VotingPluginWire {
|
||||
public static final String K_NUMBER_OF_VOTES = "numberOfVotes";
|
||||
public static final String K_DELAY_VALIDATED = "delayValidated";
|
||||
public static final String K_QUEUED_DELIVERY = "queuedDelivery";
|
||||
/** Guide-only delivery hint; cross-node occurrence order remains unconfirmed. */
|
||||
public static final String K_SESSION_DELIVERY_FRESH = "sessionDeliveryFresh";
|
||||
public static final String K_VOTE_DELIVERY_ACK_VERSION = "voteDeliveryAckVersion";
|
||||
public static final String K_AUTHENTICATED_SOCKET_VOTE_DELIVERY_VERSION =
|
||||
"authenticatedSocketVoteDeliveryVersion";
|
||||
@@ -186,7 +237,7 @@ public final class VotingPluginWire {
|
||||
.put(K_TIME, time).put(K_WAS_ONLINE, wasOnline).put(K_WAS_ONLINE_KNOWN, true)
|
||||
.put(K_REAL_VOTE, realVote).put(K_TOTALS, safe(totals))
|
||||
.put(K_VOTE_ID, voteId == null ? "" : voteId.toString()).put(K_SET_TOTALS, true)
|
||||
.put(K_MANAGE_TOTALS, manageTotals).put(K_QUEUED_DELIVERY, false)
|
||||
.put(K_MANAGE_TOTALS, manageTotals).put(K_QUEUED_DELIVERY, false).put(K_SESSION_DELIVERY_FRESH, false)
|
||||
.put(K_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num)
|
||||
.put(K_NUMBER_OF_VOTES, numberOfVotes);
|
||||
}
|
||||
@@ -252,6 +303,19 @@ public final class VotingPluginWire {
|
||||
return builder.put(K_QUEUED_DELIVERY, true).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retains origin provenance for guide observations without changing reward queue
|
||||
* classification. An empty origin explicitly means the original ingress is unknown
|
||||
* (for example a cached delivery); it does not claim a reliable multi-proxy sender.
|
||||
*/
|
||||
public static JsonEnvelope unconfirmedSessionDelivery(JsonEnvelope envelope, String origin) {
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
for (Map.Entry<String, String> field : envelope.getFields().entrySet()) {
|
||||
builder.put(field.getKey(), field.getValue());
|
||||
}
|
||||
return builder.put(K_MULTI_PROXY_ORIGIN, safe(origin)).put(K_SESSION_DELIVERY_FRESH, false).build();
|
||||
}
|
||||
|
||||
/** Removes the additive queue marker for cache rows created by pre-marker proxies. */
|
||||
public static JsonEnvelope legacyUnclassifiedDelivery(JsonEnvelope envelope) {
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
|
||||
+1
-1
@@ -629,7 +629,7 @@ public class VotingPluginBungee extends Plugin implements Listener {
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
return;
|
||||
}
|
||||
pending = new PendingIncomingVote(UUID.randomUUID(), player, service, System.currentTimeMillis());
|
||||
pending = new PendingIncomingVote(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId(), player, service, System.currentTimeMillis());
|
||||
if (votingPluginProxy == null || !votingPluginProxy.retainIncomingVoteForRestart(pending)) {
|
||||
getLogger().severe("Pending vote admission is full and durable overflow failed; vote was not accepted for "
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
|
||||
+16
@@ -958,6 +958,8 @@ public abstract class MultiProxyHandler {
|
||||
&& getRedisPrefix() != null && !getRedisPrefix().isEmpty();
|
||||
}
|
||||
|
||||
protected void onSessionFenceReply(JsonEnvelope message) { }
|
||||
|
||||
private synchronized void loadMultiProxyRedisListener(String channel) {
|
||||
if (!registeredRedisChannels.add(channel)) return;
|
||||
try {
|
||||
@@ -1161,6 +1163,18 @@ public abstract class MultiProxyHandler {
|
||||
f = new HashMap<>();
|
||||
}
|
||||
|
||||
if (sub.equals(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REQUEST)) {
|
||||
var request = VotingPluginWire.readSessionFenceRequest(envelope);
|
||||
if (request != null && configuredMultiProxyRecipientNames().containsKey(request.origin().toLowerCase(Locale.ROOT)))
|
||||
sendMultiProxyEnvelopeAccepted(VotingPluginWire.sessionFenceReply(request,
|
||||
com.bencodez.votingplugin.core.session.ProxyVoteSequence.currentFence()), List.of(request.origin()));
|
||||
return;
|
||||
}
|
||||
if (sub.equals(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REPLY)) {
|
||||
var reply = VotingPluginWire.readSessionFenceReply(envelope);
|
||||
if (reply != null && reply.request().origin().equals(getMultiProxyServerName())) onSessionFenceReply(envelope);
|
||||
return;
|
||||
}
|
||||
if (sub.equalsIgnoreCase(VotingPluginWire.SUB_STATUS)) {
|
||||
logInfo("Multi-proxy status message received");
|
||||
return;
|
||||
@@ -1276,6 +1290,8 @@ public abstract class MultiProxyHandler {
|
||||
if (origin.isBlank()) {
|
||||
triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid);
|
||||
} else {
|
||||
// Preserve the receiver-local time used by ordinary backend cooldowns.
|
||||
// Guide confirmation rejects forwarded ingress independently of this time.
|
||||
triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid,
|
||||
wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown);
|
||||
}
|
||||
|
||||
+1
-1
@@ -524,7 +524,7 @@ public class VotingPluginVelocity {
|
||||
MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
return;
|
||||
}
|
||||
pending = new PendingIncomingVote(UUID.randomUUID(), player, service, System.currentTimeMillis());
|
||||
pending = new PendingIncomingVote(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId(), player, service, System.currentTimeMillis());
|
||||
if (votingPluginProxy == null || !votingPluginProxy.retainIncomingVoteForRestart(pending)) {
|
||||
logger.error("Pending vote admission is full and durable overflow failed; vote was not accepted for {}",
|
||||
MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
|
||||
+2
-1
@@ -123,7 +123,8 @@ public class ServiceSiteHandler {
|
||||
|
||||
public void saveToServerData() {
|
||||
List<String> out = toListFormat(sitesRef.get());
|
||||
plugin.getServerData().setStringList(SERVERDATA_PATH, out);
|
||||
plugin.getServerData().getData().set(SERVERDATA_PATH, out);
|
||||
plugin.getServerData().saveData();
|
||||
}
|
||||
|
||||
public void refreshFromWebOnce(boolean logMore) {
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.bencodez.votingplugin.session;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import io.github.projectunified.unidialog.core.payload.DialogPayload;
|
||||
|
||||
/** Lazy host adapter over already bundled UniDialog; owns its own callbacks and event hook. */
|
||||
final class GuidedSessionDialogs implements SessionDialogView {
|
||||
private record Invocation(BiConsumer<UUID, String> callback, UUID owner, String action) { }
|
||||
private final ThreadLocal<List<Invocation>> pendingCallbacks = new ThreadLocal<>();
|
||||
private static final String NAMESPACE = "votingplugin_session";
|
||||
private record Platform(String manager, Class<?> event, boolean paper) {}
|
||||
private final Object manager;
|
||||
private final Platform platform;
|
||||
|
||||
private static Platform probe() throws ReflectiveOperationException {
|
||||
ClassLoader loader = GuidedSessionDialogs.class.getClassLoader();
|
||||
try {
|
||||
Class<?> dialog = Class.forName("io.papermc.paper.dialog.Dialog", false, loader);
|
||||
Class<?> event = Class.forName("io.papermc.paper.event.player.PlayerCustomClickEvent", false, loader);
|
||||
event.getMethod("getIdentifier");
|
||||
event.getMethod("getCommonConnection");
|
||||
Class<?> dialogLike = Class.forName("net.kyori.adventure.dialog.DialogLike", false, loader);
|
||||
Player.class.getMethod("showDialog", dialogLike);
|
||||
Class.forName("io.papermc.paper.registry.data.dialog.ActionButton", false, loader);
|
||||
if (!dialogLike.isAssignableFrom(dialog)) throw new NoSuchMethodException("Incompatible Paper dialog API");
|
||||
return new Platform("io.github.projectunified.unidialog.paper.PaperDialogManager", event, true);
|
||||
} catch (ClassNotFoundException | NoSuchMethodException noPaper) {
|
||||
Class<?> dialog = Class.forName("net.md_5.bungee.api.dialog.Dialog", false, loader);
|
||||
Class<?> event = Class.forName("org.bukkit.event.player.PlayerCustomClickEvent", false, loader);
|
||||
event.getMethod("getId");
|
||||
event.getMethod("getPlayer");
|
||||
Player.class.getMethod("showDialog", dialog);
|
||||
return new Platform("io.github.projectunified.unidialog.spigot.SpigotDialogManager", event, false);
|
||||
}
|
||||
}
|
||||
static boolean supported() {
|
||||
try { probe(); return true; }
|
||||
catch (ReflectiveOperationException | LinkageError unavailable) { return false; }
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
GuidedSessionDialogs(VotingPluginMain plugin) {
|
||||
try {
|
||||
platform = probe();
|
||||
Class<?> type = Class.forName(platform.manager(), true, getClass().getClassLoader());
|
||||
manager = type.getConstructor(Plugin.class, String.class).newInstance(plugin, NAMESPACE);
|
||||
Method dispatch = type.getMethod("onCustomClick", platform.event());
|
||||
type.getMethod("registerCustomAction", String.class, String.class, Consumer.class);
|
||||
type.getMethod("unregisterCustomAction", String.class, String.class);
|
||||
// Do not register the library listener: all its HashMap accesses use this private lock.
|
||||
plugin.getServer().getPluginManager().registerEvent((Class<? extends Event>) platform.event(),
|
||||
new Listener() {}, EventPriority.NORMAL, (listener, event) -> {
|
||||
if (!platform.event().isInstance(event)) return;
|
||||
List<Invocation> callbacks = new ArrayList<>();
|
||||
pendingCallbacks.set(callbacks);
|
||||
try {
|
||||
synchronized (manager) { dispatch.invoke(manager, event); }
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
plugin.getLogger().warning("Voting session dialog callback failed: " + failure.getClass().getSimpleName());
|
||||
} finally {
|
||||
pendingCallbacks.remove();
|
||||
}
|
||||
// Controller callbacks can acquire their own locks or schedule work.
|
||||
// Never invoke them while holding the library manager's registry lock.
|
||||
for (Invocation callback : callbacks)
|
||||
callback.callback().accept(callback.owner(), callback.action());
|
||||
}, plugin, false);
|
||||
} catch (ReflectiveOperationException | LinkageError failure) {
|
||||
throw new IllegalStateException("Native dialog API unavailable", failure);
|
||||
}
|
||||
}
|
||||
public Runnable show(Player player, String title, String body, List<Button> buttons,
|
||||
BiConsumer<UUID, String> action) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
synchronized (manager) {
|
||||
try {
|
||||
Object dialog = call(manager, "createMultiActionDialog", new Class<?>[0]);
|
||||
call(dialog, "title", new Class<?>[] {String.class}, title);
|
||||
call(dialog, "canCloseWithEscape", new Class<?>[] {boolean.class}, true);
|
||||
call(dialog, "pause", new Class<?>[] {boolean.class}, false);
|
||||
call(dialog, "afterAction", new Class<?>[] {io.github.projectunified.unidialog.core.dialog.Dialog.AfterAction.class},
|
||||
io.github.projectunified.unidialog.core.dialog.Dialog.AfterAction.CLOSE);
|
||||
call(dialog, "columns", new Class<?>[] {int.class}, 1);
|
||||
call(dialog, "body", new Class<?>[] {Consumer.class}, (Consumer<Object>) builder -> {
|
||||
Object text = call(builder, "text", new Class<?>[0]);
|
||||
call(text, "text", new Class<?>[] {String.class}, body);
|
||||
});
|
||||
for (var button : buttons) {
|
||||
String id = UUID.randomUUID().toString();
|
||||
if (button.action() != null) {
|
||||
Consumer<DialogPayload> callback = payload -> {
|
||||
List<Invocation> queued = pendingCallbacks.get();
|
||||
if (queued != null && queued.size() < 16)
|
||||
queued.add(new Invocation(action, payload.owner(), button.action()));
|
||||
};
|
||||
call(manager, "registerCustomAction", new Class<?>[] {String.class, String.class, Consumer.class},
|
||||
NAMESPACE, id, callback);
|
||||
ids.add(id);
|
||||
}
|
||||
call(dialog, "action", new Class<?>[] {Consumer.class}, (Consumer<Object>) builder -> {
|
||||
call(builder, "label", new Class<?>[] {String.class}, button.label());
|
||||
call(builder, "tooltip", new Class<?>[] {String.class}, button.tooltip());
|
||||
if (button.url() != null) call(builder, "openUrl", new Class<?>[] {String.class}, button.url());
|
||||
else call(builder, "dynamicCustom", new Class<?>[] {String.class, String.class}, NAMESPACE, id);
|
||||
});
|
||||
}
|
||||
Object opener = call(dialog, "opener", new Class<?>[0]);
|
||||
Class<?> audience = platform.paper()
|
||||
? Class.forName("net.kyori.adventure.audience.Audience", false, getClass().getClassLoader()) : Player.class;
|
||||
call(opener, "open", new Class<?>[] {audience}, player);
|
||||
} catch (RuntimeException | LinkageError | ClassNotFoundException failure) {
|
||||
release(ids);
|
||||
throw new IllegalStateException("Native dialog could not open", failure);
|
||||
}
|
||||
}
|
||||
List<String> owned = List.copyOf(ids);
|
||||
return () -> { synchronized (manager) { release(owned); } };
|
||||
}
|
||||
private void release(List<String> ids) {
|
||||
for (String id : ids) call(manager, "unregisterCustomAction", new Class<?>[] {String.class, String.class}, NAMESPACE, id);
|
||||
}
|
||||
private static Object call(Object target, String name, Class<?>[] types, Object... arguments) {
|
||||
try { return target.getClass().getMethod(name, types).invoke(target, arguments); }
|
||||
catch (ReflectiveOperationException failure) { throw new IllegalStateException("Native dialog method unavailable: " + name, failure); }
|
||||
}
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
package com.bencodez.votingplugin.session;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.core.session.GuidedVoteSession;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import net.md_5.bungee.api.chat.ClickEvent;
|
||||
import net.md_5.bungee.api.chat.TextComponent;
|
||||
|
||||
/** Observation-only native guidance with a clear chat fallback on older hosts. */
|
||||
public final class GuidedVotingSessions implements Listener {
|
||||
private final VotingPluginMain plugin;
|
||||
private final Map<UUID, GuidedVoteSession> sessions = new LinkedHashMap<>();
|
||||
private final Map<UUID, Long> requests = new LinkedHashMap<>();
|
||||
private record EarlyReceipt(UUID storageId, String site, UUID occurrence, long time, boolean observed, boolean proxy) { }
|
||||
private final Map<UUID, UUID> storageIds = new LinkedHashMap<>();
|
||||
private final Map<UUID, String> playerNames = new LinkedHashMap<>();
|
||||
private final Map<UUID, Player> owners = new LinkedHashMap<>();
|
||||
private final Map<UUID, Map<String, List<EarlyReceipt>>> early = new LinkedHashMap<>();
|
||||
private record ProxyRequest(UUID nonce, String server, long startedNanos, boolean sent) { }
|
||||
private final Map<UUID, ProxyRequest> proxyRequests = new LinkedHashMap<>();
|
||||
private long lifecycle;
|
||||
private final java.util.Set<Long> outstanding = new java.util.HashSet<>();
|
||||
private final Map<UUID, Long> pending = new LinkedHashMap<>();
|
||||
private long generation;
|
||||
private final java.util.function.Supplier<SessionDialogView> dialogFactory;
|
||||
private SessionDialogView dialogView;
|
||||
private boolean dialogChecked;
|
||||
private record DialogRegistration(UUID nonce, Runnable cleanup) { }
|
||||
private java.util.concurrent.ScheduledFuture<?> dialogCleanup;
|
||||
private final Map<UUID, DialogRegistration> dialogs = new LinkedHashMap<>();
|
||||
private final java.util.Set<UUID> automaticRefresh = new java.util.HashSet<>();
|
||||
|
||||
public GuidedVotingSessions(VotingPluginMain plugin) {
|
||||
this(plugin, () -> GuidedSessionDialogs.supported() ? new GuidedSessionDialogs(plugin) : null);
|
||||
}
|
||||
GuidedVotingSessions(VotingPluginMain plugin, java.util.function.Supplier<SessionDialogView> factory) {
|
||||
this.plugin = plugin; this.dialogFactory = factory;
|
||||
}
|
||||
public synchronized void clear() { for (UUID owner : List.copyOf(dialogs.keySet())) retireDialog(owner); if (dialogCleanup != null) { dialogCleanup.cancel(false); dialogCleanup = null; } automaticRefresh.clear(); sessions.clear(); proxyRequests.clear(); requests.clear(); storageIds.clear(); playerNames.clear(); owners.clear(); early.clear(); pending.clear(); generation++; lifecycle++; }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public synchronized void credited(PlayerPostVoteEvent event) {
|
||||
if (event.getVoteSite() == null || !event.isRealVote() || event.isCancelled()
|
||||
|| event.getVoteUUID() == null || event.getUuid() == null) return;
|
||||
boolean proxy = event.isProxySessionDelivery() || event.isBungee();
|
||||
// Proxy transport flags and clocks prove no original order; negotiated source fences do.
|
||||
if (proxy ? !event.isProxySessionRealVote() : event.isUnconfirmedLocalSessionDelivery()) return;
|
||||
boolean observed = event.isLiveLocalSessionDelivery() && event.getBackendObservationOrder() != 0;
|
||||
long time = observed ? event.getBackendObservationOrder() : event.getVoteTime();
|
||||
expire(System.currentTimeMillis());
|
||||
String key = event.getVoteSite().getKey();
|
||||
for (var entry : sessions.entrySet()) {
|
||||
UUID owner = entry.getKey(); GuidedVoteSession session = entry.getValue();
|
||||
if (event.getUuid().equals(storageIds.get(owner))) {
|
||||
var before = session.view("");
|
||||
if (proxy) session.acceptedProxy(key, event.getVoteUUID());
|
||||
else if (observed) session.acceptedObserved(key, event.getVoteUUID(), time);
|
||||
else session.accepted(key, event.getVoteUUID(), time, true, false);
|
||||
if (confirmedTarget(before, session.view(""))) refresh(owner, session);
|
||||
} else if (!storageIds.containsKey(owner) && session.candidate(key)
|
||||
&& (proxy || observed || time > session.started()) && event.getPlayerName() != null
|
||||
&& event.getPlayerName().equalsIgnoreCase(playerNames.get(owner))) {
|
||||
// Name only routes a bounded provisional receipt. Resolved storage UUID must
|
||||
// match before it can confirm progress; no player/entity access occurs here.
|
||||
// A failed lookup releases pending work, but this active guide still owns
|
||||
// its bounded provisional receipts until identity resolution or expiration.
|
||||
var receipts = early.computeIfAbsent(owner, ignored -> new LinkedHashMap<>())
|
||||
.computeIfAbsent(key, ignored -> new ArrayList<>());
|
||||
receipts.removeIf(receipt -> receipt.occurrence().equals(event.getVoteUUID()));
|
||||
if (receipts.size() >= 4) receipts.remove(0);
|
||||
receipts.add(new EarlyReceipt(event.getUuid(), key, event.getVoteUUID(), time, observed, proxy));
|
||||
}
|
||||
}
|
||||
// Never execute rewards from a notification; a newly accepted occurrence may refresh the guide.
|
||||
}
|
||||
|
||||
/** Recheck current eligibility on the existing worker before reopening a credited guide. */
|
||||
private void refresh(UUID owner, GuidedVoteSession session) {
|
||||
if (session.view("").finished()) return;
|
||||
automaticRefresh.add(owner);
|
||||
Player player = owners.get(owner);
|
||||
if (player == null) return;
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) {
|
||||
expire(System.currentTimeMillis());
|
||||
if (sessions.get(owner) != session || owners.get(owner) != player || session.view("").finished()) return;
|
||||
// A current worker owns a pending refresh; its final owner callback
|
||||
// consumes the flag and samples the latest confirmed progress.
|
||||
if (!pending.containsKey(owner)) prepare(player, "automatic");
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
}
|
||||
|
||||
/** Transport callback: bounded identities only; no Bukkit, user storage or reward execution. */
|
||||
public synchronized void proxyFence(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope message) {
|
||||
var reply = com.bencodez.votingplugin.proxy.VotingPluginWire.readSessionFenceReply(message);
|
||||
if (reply == null) return;
|
||||
expire(System.currentTimeMillis());
|
||||
UUID owner = reply.request().player();
|
||||
ProxyRequest request = proxyRequests.get(owner);
|
||||
GuidedVoteSession session = sessions.get(owner);
|
||||
if (request == null || !request.sent() || session == null || !request.nonce().equals(reply.request().nonce())
|
||||
|| !request.server().equals(reply.request().server())
|
||||
|| System.nanoTime() - request.startedNanos() > java.util.concurrent.TimeUnit.SECONDS.toNanos(30)) return;
|
||||
var before = session.view("");
|
||||
session.bindProxyFence(reply.fence());
|
||||
if (confirmedTarget(before, session.view(""))) refresh(owner, session);
|
||||
}
|
||||
|
||||
private static boolean confirmedTarget(GuidedVoteSession.View before, GuidedVoteSession.View after) {
|
||||
return before.current() != null && after.current() != null
|
||||
&& before.current().site().key().equals(after.current().site().key())
|
||||
&& before.current().status() != GuidedVoteSession.Status.RECEIVED
|
||||
&& after.current().status() == GuidedVoteSession.Status.RECEIVED;
|
||||
}
|
||||
|
||||
/** Capture a registration's lifecycle before the command framework queues asynchronous execution. */
|
||||
public synchronized java.util.function.Consumer<Player> commandHandler(String action) {
|
||||
long epoch = lifecycle;
|
||||
return player -> BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) { if (epoch == lifecycle) prepare(player, action); }
|
||||
}, () -> { }, () -> { });
|
||||
}
|
||||
|
||||
public void command(Player player, String action) {
|
||||
commandHandler(action).accept(player);
|
||||
}
|
||||
private void prepare(Player player, String action) {
|
||||
if (!player.isOnline() || plugin.getServer().getPlayer(player.getUniqueId()) != player) return;
|
||||
if (!plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)) {
|
||||
player.sendMessage("Guided voting sessions are disabled by this server."); return;
|
||||
}
|
||||
if (!plugin.getCommandLoader().canUseGuidedSession(player)) return;
|
||||
UUID uuid = player.getUniqueId();
|
||||
UUID storageUuid = VotingPluginMain.placeholderStorageUuid(player, plugin.getOptions().isOnlineMode());
|
||||
String name = player.getName();
|
||||
List<VoteSite> visible = new ArrayList<>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) {
|
||||
if (site.isEnabled() && !site.isHidden() && (site.getPermissionToView().isEmpty()
|
||||
|| player.hasPermission(site.getPermissionToView()))) visible.add(site);
|
||||
if (visible.size() >= 100) break;
|
||||
}
|
||||
GuidedVoteSession session;
|
||||
boolean creating;
|
||||
long request;
|
||||
com.bencodez.votingplugin.proxy.VotingPluginWire.SessionFenceRequest proxyRequest;
|
||||
synchronized (this) {
|
||||
long now = System.currentTimeMillis();
|
||||
expire(now);
|
||||
if (action.equals("automatic") && !sessions.containsKey(uuid)) return;
|
||||
if (pending.containsKey(uuid) || outstanding.size() >= 64) {
|
||||
player.sendMessage("Voting status is already being checked or busy. Please try again shortly."); return;
|
||||
}
|
||||
if (!sessions.containsKey(uuid) && sessions.size() >= 2048) {
|
||||
player.sendMessage("Voting sessions are busy. Please try again later."); return;
|
||||
}
|
||||
if (action.equals("restart")) { retireDialog(uuid); automaticRefresh.remove(uuid); sessions.remove(uuid); storageIds.remove(uuid); early.remove(uuid); proxyRequests.remove(uuid); }
|
||||
creating = !sessions.containsKey(uuid);
|
||||
session = sessions.computeIfAbsent(uuid, ignored -> new GuidedVoteSession(now));
|
||||
proxyRequest = null;
|
||||
if (creating && plugin.getBungeeSettings().isUseBungeecoord()) {
|
||||
var fenceRequest = new ProxyRequest(UUID.randomUUID(), plugin.getOptions().getServer(), 0, false);
|
||||
proxyRequests.put(uuid, fenceRequest);
|
||||
proxyRequest = new com.bencodez.votingplugin.proxy.VotingPluginWire.SessionFenceRequest(
|
||||
uuid, fenceRequest.nonce(), fenceRequest.server(), "");
|
||||
}
|
||||
session.bindCandidates(visible.stream().map(site -> new GuidedVoteSession.Site(site.getKey(),
|
||||
site.getDisplayName(), site.getVoteURL(true), false, 0)).toList());
|
||||
playerNames.put(uuid, name);
|
||||
owners.put(uuid, player);
|
||||
request = ++generation;
|
||||
requests.put(uuid, request);
|
||||
pending.put(uuid, request);
|
||||
outstanding.add(request);
|
||||
}
|
||||
final var requestToSend = proxyRequest;
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
synchronized (this) { if (!current(uuid, session, request)) return; }
|
||||
if (requestToSend != null) {
|
||||
var handler = plugin.getBackendProxyHandler();
|
||||
if (handler != null && handler.getGlobalMessageHandler() != null) {
|
||||
synchronized (this) {
|
||||
if (!current(uuid, session, request)) return;
|
||||
var queued = proxyRequests.get(uuid);
|
||||
if (queued == null || !queued.nonce().equals(requestToSend.nonce())) return;
|
||||
// Queue wait is not reply time. Publish before sending so an
|
||||
// immediate transport reply can bind this exact live request.
|
||||
proxyRequests.put(uuid, new ProxyRequest(queued.nonce(), queued.server(), System.nanoTime(), true));
|
||||
}
|
||||
handler.getGlobalMessageHandler().sendMessage(
|
||||
com.bencodez.votingplugin.proxy.VotingPluginWire.sessionFenceRequest(requestToSend));
|
||||
}
|
||||
}
|
||||
var currentSites = new java.util.HashMap<String, VoteSite>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) currentSites.put(site.getKey(), site);
|
||||
var sampledSites = new java.util.HashMap<String, VoteSite>();
|
||||
var user = plugin.getVotingPluginUserManager().getVotingPluginUser(storageUuid, name);
|
||||
String storedName = user.getPlayerName();
|
||||
String voteName = storedName == null ? name : storedName;
|
||||
var lastVotes = new java.util.HashMap<String, Long>();
|
||||
user.getLastVotes().forEach((site, time) -> lastVotes.put(site.getKey(), time));
|
||||
List<GuidedVoteSession.Site> snapshots = new ArrayList<>();
|
||||
for (VoteSite original : visible) {
|
||||
VoteSite site = currentSites.get(original.getKey());
|
||||
if (site == null || !site.isEnabled() || site.isHidden()) continue;
|
||||
sampledSites.put(site.getKey(), site);
|
||||
snapshots.add(new GuidedVoteSession.Site(site.getKey(), site.getDisplayName(),
|
||||
com.bencodez.advancedcore.api.messages.PlaceholderUtils.replacePlaceHolder(site.getVoteURL(true),
|
||||
"player", voteName),
|
||||
user.canVoteSite(site, lastVotes.getOrDefault(site.getKey(), 0L)),
|
||||
plugin.getBungeeSettings().isUseBungeecoord() ? 0 : lastVotes.getOrDefault(site.getKey(), 0L)));
|
||||
}
|
||||
synchronized (this) {
|
||||
if (!current(uuid, session, request)) return;
|
||||
UUID storageId = user.getJavaUUID();
|
||||
storageIds.put(uuid, storageId);
|
||||
var provisional = early.remove(uuid);
|
||||
if (provisional != null) for (var receipts : provisional.values()) for (var receipt : receipts) {
|
||||
if (receipt.storageId().equals(storageId)) {
|
||||
if (receipt.proxy()) session.acceptedProxy(receipt.site(), receipt.occurrence());
|
||||
else if (receipt.observed()) session.acceptedObserved(receipt.site(), receipt.occurrence(), receipt.time());
|
||||
else session.accepted(receipt.site(), receipt.occurrence(), receipt.time(), true, false);
|
||||
}
|
||||
}
|
||||
session.refresh(snapshots);
|
||||
// Storage sampling is complete before an owner callback can render.
|
||||
// A newly credited vote must be able to prepare its successor even
|
||||
// when this callback completes before the worker enters finally.
|
||||
pending.remove(uuid, request); outstanding.remove(request);
|
||||
}
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) { if (!current(uuid, session, request)) return; }
|
||||
if (player.isOnline() && plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)
|
||||
&& plugin.getCommandLoader().canUseGuidedSession(player)) {
|
||||
synchronized (this) {
|
||||
if (!current(uuid, session, request)) return;
|
||||
boolean automatic = automaticRefresh.remove(uuid) || action.equals("automatic");
|
||||
if (action.equals("automatic") && session.view("").finished()) return;
|
||||
// Visibility may change after the worker's storage sample.
|
||||
// Refresh from owner-validated immutable snapshots before
|
||||
// choosing the next target; no storage is accessed here.
|
||||
var ownerVisible = visibleView(player, session.view(""), sampledSites);
|
||||
session.refresh(ownerVisible.entries().stream().map(GuidedVoteSession.Entry::site).toList());
|
||||
if (automatic || action.isEmpty()) session.selectNextAwaiting();
|
||||
var view = session.view(action);
|
||||
render(player, visibleView(player, view, sampledSites));
|
||||
}
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
} catch (RuntimeException failure) {
|
||||
plugin.debug(failure);
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) { if (!current(uuid, session, request)) return; }
|
||||
if (player.isOnline() && plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)
|
||||
&& plugin.getCommandLoader().canUseGuidedSession(player)) player.sendMessage("Could not check voting status. Please try " + sessionCommand("check") + ".");
|
||||
}, () -> { }, () -> { });
|
||||
} finally {
|
||||
synchronized (this) { pending.remove(uuid, request); outstanding.remove(request); }
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException unavailable) {
|
||||
synchronized (this) {
|
||||
pending.remove(uuid, request); outstanding.remove(request);
|
||||
// No worker ran to send the initial fence. Retire only this new guide,
|
||||
// so an ordinary reopen retries creation without resetting existing progress.
|
||||
if (creating && sessions.get(uuid) == session && Long.valueOf(request).equals(requests.get(uuid))) {
|
||||
sessions.remove(uuid); requests.remove(uuid); storageIds.remove(uuid);
|
||||
playerNames.remove(uuid); early.remove(uuid); proxyRequests.remove(uuid);
|
||||
}
|
||||
}
|
||||
player.sendMessage("Voting status is temporarily unavailable. Please try again.");
|
||||
}
|
||||
}
|
||||
/** Expiration releases owner admission, while outstanding work retains its global slot until it finishes. */
|
||||
private synchronized void expire(long now) {
|
||||
int minutes = Math.max(1, Math.min(120, plugin.getConfigFile().getData().getInt("GuidedVotingSession.TimeoutMinutes", 30)));
|
||||
sessions.entrySet().removeIf(e -> {
|
||||
if (now - e.getValue().started() < minutes * 60_000L) return false;
|
||||
retireDialog(e.getKey()); automaticRefresh.remove(e.getKey()); return true;
|
||||
});
|
||||
requests.keySet().retainAll(sessions.keySet());
|
||||
pending.keySet().retainAll(sessions.keySet());
|
||||
storageIds.keySet().retainAll(sessions.keySet());
|
||||
playerNames.keySet().retainAll(sessions.keySet());
|
||||
owners.keySet().retainAll(sessions.keySet());
|
||||
early.keySet().retainAll(sessions.keySet());
|
||||
proxyRequests.keySet().retainAll(sessions.keySet());
|
||||
}
|
||||
private synchronized boolean current(UUID uuid, GuidedVoteSession session, long request) {
|
||||
expire(System.currentTimeMillis());
|
||||
return sessions.get(uuid) == session && Long.valueOf(request).equals(requests.get(uuid));
|
||||
}
|
||||
/** Recheck visibility on the player context, after any asynchronous wait. */
|
||||
private GuidedVoteSession.View visibleView(Player player, GuidedVoteSession.View view, Map<String, VoteSite> sampledSites) {
|
||||
java.util.Set<String> allowed = new java.util.HashSet<>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) {
|
||||
if (sampledSites.get(site.getKey()) == site && site.isEnabled() && !site.isHidden() && (site.getPermissionToView().isEmpty()
|
||||
|| player.hasPermission(site.getPermissionToView()))) allowed.add(site.getKey());
|
||||
}
|
||||
List<GuidedVoteSession.Entry> entries = new ArrayList<>();
|
||||
for (var entry : view.entries()) entries.add(allowed.contains(entry.site().key()) ? entry
|
||||
: new GuidedVoteSession.Entry(new GuidedVoteSession.Site(entry.site().key(), "Unavailable site",
|
||||
null, false, 0), GuidedVoteSession.Status.UNAVAILABLE));
|
||||
return new GuidedVoteSession.View(List.copyOf(entries), view.index(), view.finished());
|
||||
}
|
||||
private void render(Player player, GuidedVoteSession.View view) {
|
||||
retireDialog(player.getUniqueId());
|
||||
try {
|
||||
if (!dialogChecked) { dialogChecked = true; dialogView = dialogFactory.get(); }
|
||||
if (dialogView != null) { renderDialog(player, view); return; }
|
||||
} catch (RuntimeException | LinkageError unsupported) {
|
||||
plugin.debug(unsupported);
|
||||
}
|
||||
player.sendMessage("Native voting dialogs are unavailable on this server. Showing chat guidance instead.");
|
||||
renderChat(player, view);
|
||||
}
|
||||
private void renderDialog(Player player, GuidedVoteSession.View view) {
|
||||
UUID owner = player.getUniqueId();
|
||||
UUID nonce = UUID.randomUUID();
|
||||
GuidedVoteSession session = sessions.get(owner);
|
||||
List<SessionDialogView.Button> buttons = new ArrayList<>();
|
||||
String body = view.received() + "/" + view.entries().size() + " votes received.";
|
||||
if (view.entries().isEmpty()) body += "\nNo eligible, visible voting sites. Restart after your cooldowns end.";
|
||||
else if (view.finished() || view.complete()) body += view.complete()
|
||||
? "\nAll session votes were received. Thank you!" : "\nSession finished. Unconfirmed votes remain unconfirmed.";
|
||||
else {
|
||||
var entry = view.current();
|
||||
body += "\nSite " + (view.index() + 1) + "/" + view.entries().size() + ": " + entry.site().name()
|
||||
+ "\n" + entry.status() + "\nWaiting for VotingPlugin to receive your vote.";
|
||||
String url = GuidedVoteSession.httpUrl(entry.site().url());
|
||||
if (url != null && (entry.status() == GuidedVoteSession.Status.AWAITING || entry.status() == GuidedVoteSession.Status.SKIPPED))
|
||||
buttons.add(new SessionDialogView.Button("Open voting link", "Your client controls opening the browser.", null, url));
|
||||
else if (url == null) body += "\nThis site has no valid HTTP/HTTPS link. Ask an administrator.";
|
||||
}
|
||||
if (session != null && plugin.getBungeeSettings().isUseBungeecoord() && !session.hasProxyFence())
|
||||
body += "\nProxy confirmation is pending or unavailable.";
|
||||
body += "\nOpening a link never grants a vote or reward.";
|
||||
if (!view.entries().isEmpty() && !view.finished() && !view.complete())
|
||||
for (String action : List.of("previous", "next", "skip", "check", "finish"))
|
||||
buttons.add(new SessionDialogView.Button(action, "", action, null));
|
||||
buttons.add(new SessionDialogView.Button("Restart session", "", "restart", null));
|
||||
buttons.add(new SessionDialogView.Button("Close", "Your current session is retained.", "close", null));
|
||||
java.util.concurrent.atomic.AtomicBoolean used = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
Runnable cleanup = dialogView.show(player, "Voting session", body, List.copyOf(buttons), (actor, action) -> {
|
||||
if (!owner.equals(actor)) return;
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) {
|
||||
expire(System.currentTimeMillis());
|
||||
DialogRegistration active = dialogs.get(owner);
|
||||
if (active == null || !nonce.equals(active.nonce()) || sessions.get(owner) != session
|
||||
|| !player.isOnline() || !used.compareAndSet(false, true)) return;
|
||||
retireDialog(owner);
|
||||
if (plugin.getCommandLoader().canUseGuidedSession(player) && !action.equals("close")) prepare(player, action);
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
});
|
||||
try {
|
||||
// One bounded sweep owns all callback deadlines. Replacing a view
|
||||
// does not leave cancelled per-view futures in a shared timer queue.
|
||||
if (dialogCleanup == null) dialogCleanup = plugin.getTimer().scheduleWithFixedDelay(() -> {
|
||||
synchronized (this) { expire(System.currentTimeMillis()); }
|
||||
}, 30, 30, java.util.concurrent.TimeUnit.SECONDS);
|
||||
dialogs.put(owner, new DialogRegistration(nonce, cleanup));
|
||||
} catch (RuntimeException failure) {
|
||||
cleanup.run(); throw failure;
|
||||
}
|
||||
}
|
||||
private void retireDialog(UUID owner) {
|
||||
DialogRegistration registration = dialogs.remove(owner);
|
||||
if (registration == null) return;
|
||||
try { registration.cleanup().run(); }
|
||||
catch (RuntimeException failure) { plugin.debug(failure); }
|
||||
}
|
||||
@EventHandler
|
||||
public synchronized void disconnected(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
UUID owner = event.getPlayer().getUniqueId();
|
||||
retireDialog(owner); requests.remove(owner); pending.remove(owner); automaticRefresh.remove(owner); owners.remove(owner);
|
||||
}
|
||||
private void renderChat(Player player, GuidedVoteSession.View view) {
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord()) {
|
||||
GuidedVoteSession session = sessions.get(player.getUniqueId());
|
||||
if (session != null && !session.hasProxyFence()) player.sendMessage(
|
||||
"Proxy confirmation is pending or unavailable. Only votes after a supporting proxy acknowledges this session can be confirmed.");
|
||||
}
|
||||
player.sendMessage("Voting session: " + view.received() + "/" + view.entries().size() + " votes received.");
|
||||
if (view.entries().isEmpty()) {
|
||||
player.sendMessage("No eligible, visible voting sites. Try " + sessionCommand("restart") + " after your cooldowns end."); return;
|
||||
}
|
||||
if (view.finished() || view.complete()) {
|
||||
player.sendMessage(view.complete() ? "All session votes were received. Thank you!"
|
||||
: "Session finished. Unconfirmed votes remain unconfirmed; normal vote rewards are unchanged.");
|
||||
}
|
||||
for (int i = 0; i < view.entries().size(); i++) {
|
||||
var entry = view.entries().get(i);
|
||||
player.sendMessage((i == view.index() ? "> " : " ") + entry.site().name() + " — " + entry.status());
|
||||
}
|
||||
var entry = view.current();
|
||||
if (!view.finished() && (entry.status() == GuidedVoteSession.Status.AWAITING || entry.status() == GuidedVoteSession.Status.SKIPPED)) {
|
||||
String url = GuidedVoteSession.httpUrl(entry.site().url());
|
||||
if (url != null) button(player, "Open voting link: " + entry.site().name(), ClickEvent.Action.OPEN_URL, url);
|
||||
else player.sendMessage("This site has no valid HTTP/HTTPS link. Ask an administrator.");
|
||||
}
|
||||
for (String action : List.of("previous", "next", "skip", "check", "finish", "restart"))
|
||||
button(player, "[" + action + "]", ClickEvent.Action.RUN_COMMAND, sessionCommand(action));
|
||||
player.sendMessage("A link or click is not vote confirmation. Check again after the vote arrives.");
|
||||
}
|
||||
private String sessionCommand(String action) {
|
||||
return "/" + plugin.getCommandLoader().getVoteCommandName() + " session " + action;
|
||||
}
|
||||
private static void button(Player player, String title, ClickEvent.Action action, String value) {
|
||||
TextComponent component = new TextComponent(title);
|
||||
component.setClickEvent(new ClickEvent(action, value));
|
||||
player.spigot().sendMessage(component);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.bencodez.votingplugin.session;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** A player-owned native view; cleanup releases only this view's action handlers. */
|
||||
interface SessionDialogView {
|
||||
record Button(String label, String tooltip, String action, String url) { }
|
||||
Runnable show(Player player, String title, String body, List<Button> buttons,
|
||||
BiConsumer<UUID, String> action);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -24,14 +25,14 @@ public class SignHandler {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private volatile String data;
|
||||
private String data;
|
||||
|
||||
/** The lines. */
|
||||
private ArrayList<String> lines;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private volatile Location location;
|
||||
private Location location;
|
||||
|
||||
private String playerName;
|
||||
|
||||
@@ -47,7 +48,7 @@ public class SignHandler {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private volatile Location skullLocation;
|
||||
private Location skullLocation;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -55,88 +56,6 @@ 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.
|
||||
*
|
||||
@@ -162,28 +81,19 @@ public class SignHandler {
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
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")) {
|
||||
private boolean checkSkull(Block block) {
|
||||
if (block.getState() instanceof Skull) {
|
||||
if (!playerName.equals("No Player")) {
|
||||
Skull skull = (Skull) block.getState();
|
||||
try {
|
||||
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);
|
||||
SkullMeta meta = (SkullMeta) plugin.getSkullCacheHandler().getSkull(uuid, playerName).getItemMeta();
|
||||
skull.setOwnerProfile(meta.getOwnerProfile());
|
||||
skull.update(true, false);
|
||||
} catch (Exception e) {
|
||||
plugin.debug("Failed to set skull for " + name);
|
||||
plugin.debug("Failed to set skull for " + playerName);
|
||||
plugin.debug(e);
|
||||
if (!current(frame)) return true;
|
||||
if (skull.setOwner(name) && current(frame) && skull.update(true, false)) rememberSkull(frame, target);
|
||||
skull.setOwner(playerName);
|
||||
skull.update(true, false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -196,24 +106,18 @@ public class SignHandler {
|
||||
* Check skulls.
|
||||
*/
|
||||
public void checkSkulls() {
|
||||
Render rendered = currentRender();
|
||||
checkSkulls(rendered.name(), rendered.identity());
|
||||
}
|
||||
if (noNameAvailable()) {
|
||||
return;
|
||||
}
|
||||
if (skullLocation != null) {
|
||||
checkSkull(skullLocation.getBlock());
|
||||
return;
|
||||
}
|
||||
|
||||
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 loc = location;
|
||||
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, frame);
|
||||
updateSkulls(loc1, loc2);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,12 +190,6 @@ 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);
|
||||
}
|
||||
@@ -300,131 +198,95 @@ public class SignHandler {
|
||||
* Update lines.
|
||||
*/
|
||||
public void updateLines() {
|
||||
Render rendered = renderLines();
|
||||
synchronized (this) { applyRender(rendered); }
|
||||
}
|
||||
|
||||
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>();
|
||||
lines = new ArrayList<>();
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
/** 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) {
|
||||
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());
|
||||
}
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
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());
|
||||
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());
|
||||
}
|
||||
} catch (Exception failure) {
|
||||
plugin.debug("Failed to update sign display");
|
||||
plugin.debug(failure);
|
||||
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sign.
|
||||
*
|
||||
* @param delay the delay
|
||||
*/
|
||||
public void updateSign(int delay) {
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, new Runnable() {
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public void run() {
|
||||
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();
|
||||
}
|
||||
|
||||
} catch (Exception ex) {
|
||||
plugin.debug("Failed to update sign at " + getLocation().toString());
|
||||
VotingPluginMain.plugin.debug(ex);
|
||||
}
|
||||
}
|
||||
}, delay, getLocation());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -434,38 +296,19 @@ public class SignHandler {
|
||||
* @param loc2 the second location
|
||||
*/
|
||||
public void updateSkulls(Location loc1, Location loc2) {
|
||||
Render rendered = currentRender();
|
||||
updateSkulls(loc1, loc2, rendered.name(), rendered.identity());
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
private void updateSkulls(Location loc1, Location loc2, String name, UUID identity) {
|
||||
updateSkulls(loc1, loc2, skullFrame(name, identity));
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
for (Block block : MiscUtils.getInstance().getRegionBlocks(location.getWorld(), loc1, loc2)) {
|
||||
checkSkull(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,69 +64,6 @@ 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.
|
||||
*/
|
||||
|
||||
+2
-3
@@ -146,9 +146,8 @@ public class VoteParty implements Listener {
|
||||
public void commandVoteParty(CommandSender sender) {
|
||||
if (plugin.getSpecialRewardsConfig().isVotePartyEnabled()) {
|
||||
ArrayList<String> msg = plugin.getConfigFile().getFormatCommandsVoteParty();
|
||||
var display = plugin.getServerData().getVotePartyDisplay();
|
||||
int votesRequired = display.required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired());
|
||||
int votes = display.total();
|
||||
int votesRequired = getVotesRequired();
|
||||
int votes = getTotalVotes();
|
||||
int neededVotes = votesRequired - votes;
|
||||
HashMap<String, String> placeholders = new HashMap<>();
|
||||
placeholders.put("votesrequired", "" + votesRequired);
|
||||
|
||||
+4
-2
@@ -24,7 +24,8 @@ public final class VotePartyState {
|
||||
}
|
||||
|
||||
public void setTotalVotes(int value) {
|
||||
plugin.getServerData().setInt(TOTAL_PATH, value);
|
||||
plugin.getServerData().getData().set(TOTAL_PATH, value);
|
||||
plugin.getServerData().saveData();
|
||||
}
|
||||
|
||||
public List<String> getVotedUsers() {
|
||||
@@ -33,7 +34,8 @@ public final class VotePartyState {
|
||||
}
|
||||
|
||||
public void setVotedUsers(List<String> users) {
|
||||
plugin.getServerData().setStringList(VOTED_PATH, users);
|
||||
plugin.getServerData().getData().set(VOTED_PATH, users);
|
||||
plugin.getServerData().saveData();
|
||||
}
|
||||
|
||||
public int getVotesRequired() {
|
||||
|
||||
+40
-254
@@ -3,15 +3,10 @@ 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;
|
||||
@@ -29,24 +24,11 @@ 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();
|
||||
|
||||
@@ -67,13 +49,15 @@ public class TimeQueueHandler implements Listener {
|
||||
* @param voteSiteName the vote site name
|
||||
*/
|
||||
public void addVote(String voteUsername, String voteSiteName) {
|
||||
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();
|
||||
}
|
||||
addVote(voteUsername, voteSiteName, 0L);
|
||||
}
|
||||
|
||||
/** Retain original ingress order only for this process; legacy callers have unknown order. */
|
||||
public void addVote(String voteUsername, String voteSiteName, long observationOrder) {
|
||||
VoteTimeQueue vote = new VoteTimeQueue(voteUsername, voteSiteName,
|
||||
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
|
||||
vote.setBackendObservationOrder(observationOrder);
|
||||
timeChangeQueue.add(vote);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,11 +83,17 @@ public class TimeQueueHandler implements Listener {
|
||||
}
|
||||
|
||||
private void scheduleQueueProcessing(long delay, TimeUnit unit) {
|
||||
synchronized (persistenceLock) { if (stopping) return; }
|
||||
boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), this::processQueue, delay, 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,14 +101,15 @@ 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);
|
||||
}, delaySeconds);
|
||||
}, delayTicks);
|
||||
} catch (RuntimeException rejected) {
|
||||
retryPending.set(false);
|
||||
plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain owned for recovery.");
|
||||
plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain persisted for recovery.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,239 +117,34 @@ public class TimeQueueHandler implements Listener {
|
||||
* Processes all votes in the queue.
|
||||
*/
|
||||
public void processQueue() {
|
||||
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 (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());
|
||||
voteEvent.setTimeQueuedLocalSessionDelivery(true);
|
||||
voteEvent.setBackendObservationOrder(vote.getBackendObservationOrder());
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
|
||||
if (voteEvent.isCancelled()) {
|
||||
plugin.debug("Vote cancelled");
|
||||
continue;
|
||||
}
|
||||
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 (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);
|
||||
if (!timeChangeQueue.isEmpty()) {
|
||||
int num = 0;
|
||||
for (VoteTimeQueue vote : timeChangeQueue) {
|
||||
plugin.getServerData().addTimeVoted(num, vote);
|
||||
num++;
|
||||
}
|
||||
}
|
||||
timeChangeQueue.clear();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ public class VoteTimeQueue {
|
||||
@Getter
|
||||
@Setter
|
||||
private long time;
|
||||
/** Process-local original ingress order; intentionally excluded from persisted cache rows. */
|
||||
@Getter
|
||||
@Setter
|
||||
private transient long backendObservationOrder;
|
||||
@Getter
|
||||
@Setter
|
||||
private UUID voteId;
|
||||
|
||||
-269
@@ -1,269 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+18
-28
@@ -113,12 +113,11 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] getTopVotersWeekly() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
var board = plugin.getTopVoterDisplay(TopVoter.Weekly);
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(board.keySet());
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(plugin.getTopVoter(TopVoter.Weekly).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%", "" + board.get(users.get(i)));
|
||||
.replace("%votes%", "" + plugin.getTopVoter(TopVoter.Weekly).get(users.get(i)));
|
||||
msg.add(line);
|
||||
}
|
||||
msg = ArrayUtils.colorize(msg);
|
||||
@@ -639,12 +638,11 @@ public class TopVoterHandler implements Listener {
|
||||
* @return the string[]
|
||||
*/
|
||||
public String[] topVoterAllTime(int page) {
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -664,8 +662,7 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterAllTime());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -679,12 +676,11 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterDaily(int page) {
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -704,8 +700,7 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterDaily());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -719,12 +714,11 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterMonthly(int page) {
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -744,8 +738,7 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterMonthly());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -759,7 +752,7 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersAllTime() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet());
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoter(TopVoter.AllTime).entrySet());
|
||||
int i = 0;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : list) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
@@ -785,8 +778,7 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersDaily() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
var board = plugin.getTopVoterDisplay(TopVoter.Daily);
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(board.keySet());
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(plugin.getTopVoter(TopVoter.Daily).keySet());
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
line = line.replace("%num%", "" + (i + 1));
|
||||
@@ -795,7 +787,7 @@ public class TopVoterHandler implements Listener {
|
||||
} catch (Exception ex) {
|
||||
VotingPluginMain.plugin.debug(ex);
|
||||
}
|
||||
line = line.replace("%votes%", "" + board.get(users.get(i)));
|
||||
line = line.replace("%votes%", "" + plugin.getTopVoter(TopVoter.Monthly).get(users.get(i)));
|
||||
msg.add(line);
|
||||
}
|
||||
|
||||
@@ -809,7 +801,7 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersMonthly() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet());
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoter(TopVoter.Monthly).entrySet());
|
||||
int i = 0;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : list) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
@@ -835,12 +827,11 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterWeekly(int page) {
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -860,8 +851,7 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterWeekly());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+4
-38
@@ -93,20 +93,10 @@ 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.getBukkitScheduler().runTaskAsynchronously(plugin,
|
||||
() -> tryInvalidateAndRefreshOnWorker(plugin, uuid, columns));
|
||||
plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuid), false).cacheAsync();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,37 +106,13 @@ 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) {
|
||||
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;
|
||||
invalidate(plugin, uuid, columns);
|
||||
if (plugin == null || uuid == null) return;
|
||||
try {
|
||||
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;
|
||||
plugin.getUserManager().getDataManager().cacheUser(UUID.fromString(uuid), null);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+74
-180
@@ -185,29 +185,16 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) {
|
||||
if (operationId == null || operationId.isEmpty()) return addCommittedToColumn(user, amount, pointsColumn);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
UUID uuid = UUID.fromString(user.getUUID());
|
||||
if (placeholders != null) placeholders.beginSharedPointDebit(uuid);
|
||||
boolean confirmed = false;
|
||||
drainCache(user, pointsColumn);
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
discardPointsCache(user, pointsColumn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,36 +209,18 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
AddResult removeCommittedFromColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) {
|
||||
if (operationId == null || operationId.isEmpty()) return new AddResult(false, 0);
|
||||
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;
|
||||
drainCache(user, pointsColumn);
|
||||
try {
|
||||
drainCache(user, pointsColumn);
|
||||
SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql())
|
||||
.subtract(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis());
|
||||
outcome = new AddResult(true, result.total());
|
||||
return new AddResult(true, result.total());
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof SharedPointAdditionJournal.DebitRejectedException) {
|
||||
outcome = new AddResult(MutationOutcome.REJECTED, 0);
|
||||
} else {
|
||||
logFailure(failure);
|
||||
outcome = new AddResult(MutationOutcome.INDETERMINATE, 0);
|
||||
}
|
||||
logFailure(failure);
|
||||
return new AddResult(failure instanceof SharedPointAdditionJournal.DebitRejectedException
|
||||
? MutationOutcome.REJECTED : MutationOutcome.INDETERMINATE, 0);
|
||||
} finally {
|
||||
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);
|
||||
}
|
||||
discardPointsCache(user, pointsColumn);
|
||||
}
|
||||
return new AddResult(outcome.outcome(), outcome.total(), refreshed);
|
||||
}
|
||||
|
||||
/** Looks up a completed retry before its Bukkit receive event is dispatched. */
|
||||
@@ -357,20 +326,7 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
boolean set(VotingPluginUser user, int value, boolean async) {
|
||||
String pointsColumn = user.getPointsPath();
|
||||
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;
|
||||
return run(() -> setAbsolute(user, value, pointsColumn), async);
|
||||
}
|
||||
|
||||
boolean setCommitted(VotingPluginUser user, int value) {
|
||||
@@ -542,40 +498,41 @@ 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(() -> {
|
||||
MySQL table = plugin.getMysql();
|
||||
String transferId = UUID.randomUUID().toString();
|
||||
String owner = UUID.randomUUID().toString();
|
||||
SharedPointTransferJournal prepared = null;
|
||||
boolean known = true;
|
||||
boolean reserved = false;
|
||||
PointTransferResult failed = PointTransferResult.UNAVAILABLE;
|
||||
try {
|
||||
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) {
|
||||
known |= failure instanceof SharedPointTransferJournal.PreparationFailure;
|
||||
logFailure(failure);
|
||||
} 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);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
return;
|
||||
}
|
||||
MySQL table = plugin.getMysql();
|
||||
String transferId = UUID.randomUUID().toString();
|
||||
String owner = UUID.randomUUID().toString();
|
||||
SharedPointTransferJournal journal;
|
||||
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);
|
||||
} 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);
|
||||
logFailure(failure);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
return;
|
||||
}
|
||||
SharedPointTransferJournal journal = prepared;
|
||||
|
||||
/*
|
||||
* Do not claim the reservation until the Bukkit approval task has actually
|
||||
@@ -605,7 +562,6 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -618,7 +574,8 @@ final class SharedMysqlPointMutator {
|
||||
System.currentTimeMillis());
|
||||
if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) {
|
||||
try {
|
||||
refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
discardPointsCache(source, sourcePoints);
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
}
|
||||
@@ -756,7 +713,10 @@ final class SharedMysqlPointMutator {
|
||||
} catch (SQLException markerFailure) {
|
||||
boolean refunded = false;
|
||||
try {
|
||||
refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
if (refunded) {
|
||||
discardPointsCache(source, sourcePoints);
|
||||
}
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
}
|
||||
@@ -776,24 +736,17 @@ 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 {
|
||||
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);
|
||||
discardPointsCache(source, sourcePoints);
|
||||
discardPointsCache(target, targetPoints);
|
||||
}
|
||||
result = outcome == SharedPointTransferJournal.SettlementOutcome.COMPLETED ? PointTransferResult.SUCCESS
|
||||
: outcome == SharedPointTransferJournal.SettlementOutcome.REFUNDED ? PointTransferResult.CANCELLED
|
||||
@@ -807,29 +760,14 @@ 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 {
|
||||
refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
if (journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount)) {
|
||||
discardPointsCache(source, sourcePoints);
|
||||
}
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
}
|
||||
@@ -845,23 +783,24 @@ 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 = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
} else {
|
||||
refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
refunded = journal.refundReserved(transferId, source.getUUID(), 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 = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
if (!refunded) {
|
||||
refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
}
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
}
|
||||
logFailure(markerFailure);
|
||||
}
|
||||
if (refunded) discardPointsCache(source, sourcePoints);
|
||||
if (!refunded) {
|
||||
rememberPendingCompensationMarker(plugin, transferId);
|
||||
logIndeterminateClaim(transferId);
|
||||
@@ -876,32 +815,19 @@ 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 {
|
||||
refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
journal.refundHookStarted(transferId, source.getUUID(), 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);
|
||||
@@ -1064,12 +990,9 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
enum MutationOutcome { CONFIRMED, REJECTED, INDETERMINATE }
|
||||
|
||||
record AddResult(MutationOutcome outcome, int total, boolean sourceRefreshed) {
|
||||
AddResult(MutationOutcome outcome, int total) {
|
||||
this(outcome, total, false);
|
||||
}
|
||||
record AddResult(MutationOutcome outcome, int total) {
|
||||
AddResult(boolean success, int total) {
|
||||
this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total, false);
|
||||
this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total);
|
||||
}
|
||||
boolean success() { return outcome == MutationOutcome.CONFIRMED; }
|
||||
}
|
||||
@@ -1079,29 +1002,21 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private boolean setAbsolute(VotingPluginUser user, int value, String points) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
} finally {
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
discardPointsCache(user, points);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,27 +1101,6 @@ 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());
|
||||
}
|
||||
|
||||
+72
-99
@@ -28,14 +28,6 @@ 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";
|
||||
/*
|
||||
@@ -152,59 +144,51 @@ final class SharedPointTransferJournal {
|
||||
*/
|
||||
boolean reserve(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints, String targetUuid,
|
||||
int requestedCreditPoints, long now) throws SQLException {
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
TransferRow existing = find(transferId);
|
||||
if (existing != null) {
|
||||
return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints)
|
||||
&& !REFUNDED.equals(existing.state);
|
||||
}
|
||||
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();
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,50 +286,42 @@ final class SharedPointTransferJournal {
|
||||
*/
|
||||
boolean refundReserved(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints)
|
||||
throws SQLException {
|
||||
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) {
|
||||
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 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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +340,6 @@ 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)) {
|
||||
@@ -388,7 +363,6 @@ 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;
|
||||
@@ -404,7 +378,6 @@ final class SharedPointTransferJournal {
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, REFUNDED);
|
||||
} catch (SQLException failure) {
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,18 +262,7 @@ 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);
|
||||
@@ -291,21 +280,7 @@ 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);
|
||||
|
||||
+2002
-2111
File diff suppressed because it is too large.
Load diff
-217
@@ -1,217 +0,0 @@
|
||||
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,12 +13,11 @@ public final class VoteTaskAdmission {
|
||||
/**
|
||||
* Attempts to submit a one-shot vote task without running it on the caller.
|
||||
*
|
||||
* @param executor bounded vote executor, or null before startup/after shutdown
|
||||
* @param executor bounded vote executor
|
||||
* @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;
|
||||
@@ -30,14 +29,13 @@ public final class VoteTaskAdmission {
|
||||
/**
|
||||
* Attempts to schedule a one-shot vote task without blocking the caller.
|
||||
*
|
||||
* @param executor bounded vote executor, or null before startup/after shutdown
|
||||
* @param executor bounded vote executor
|
||||
* @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;
|
||||
|
||||
+117
-149
@@ -29,14 +29,6 @@ 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";
|
||||
@@ -127,68 +119,60 @@ 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 {
|
||||
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();
|
||||
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);
|
||||
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;
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
throw failure;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,73 +370,65 @@ final class SharedMysqlPurchaseJournal {
|
||||
private RefundedPurchase setTerminalDetails(String purchaseId, String terminalState, long now,
|
||||
String... refundableStates)
|
||||
throws SQLException {
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
if (!commitAndConfirm(connection, purchaseId, terminalState)) return null;
|
||||
return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn)
|
||||
: new RefundedPurchase(null, null, null);
|
||||
} catch (SQLException failure) {
|
||||
throw failure;
|
||||
}
|
||||
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) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
@@ -466,28 +442,20 @@ final class SharedMysqlPurchaseJournal {
|
||||
|
||||
private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost,
|
||||
boolean refundLimit) throws SQLException {
|
||||
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;
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-71
@@ -186,24 +186,15 @@ 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;
|
||||
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);
|
||||
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()));
|
||||
}
|
||||
if (debit.result() != VoteShopPurchaseResult.SUCCESS) {
|
||||
completePurchaseResult(player, completion, debit.result());
|
||||
@@ -216,7 +207,6 @@ public class VoteShopPurchaseService {
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException persistenceRejected) {
|
||||
if (admission != null) admission.accept(false);
|
||||
plugin.debug(persistenceRejected);
|
||||
completeFailedPurchase(player, completion);
|
||||
}
|
||||
@@ -489,29 +479,20 @@ 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 {
|
||||
debit.journal().refundCompensatingReward(debit.purchaseId());
|
||||
known = true;
|
||||
if (debit.journal().refundCompensatingReward(debit.purchaseId())) {
|
||||
refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,50 +738,37 @@ public class VoteShopPurchaseService {
|
||||
|
||||
private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, VoteShopItem item,
|
||||
LimitGeneration limitGeneration) {
|
||||
var publication = plugin.getPlaceholders();
|
||||
UUID pointUuid = UUID.fromString(user.getUUID());
|
||||
if (publication != null) publication.beginSharedPointDebit(pointUuid);
|
||||
boolean known = true;
|
||||
try {
|
||||
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);
|
||||
}
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null);
|
||||
} finally {
|
||||
if (publication != null) publication.finishSharedPointDebit(pointUuid, known, false);
|
||||
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();
|
||||
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);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
/** The legacy cache path created server-suffixed points columns before direct SQL used them. */
|
||||
|
||||
@@ -451,14 +451,6 @@ 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:
|
||||
@@ -1273,3 +1265,9 @@ DisableUpdateChecking: false
|
||||
# Disable checking permissions on tab complete
|
||||
# Could increase performance slightly, but probably not
|
||||
DisableAdvancedTab: false
|
||||
|
||||
# Observation-only chat guide; normal /vote and reward processing remain unchanged.
|
||||
# Progress is local, expires after this interval, and resets on reload/restart.
|
||||
GuidedVotingSession:
|
||||
Enabled: false
|
||||
TimeoutMinutes: 30
|
||||
@@ -195,6 +195,9 @@ permissions:
|
||||
VotingPlugin.Login.RemindVotes.All:
|
||||
description: Remind if player can vote on all votesites, rather than any
|
||||
default: false
|
||||
VotingPlugin.Commands.Vote.Session:
|
||||
description: Start or navigate a guided voting session
|
||||
default: false
|
||||
|
||||
VotingPlugin.Commands.Vote.Streaks:
|
||||
description: View your read-only vote streak progression
|
||||
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
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
@@ -1,76 +0,0 @@
|
||||
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
@@ -1,382 +0,0 @@
|
||||
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
@@ -1,106 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
import com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache;
|
||||
import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync;
|
||||
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter;
|
||||
import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager;
|
||||
import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync;
|
||||
import com.bencodez.votingplugin.config.BungeeSettings;
|
||||
import com.bencodez.votingplugin.core.vote.SharedVotePolicy;
|
||||
import com.bencodez.votingplugin.core.vote.SharedVoteProcessor;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.proxy.BungeeMethod;
|
||||
import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginProxy;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler;
|
||||
import com.bencodez.votingplugin.proxy.multiproxy.MultiProxyHandler;
|
||||
import com.bencodez.votingplugin.user.UserManager;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import com.bencodez.votingplugin.votesites.VoteSiteManager;
|
||||
|
||||
class MultiProxyCooldownCompatibilityTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> reliableForwardingKeepsNormalCooldownTimeLocalToReceiver() {
|
||||
return Stream.of(100L, System.currentTimeMillis() + 86_400_000L)
|
||||
.map(senderTime -> DynamicTest.dynamicTest("sender time " + senderTime,
|
||||
() -> assertReceiverCooldownTime(senderTime)));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void sourceFenceRepliesOnlyToConfiguredOriginAndOriginRelaysOnlyItsOwnSession() throws Exception {
|
||||
VotingPluginProxyTestImpl proxy = spy(new VotingPluginProxyTestImpl());
|
||||
proxy.setDataFolder(temporaryDirectory.toFile());
|
||||
when(proxy.getConfig().getProxyServerName()).thenReturn("Receiver");
|
||||
var backendReplies = new java.util.ArrayList<JsonEnvelope>();
|
||||
proxy.setGlobalMessageProxyHandlerForTest(new GlobalMessageProxyHandler() {
|
||||
@Override public void sendMessage(String server, int delay, JsonEnvelope envelope) {
|
||||
assertEquals("server1", server); backendReplies.add(envelope);
|
||||
}
|
||||
});
|
||||
proxy.loadMultiProxySupport();
|
||||
MultiProxyHandler handler = spy(proxy.getMultiProxyHandler());
|
||||
doReturn(com.bencodez.votingplugin.proxy.multiproxy.MultiProxyMethod.REDIS).when(handler).getMultiProxyMethod();
|
||||
doReturn(java.util.List.of("Origin")).when(handler).getProxyServers();
|
||||
doReturn("Receiver").when(handler).getMultiProxyServerName();
|
||||
var outgoing = new java.util.ArrayList<JsonEnvelope>();
|
||||
org.mockito.Mockito.doAnswer(i -> {
|
||||
assertEquals(java.util.List.of("Origin"), i.getArgument(1));
|
||||
outgoing.add(i.getArgument(0)); return true;
|
||||
}).when(handler).sendMultiProxyEnvelopeAccepted(any(JsonEnvelope.class), any(java.util.Collection.class));
|
||||
Method receive = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class);
|
||||
receive.setAccessible(true);
|
||||
var request = new VotingPluginWire.SessionFenceRequest(UUID.randomUUID(), UUID.randomUUID(), "server1", "Origin");
|
||||
receive.invoke(handler, VotingPluginWire.sessionFenceRequest(request));
|
||||
assertEquals(1, outgoing.size());
|
||||
var reply = VotingPluginWire.readSessionFenceReply(outgoing.get(0)); assertNotNull(reply);
|
||||
assertEquals(request, reply.request());
|
||||
assertTrue(reply.fence().newer(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId()));
|
||||
receive.invoke(handler, VotingPluginWire.sessionFenceRequest(new VotingPluginWire.SessionFenceRequest(
|
||||
request.player(), request.nonce(), request.server(), "Unknown")));
|
||||
assertEquals(1, outgoing.size(), "unknown origin must not receive a fence");
|
||||
receive.invoke(handler, outgoing.get(0));
|
||||
assertEquals(0, backendReplies.size(), "reply for another proxy must not be relayed");
|
||||
var local = new VotingPluginWire.SessionFenceRequest(request.player(), request.nonce(), request.server(), "Receiver");
|
||||
receive.invoke(handler, VotingPluginWire.sessionFenceReply(local, reply.fence()));
|
||||
assertEquals(1, backendReplies.size());
|
||||
assertEquals(local, VotingPluginWire.readSessionFenceReply(backendReplies.get(0)).request());
|
||||
}
|
||||
|
||||
private void assertReceiverCooldownTime(long senderTime) throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
UUID voteId = UUID.randomUUID();
|
||||
VotingPluginProxyTestImpl proxy = spy(new VotingPluginProxyTestImpl());
|
||||
proxy.setDataFolder(temporaryDirectory.toFile());
|
||||
VoteCacheHandler voteCache = mock(VoteCacheHandler.class);
|
||||
when(voteCache.markMultiProxyVoteCompletedDurably(any())).thenReturn(true);
|
||||
when(voteCache.getTimeChangeQueue()).thenReturn(new ConcurrentLinkedQueue<>());
|
||||
doReturn(voteCache).when(proxy).getVoteCacheHandler();
|
||||
doNothing().when(proxy).addVoteParty();
|
||||
proxy.setMethod(BungeeMethod.PLUGINMESSAGING);
|
||||
proxy.setGlobalMessageProxyHandlerForTest(new GlobalMessageProxyHandler() {
|
||||
@Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { }
|
||||
});
|
||||
|
||||
// Install the production callback while transport startup is disabled. Only
|
||||
// the transport acknowledgement is stubbed; decoding and vote production run.
|
||||
proxy.loadMultiProxySupport();
|
||||
MultiProxyHandler handler = spy(proxy.getMultiProxyHandler());
|
||||
doNothing().when(handler).acknowledgeMultiProxyVote(any(), anyString());
|
||||
proxy.setMultiProxyHandler(handler);
|
||||
when(proxy.getConfig().getMultiProxySupport()).thenReturn(true);
|
||||
when(proxy.getConfig().getPrimaryServer()).thenReturn(false);
|
||||
when(proxy.getConfig().getOnlineMode()).thenReturn(true);
|
||||
when(proxy.getConfig().getBungeeManageTotals()).thenReturn(true);
|
||||
when(proxy.getConfig().getSendVotesToAllServers()).thenReturn(false);
|
||||
Field legacyServers = VotingPluginProxy.class.getDeclaredField("legacyVoteDeliveryServers");
|
||||
legacyServers.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Set<String> legacy = (java.util.Set<String>) legacyServers.get(proxy);
|
||||
legacy.add("server1");
|
||||
legacy.add("server2");
|
||||
|
||||
Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class);
|
||||
handleEnvelope.setAccessible(true);
|
||||
long beforeReceive = System.currentTimeMillis();
|
||||
handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVote("Player", playerId.toString(),
|
||||
"Service", senderTime, true, true, "", voteId, false, false, 1, 1, "Primary", true));
|
||||
long afterReceive = System.currentTimeMillis();
|
||||
|
||||
JsonEnvelope emitted = proxy.getLastVoteEnvelope();
|
||||
assertNotNull(emitted, "the actual receiver must produce a backend vote");
|
||||
VotingPluginWire.Vote forwarded = VotingPluginWire.readVote(emitted);
|
||||
assertTrue(forwarded.time >= beforeReceive && forwarded.time <= afterReceive,
|
||||
"ordinary backend LastVotes time must use the receiver clock");
|
||||
assertEquals(voteId, forwarded.voteId);
|
||||
assertEquals(playerId.toString(), forwarded.uuid);
|
||||
assertTrue(forwarded.delayValidationKnown);
|
||||
assertTrue(forwarded.delayValidated);
|
||||
assertTrue(forwarded.queuedDeliveryKnown);
|
||||
|
||||
// Exercise the real backend router and event adapter. No guide manager is
|
||||
// installed: provenance must not change ordinary vote/cooldown accounting.
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
UserManager users = mock(UserManager.class);
|
||||
VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
Field userPlugin = VotingPluginUser.class.getDeclaredField("plugin");
|
||||
userPlugin.setAccessible(true);
|
||||
userPlugin.set(user, plugin);
|
||||
when(user.getPlayerName()).thenReturn("Player");
|
||||
doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(anyString(), anyLong(),
|
||||
any(VoteTotalsSnapshot.class), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(),
|
||||
anyBoolean(), anyBoolean(), anyBoolean(), any(UUID.class), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
BungeeSettings settings = mock(BungeeSettings.class);
|
||||
when(settings.isUseBungeecoord()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings()).thenReturn(settings);
|
||||
when(plugin.getVotingPluginUserManager()).thenReturn(users);
|
||||
when(users.getVotingPluginUser(playerId, "Player")).thenReturn(user);
|
||||
ServerData serverData = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(serverData);
|
||||
VoteSiteManager sites = mock(VoteSiteManager.class);
|
||||
VoteSite site = mock(VoteSite.class);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);
|
||||
when(sites.getVoteSite("Service", true)).thenReturn(site);
|
||||
Server server = mock(Server.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
ProcessedVoteCache processed = mock(ProcessedVoteCache.class);
|
||||
when(processed.reserveWithOutcome(voteId)).thenReturn(ProcessedVoteCache.Reservation.RESERVED);
|
||||
when(processed.complete(voteId)).thenReturn(true);
|
||||
BackendProxyMessageRouter router = new BackendProxyMessageRouter(plugin,
|
||||
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
|
||||
mock(BackendVotePartySync.class), processed);
|
||||
// Check completion afterwards so a fixture error retains its original
|
||||
// exception instead of being replaced by the quarantine callback assertion.
|
||||
AtomicReference<BackendProxyMessageRouter.OrderedVoteOutcome> outcome = new AtomicReference<>();
|
||||
router.handleOrderedVote(emitted, outcome::set);
|
||||
assertEquals(BackendProxyMessageRouter.OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
ArgumentCaptor<PlayerVoteEvent> event = ArgumentCaptor.forClass(PlayerVoteEvent.class);
|
||||
verify(events).callEvent(event.capture());
|
||||
assertEquals(forwarded.time, event.getValue().getTime());
|
||||
assertEquals(voteId, event.getValue().getProxyVoteId());
|
||||
assertEquals(!forwarded.manageTotals, event.getValue().isAddTotals());
|
||||
assertTrue(event.getValue().isUnconfirmedProxySessionDelivery());
|
||||
assertTrue(event.getValue().isProxyDelayValidationKnown());
|
||||
assertTrue(event.getValue().isProxyQueueClassificationKnown());
|
||||
assertEquals(forwarded.queuedDelivery, event.getValue().isQueuedProxyVote());
|
||||
|
||||
assertNormalAccountingTime(event.getValue(), site, user);
|
||||
}
|
||||
|
||||
private void assertNormalAccountingTime(PlayerVoteEvent event, VoteSite site, VotingPluginUser user) {
|
||||
@SuppressWarnings("unchecked")
|
||||
SharedVoteProcessor.Operations<VoteSite, VotingPluginUser> operations = mock(SharedVoteProcessor.Operations.class);
|
||||
when(operations.enabled()).thenReturn(true);
|
||||
when(operations.incomingName()).thenReturn("Player");
|
||||
when(operations.properName("Player")).thenReturn("Player");
|
||||
when(operations.validate("Player", false)).thenReturn(
|
||||
new SharedVoteProcessor.Validation(true, "Player", "CACHE", "ok", false));
|
||||
when(operations.resolveSite()).thenReturn(site);
|
||||
when(operations.siteEnabled(site)).thenReturn(true);
|
||||
when(operations.resolveUser("Player")).thenReturn(user);
|
||||
when(operations.proxyVote()).thenReturn(event.isBungee());
|
||||
when(operations.forceProxyRouting()).thenReturn(event.isForceBungee());
|
||||
when(operations.wasOnline()).thenReturn(event.isWasOnline());
|
||||
when(operations.realVote()).thenReturn(event.isRealVote());
|
||||
when(operations.addTotals()).thenReturn(event.isAddTotals());
|
||||
when(operations.proxyVoteId()).thenReturn(event.getProxyVoteId());
|
||||
when(operations.proxyQueueClassificationKnown()).thenReturn(event.isProxyQueueClassificationKnown());
|
||||
when(operations.proxyDelayValidationKnown()).thenReturn(event.isProxyDelayValidationKnown());
|
||||
when(operations.incomingTime()).thenReturn(event.getTime());
|
||||
when(operations.countingPolicy()).thenReturn(new SharedVotePolicy(false, true, false, false, false));
|
||||
SharedVoteProcessor.process(operations);
|
||||
verify(operations).setTime(user, site, event.getTime());
|
||||
verify(operations).cooldown(user, site);
|
||||
}
|
||||
}
|
||||
-220
@@ -1,220 +0,0 @@
|
||||
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
@@ -1,427 +0,0 @@
|
||||
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
@@ -1,492 +0,0 @@
|
||||
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
@@ -1,249 +0,0 @@
|
||||
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
@@ -1,368 +0,0 @@
|
||||
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()).setInt("VoteParty.Total", 6);
|
||||
verify(plugin.getServerData().getData()).set("VoteParty.Total", 6);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,8 +114,6 @@ 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
@@ -1,603 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -857,7 +857,7 @@ class BackendProxyMessageRouterTest {
|
||||
var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(user, plugin);
|
||||
org.mockito.Mockito.doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(
|
||||
any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(),
|
||||
anyBoolean(), anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
var manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(manager);
|
||||
@@ -897,7 +897,7 @@ class BackendProxyMessageRouterTest {
|
||||
.thenReturn(user);
|
||||
doThrow(new IllegalStateException("partial reward")).when(user).bungeeVotePluginMessagingEvent(
|
||||
any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), any(), anyBoolean(), eq(true), anyBoolean());
|
||||
BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
|
||||
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
|
||||
mock(BackendVotePartySync.class), cache);
|
||||
@@ -943,7 +943,7 @@ class BackendProxyMessageRouterTest {
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
verify(cache, times(2)).complete(voteId);
|
||||
verify(user, times(1)).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false));
|
||||
anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false), eq(true), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -964,7 +964,7 @@ class BackendProxyMessageRouterTest {
|
||||
LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), ignored -> { });
|
||||
|
||||
verify(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), eq(false), eq(false), eq(true), eq(voteId), eq(false));
|
||||
anyBoolean(), anyInt(), eq(false), eq(false), eq(true), eq(voteId), eq(false), eq(true), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -984,7 +984,7 @@ class BackendProxyMessageRouterTest {
|
||||
LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), ignored -> { });
|
||||
|
||||
verify(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true));
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true), eq(true), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1006,7 +1006,7 @@ class BackendProxyMessageRouterTest {
|
||||
assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get());
|
||||
verify(cache, never()).complete(voteId);
|
||||
verify(user, never()).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), eq(true), anyBoolean());
|
||||
verify(cache).cancelReservation(voteId);
|
||||
}
|
||||
|
||||
@@ -1026,7 +1026,7 @@ class BackendProxyMessageRouterTest {
|
||||
|
||||
assertEquals(OrderedVoteOutcome.RETRY, outcome.get());
|
||||
verify(user, never()).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), eq(true), anyBoolean());
|
||||
verify(cache, never()).complete(voteId);
|
||||
}
|
||||
|
||||
@@ -1249,7 +1249,7 @@ class BackendProxyMessageRouterTest {
|
||||
voteRouter.handleOrderedVote(queued, ignored -> { });
|
||||
|
||||
verify(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true));
|
||||
anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true), eq(true), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -266,7 +266,7 @@ class BackendProxyVoteDelayReplayTest {
|
||||
pluginField.set(user, plugin);
|
||||
doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(VoteTotalsSnapshot.class),
|
||||
anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), any(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
PlayerVoteListener listener = new PlayerVoteListener(plugin);
|
||||
var eventManager = plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> {
|
||||
|
||||
+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.PlatformScheduler implementation =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
com.bencodez.simpleapi.folialib.impl.ServerImplementation implementation =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
|
||||
+2
-46
@@ -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.PlatformScheduler;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.user.PointTransferResult;
|
||||
@@ -209,53 +209,9 @@ 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);
|
||||
PlatformScheduler entityScheduler = mock(PlatformScheduler.class);
|
||||
ServerImplementation entityScheduler = mock(ServerImplementation.class);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
when(folia.getImpl()).thenReturn(entityScheduler);
|
||||
when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class)))
|
||||
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
package com.bencodez.votingplugin.tests.commands;
|
||||
|
||||
import com.bencodez.votingplugin.commands.CommandLoader;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
|
||||
class GuidedSessionCommandRegistrationTest {
|
||||
@Test void disabledGuidePreservesTheExistingCustomSessionHandler() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.register();
|
||||
assertEquals(1, f.sessionHandlers().size());
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
assertFalse(f.plugin.getVoteCommand().stream().anyMatch(h -> h.getArgs().length == 2
|
||||
&& h.getArgs()[0].equalsIgnoreCase("Session")));
|
||||
}
|
||||
|
||||
@Test void enabledGuideReservesSessionAndAllNavigationHandlers() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
assertEquals(8, f.sessionHandlers().size()); // seven built-ins plus the owner custom command
|
||||
assertEquals("Guided voting session", f.sessionHandlers().get(0).getHelpMessage());
|
||||
for (String action : List.of("next", "previous", "skip", "check", "finish", "restart")) {
|
||||
assertTrue(f.sessionHandlers().stream().anyMatch(h -> h.getArgs().length == 2
|
||||
&& h.getArgs()[1].equals(action)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disablingAndReloadingRestoresCustomRoutingWithoutDuplicateHandlers() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertEquals(1, f.sessionHandlers().size());
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
}
|
||||
|
||||
@Test void enablingOnReloadAddsOnlyGuideHandlersAndPreservesProductionIdentity() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.register();
|
||||
var production = new ArrayList<>(f.plugin.getVoteCommand());
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertEquals(production.size() + 7, f.plugin.getVoteCommand().size());
|
||||
for (CommandHandler original : production) assertTrue(f.plugin.getVoteCommand().contains(original));
|
||||
assertEquals("Guided voting session", f.sessionHandlers().get(0).getHelpMessage());
|
||||
}
|
||||
|
||||
@Test void reloadHonorsDisabledGuidePermissionWithoutRemovingCustomHandler() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
when(f.config.getDisabledCommands()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertEquals(1, f.sessionHandlers().size());
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
}
|
||||
|
||||
@Test void permissionLookupTracksEnableDisableAndRetainsUnrelatedCommands() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
org.bukkit.entity.Player owner = mock(org.bukkit.entity.Player.class);
|
||||
when(owner.hasPermission("owner.session")).thenReturn(true);
|
||||
org.bukkit.entity.Player voter = mock(org.bukkit.entity.Player.class);
|
||||
when(voter.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
var index = new java.util.HashMap<String, CommandHandler>();
|
||||
CommandHandler custom = f.sessionHandlers().get(0);
|
||||
CommandHandler unrelated = f.plugin.getVoteCommand().get(0);
|
||||
index.put("voteSession", custom);
|
||||
index.put("voteHelp", unrelated);
|
||||
f.loader.setCommands(index);
|
||||
assertTrue(f.loader.hasPermission(owner, "voteSession"));
|
||||
assertFalse(f.loader.hasPermission(voter, "voteSession"));
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.hasPermission(owner, "voteSession"));
|
||||
assertTrue(f.loader.hasPermission(voter, "votingplugin:voteSession"));
|
||||
assertSame(unrelated, index.get("voteHelp"));
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertSame(custom, index.get("voteSession"));
|
||||
assertTrue(f.loader.hasPermission(owner, "voteSession"));
|
||||
assertFalse(f.loader.hasPermission(voter, "voteSession"));
|
||||
}
|
||||
|
||||
@Test void initialAliasIndexUsesGuideRatherThanCustomOrNavigationHandler() throws Exception {
|
||||
assertInitialAliasIndexUsesGuide("vote");
|
||||
}
|
||||
|
||||
@Test void renamedPlayerRootKeepsTheGuidedSessionPermissionIndex() throws Exception {
|
||||
assertInitialAliasIndexUsesGuide("ballot");
|
||||
}
|
||||
|
||||
public interface CommandMapAccess { org.bukkit.command.CommandMap getCommandMap(); }
|
||||
|
||||
private void assertInitialAliasIndexUsesGuide(String commandName) throws Exception {
|
||||
Fixture f = new Fixture(commandName);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
when(f.plugin.getAdminVoteCommand()).thenReturn(new ArrayList<>());
|
||||
when(f.plugin.getDescription().getCommands()).thenReturn(java.util.Map.of());
|
||||
when(f.plugin.getDescription().getName()).thenReturn("VotingPlugin");
|
||||
when(f.plugin.getName()).thenReturn("VotingPlugin");
|
||||
org.bukkit.Server server = mock(org.bukkit.Server.class, withSettings().extraInterfaces(CommandMapAccess.class));
|
||||
var map = new org.bukkit.command.SimpleCommandMap(server);
|
||||
when(((CommandMapAccess) server).getCommandMap()).thenReturn(map);
|
||||
when(f.plugin.getServer()).thenReturn(server);
|
||||
var constructor = org.bukkit.command.PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
|
||||
constructor.setAccessible(true);
|
||||
var root = constructor.newInstance("vote", f.plugin);
|
||||
map.register("votingplugin", root);
|
||||
when(f.plugin.getCommand("vote")).thenReturn(root);
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
f.loader.loadAliases();
|
||||
}
|
||||
assertSame(root, map.getCommand(commandName));
|
||||
assertTrue(f.loader.isVoteCommandEnabled());
|
||||
CommandHandler indexed = f.loader.getCommands().get("voteSession");
|
||||
assertEquals("Guided voting session", indexed.getHelpMessage());
|
||||
assertArrayEquals(new String[] {"Session"}, indexed.getArgs());
|
||||
org.bukkit.entity.Player voter = mock(org.bukkit.entity.Player.class);
|
||||
when(voter.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.hasPermission(voter, "voteSession"));
|
||||
}
|
||||
|
||||
@Test void enablingOnReloadHonorsDisabledDefaultPermission() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
when(f.plugin.getOptions().isMultiplePermissionChecks()).thenReturn(true);
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.setCommands(new java.util.HashMap<>());
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
assertFalse(f.loader.hasPermission(player, "voteSession"));
|
||||
assertTrue(f.sessionHandlers().stream().filter(h -> h.getHelpMessage().equals("Guided voting session"))
|
||||
.allMatch(h -> h.getPerm().equals("VotingPlugin.Commands.Vote.Session")));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.hasPermission(player, "voteSession"));
|
||||
}
|
||||
|
||||
@Test void guidedSessionUseRequiresTheRegisteredRootHandlerPolicy() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
when(f.plugin.getOptions().isMultiplePermissionChecks()).thenReturn(true);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
assertTrue(f.loader.canUseGuidedSession(player));
|
||||
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.canUseGuidedSession(player));
|
||||
|
||||
when(f.config.getDisabledCommands()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
}
|
||||
|
||||
@Test void guidedSessionUseRejectsMultiplePermissionFallbackAndCustomOnlyOrDisabledGuide() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
when(f.plugin.getOptions().isMultiplePermissionChecks()).thenReturn(false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.canUseGuidedSession(player));
|
||||
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
when(f.config.getDisabledCommands()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
}
|
||||
|
||||
@Test void reloadReconcilesOnlyGuidePermissionInheritanceAndInvalidatesStaleTasks() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
java.util.List<Runnable> tasks = new ArrayList<>();
|
||||
var scheduler = f.plugin.getBukkitScheduler();
|
||||
doAnswer(call -> { tasks.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(f.plugin), any(Runnable.class));
|
||||
var manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(manager);
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
var parent = new org.bukkit.permissions.Permission("VotingPlugin.Player");
|
||||
parent.getChildren().put("owner.unrelated", true);
|
||||
when(manager.getPermission("VotingPlugin.Player")).thenReturn(parent);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertEquals(true, parent.getChildren().get("VotingPlugin.Commands.Vote.Session"));
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertFalse(parent.getChildren().containsKey("VotingPlugin.Commands.Vote.Session"));
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>());
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertEquals(true, parent.getChildren().get("VotingPlugin.Commands.Vote.Session"));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
Runnable staleEnable = tasks.remove(0);
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
Runnable staleDisable = tasks.remove(0);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
staleEnable.run();
|
||||
staleDisable.run();
|
||||
assertFalse(parent.getChildren().containsKey("VotingPlugin.Commands.Vote.Session"));
|
||||
assertEquals(true, parent.getChildren().get("owner.unrelated"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disablingGuideRetainsCustomSessionInheritanceUnlessOwnerDisablesDefaultPermission() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("CustomCommands.owner-session.Permission", "VotingPlugin.Commands.Vote.Session|VotingPlugin.Player");
|
||||
f.register();
|
||||
java.util.List<Runnable> tasks = new ArrayList<>();
|
||||
var scheduler = f.plugin.getBukkitScheduler();
|
||||
doAnswer(call -> { tasks.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(f.plugin), any(Runnable.class));
|
||||
var manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(manager);
|
||||
var parent = new org.bukkit.permissions.Permission("VotingPlugin.Player");
|
||||
when(manager.getPermission("VotingPlugin.Player")).thenReturn(parent);
|
||||
parent.getChildren().put("VotingPlugin.Commands.Vote.Session", true);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertEquals(true, parent.getChildren().get("VotingPlugin.Commands.Vote.Session"));
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertFalse(parent.getChildren().containsKey("VotingPlugin.Commands.Vote.Session"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final Config config = mock(Config.class);
|
||||
final CommandLoader loader;
|
||||
final YamlConfiguration data = new YamlConfiguration();
|
||||
final AtomicReference<ArrayList<CommandHandler>> handlers = new AtomicReference<>();
|
||||
Fixture() { this("vote"); }
|
||||
Fixture(String commandName) {
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isVoteCommandEnabled()).thenReturn(true);
|
||||
when(config.getVoteCommandName()).thenReturn(commandName);
|
||||
loader = new CommandLoader(plugin);
|
||||
when(config.getData()).thenReturn(data);
|
||||
when(config.getDisabledCommands()).thenReturn(new ArrayList<>());
|
||||
when(config.isAddCustomCommands()).thenReturn(true);
|
||||
when(config.getCustomCommands()).thenReturn(Set.of("owner-session"));
|
||||
var custom = data.createSection("CustomCommands.owner-session");
|
||||
custom.set("Args", new ArrayList<>(List.of("Session")));
|
||||
custom.set("HelpMessage", "existing owner command");
|
||||
custom.set("Permission", "owner.session");
|
||||
when(config.getCustomCommands("owner-session")).thenReturn(custom);
|
||||
doAnswer(call -> { handlers.set(call.getArgument(0)); return null; })
|
||||
.when(plugin).setVoteCommand(any(ArrayList.class));
|
||||
when(plugin.getVoteCommand()).thenAnswer(call -> handlers.get());
|
||||
}
|
||||
void register() throws Exception {
|
||||
Method load = CommandLoader.class.getDeclaredMethod("loadVoteCommand");
|
||||
load.setAccessible(true);
|
||||
try (var singleton = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) {
|
||||
var core = mock(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
singleton.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(core);
|
||||
when(core.getBasicCommands("VotingPlugin")).thenReturn(new ArrayList<>());
|
||||
load.invoke(loader);
|
||||
}
|
||||
}
|
||||
List<CommandHandler> sessionHandlers() {
|
||||
return plugin.getVoteCommand().stream().filter(h -> h.getArgs().length > 0
|
||||
&& h.getArgs()[0].equalsIgnoreCase("Session")).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
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
@@ -1,169 +0,0 @@
|
||||
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
@@ -1,83 +0,0 @@
|
||||
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
@@ -1,54 +0,0 @@
|
||||
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
@@ -1,72 +0,0 @@
|
||||
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
@@ -1,94 +0,0 @@
|
||||
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
@@ -1,280 +0,0 @@
|
||||
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
@@ -1,156 +0,0 @@
|
||||
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
@@ -1,70 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package com.bencodez.votingplugin.tests.core.session;
|
||||
|
||||
import com.bencodez.votingplugin.core.session.GuidedVoteSession;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GuidedVoteSessionTest {
|
||||
@Test void guideCreationAndIngressShareAStrictSequenceWithoutDependingOnClockResolution() {
|
||||
var old = new com.bencodez.votingplugin.events.PlayerVoteEvent(null, "Alex", "a", true);
|
||||
var session = new GuidedVoteSession(100); session.refresh(List.of(site("a", true)));
|
||||
session.acceptedObserved("a", UUID.randomUUID(), old.getBackendObservationOrder()); assertEquals(0, session.view("check").received());
|
||||
var fresh = new com.bencodez.votingplugin.events.PlayerVoteEvent(null, "Alex", "a", true);
|
||||
assertTrue(fresh.getBackendObservationOrder() > old.getBackendObservationOrder());
|
||||
session.acceptedObserved("a", UUID.randomUUID(), fresh.getBackendObservationOrder()); assertEquals(1, session.view("check").received());
|
||||
}
|
||||
@Test void concurrentIngressOrdersAreUniqueAndStrictWithinEachProducer() throws Exception {
|
||||
var orders = java.util.concurrent.ConcurrentHashMap.<Long>newKeySet();
|
||||
try (var pool = java.util.concurrent.Executors.newFixedThreadPool(8)) {
|
||||
var futures = new java.util.ArrayList<java.util.concurrent.Future<?>>();
|
||||
for (int thread = 0; thread < 8; thread++) futures.add(pool.submit(() -> {
|
||||
long previous = 0;
|
||||
for (int i = 0; i < 128; i++) {
|
||||
long order = new com.bencodez.votingplugin.events.PlayerVoteEvent(null, "Alex", "a", true).getBackendObservationOrder();
|
||||
assertTrue(order > previous); assertTrue(orders.add(order)); previous = order;
|
||||
}
|
||||
}));
|
||||
for (var future : futures) future.get();
|
||||
}
|
||||
assertEquals(1024, orders.size());
|
||||
}
|
||||
private GuidedVoteSession.Site site(String key, boolean eligible) {
|
||||
return new GuidedVoteSession.Site(key, key, "https://example.org/" + key, eligible, 0);
|
||||
}
|
||||
@Test void onlyRealNewMatchingAcceptedVotesConfirmSites() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true)));
|
||||
UUID id = UUID.randomUUID();
|
||||
s.accepted("unrelated", id, 101, true, false);
|
||||
s.accepted("a", id, 99, true, false);
|
||||
s.accepted("a", id, 101, false, false);
|
||||
s.accepted("a", id, 101, true, true);
|
||||
assertEquals(0, s.view("check").received());
|
||||
s.accepted("a", id, 101, true, false);
|
||||
s.accepted("a", id, 101, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 100, true, false);
|
||||
assertEquals(1, s.view("check").received());
|
||||
assertFalse(s.view("check").complete());
|
||||
}
|
||||
@Test void delayedNewVoteAndOutOfOrderDeliveryDoNotRegressProgress() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true)));
|
||||
s.accepted("a", UUID.randomUUID(), 200, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 150, true, false);
|
||||
assertTrue(s.view("check").complete());
|
||||
}
|
||||
@Test void permissionCooldownChangesAndReopeningRetainObservedProgress() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true), site("cooldown", false)));
|
||||
s.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
s.refresh(List.of(site("a", false)));
|
||||
var v = s.view("check");
|
||||
assertEquals(GuidedVoteSession.Status.RECEIVED, v.entries().get(0).status());
|
||||
assertEquals(GuidedVoteSession.Status.UNAVAILABLE, v.entries().get(1).status());
|
||||
assertEquals(2, v.entries().size());
|
||||
assertEquals(1, s.view("check").received());
|
||||
}
|
||||
@Test void notificationBeforeInitialSnapshotIsRetained() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 101)));
|
||||
assertTrue(s.view("check").complete());
|
||||
}
|
||||
@Test void preSessionIngressProcessedLaterCannotAdmitAnInitiallyCoolingSite() {
|
||||
for (long order : new long[] {99, 100}) {
|
||||
var s = new GuidedVoteSession(100, 100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.acceptedObserved("a", UUID.randomUUID(), order);
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 101)));
|
||||
assertTrue(s.view("check").entries().isEmpty(), "processing time cannot replace ingress order " + order);
|
||||
assertEquals(0, s.view("check").received());
|
||||
assertFalse(s.view("check").complete());
|
||||
}
|
||||
}
|
||||
@Test void freshObservedReceiptBeforeInitialCooldownSampleAdmitsItsBoundSite() {
|
||||
var s = new GuidedVoteSession(100, 100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.acceptedObserved("a", UUID.randomUUID(), 101);
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 100)));
|
||||
assertEquals(1, s.view("check").entries().size());
|
||||
assertTrue(s.view("check").complete());
|
||||
}
|
||||
@Test void olderCachedVoteCannotEraseAnEarlyConfirmedReceipt() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.bindCandidates(List.of(site("a", true)));
|
||||
s.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 99, true, false);
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 99)));
|
||||
assertTrue(s.view("check").complete());
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 99)));
|
||||
assertEquals(1, s.view("check").received());
|
||||
}
|
||||
@Test void absentFirstSampleCannotDeleteConfirmedProgressOrOccurrenceIdentity() {
|
||||
var s = new GuidedVoteSession(100); UUID occurrence = UUID.randomUUID();
|
||||
s.bindCandidates(List.of(site("a", true), site("b", true)));
|
||||
s.accepted("a", occurrence, 101, true, false);
|
||||
s.refresh(List.of(site("b", true)));
|
||||
assertEquals(1, s.view("check").received());
|
||||
s.accepted("b", occurrence, 102, true, false);
|
||||
s.refresh(List.of(site("a", false), site("b", true)));
|
||||
assertEquals(1, s.view("check").received());
|
||||
assertEquals(GuidedVoteSession.Status.RECEIVED, s.view("check").entries().stream()
|
||||
.filter(e -> e.site().key().equals("a")).findFirst().orElseThrow().status());
|
||||
}
|
||||
@Test void receiptAfterFirstIneligibleSampleRestoresBoundCandidate() {
|
||||
var s = new GuidedVoteSession(100, 100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 101)));
|
||||
assertTrue(s.view("check").entries().isEmpty());
|
||||
for (int i = 0; i < 101; i++) s.acceptedObserved("unrelated" + i, UUID.randomUUID(), 101);
|
||||
s.acceptedObserved("a", UUID.randomUUID(), 101);
|
||||
assertTrue(s.view("check").complete());
|
||||
assertEquals(1, s.view("check").entries().size());
|
||||
}
|
||||
@Test void originalTimestampReceiptAfterFirstIneligibleSampleRestoresOnlyItsBoundCandidate() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.bindCandidates(List.of(site("a", false), site("b", false)));
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 101), site("b", false)));
|
||||
assertTrue(s.view("check").entries().isEmpty());
|
||||
UUID occurrence = UUID.randomUUID();
|
||||
s.accepted("a", occurrence, 99, true, false);
|
||||
s.accepted("a", occurrence, 100, true, false);
|
||||
s.accepted("a", null, 101, true, false);
|
||||
s.accepted("a", occurrence, 101, false, false);
|
||||
s.accepted("a", occurrence, 101, true, true);
|
||||
s.accepted("unrelated", occurrence, 101, true, false);
|
||||
assertTrue(s.view("check").entries().isEmpty());
|
||||
s.accepted("a", occurrence, 101, true, false);
|
||||
s.accepted("a", occurrence, 101, true, false);
|
||||
s.accepted("b", occurrence, 102, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 99, true, false);
|
||||
assertTrue(s.view("check").complete());
|
||||
assertEquals(1, s.view("check").entries().size());
|
||||
assertEquals("a", s.view("check").current().site().key());
|
||||
}
|
||||
@Test void initiallyCoolingDownSiteCannotJoinFromPreSessionTimestampReceipts() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 99)));
|
||||
s.accepted("a", UUID.randomUUID(), 99, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 100, true, false);
|
||||
assertTrue(s.view("check").entries().isEmpty());
|
||||
}
|
||||
@Test void oneCanonicalOccurrenceCannotConfirmTwoSites() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true)));
|
||||
UUID occurrence = UUID.randomUUID();
|
||||
s.accepted("a", occurrence, 101, true, false);
|
||||
s.accepted("b", occurrence, 102, true, false);
|
||||
assertEquals(1, s.view("check").received());
|
||||
}
|
||||
@Test void emptyAndFinishedSessionsDoNotInventCompletion() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", false)));
|
||||
assertNull(s.view("check").current());
|
||||
assertFalse(s.view("finish").complete());
|
||||
assertTrue(s.view("check").finished());
|
||||
}
|
||||
@Test void navigationAndSkipDoNotCountVotes() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true)));
|
||||
assertEquals("b", s.view("next").current().site().key());
|
||||
assertEquals("a", s.view("previous").current().site().key());
|
||||
assertEquals("b", s.view("skip").current().site().key());
|
||||
assertEquals(GuidedVoteSession.Status.SKIPPED, s.view("check").entries().get(0).status());
|
||||
assertEquals(0, s.view("finish").received());
|
||||
}
|
||||
@Test void automaticSelectionStartsAtFirstEligibleSiteAndAdvancesReceivedCurrent() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true), site("c", true)));
|
||||
assertEquals("a", s.view("check").current().site().key());
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
s.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
assertTrue(s.selectNextAwaiting());
|
||||
assertEquals("b", s.view("check").current().site().key());
|
||||
}
|
||||
@Test void unrelatedReceiptDoesNotMoveCurrentAndEligibilityChangesSkipBlockedSites() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true), site("c", true)));
|
||||
s.accepted("c", UUID.randomUUID(), 101, true, false);
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
assertEquals("a", s.view("check").current().site().key());
|
||||
s.accepted("a", UUID.randomUUID(), 102, true, false);
|
||||
assertTrue(s.selectNextAwaiting());
|
||||
assertEquals("b", s.view("check").current().site().key());
|
||||
s.refresh(List.of(site("a", false), site("b", true), site("c", true)));
|
||||
s.view("previous");
|
||||
assertTrue(s.selectNextAwaiting());
|
||||
assertEquals("b", s.view("check").current().site().key());
|
||||
}
|
||||
@Test void skippedReceivedUnavailableAndDuplicateDeliveryCannotStallOrRepeatAutoStep() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true), site("c", true)));
|
||||
assertEquals("b", s.view("skip").current().site().key());
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
s.accepted("b", UUID.randomUUID(), 101, true, false);
|
||||
assertTrue(s.selectNextAwaiting());
|
||||
assertEquals("c", s.view("check").current().site().key());
|
||||
s.accepted("c", UUID.randomUUID(), 102, true, false);
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
assertEquals("c", s.view("check").current().site().key());
|
||||
UUID duplicate = UUID.randomUUID();
|
||||
s.accepted("c", duplicate, 103, true, false);
|
||||
s.accepted("c", duplicate, 104, true, false);
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
}
|
||||
@Test void automaticSelectionHandlesEmptyAndAllDoneSessions() {
|
||||
var empty = new GuidedVoteSession(100);
|
||||
empty.refresh(List.of(site("a", false)));
|
||||
assertFalse(empty.selectNextAwaiting());
|
||||
var done = new GuidedVoteSession(100);
|
||||
done.refresh(List.of(site("a", true)));
|
||||
done.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
assertFalse(done.selectNextAwaiting());
|
||||
assertTrue(done.view("check").complete());
|
||||
var finished = new GuidedVoteSession(100);
|
||||
finished.refresh(List.of(site("a", true), site("b", true)));
|
||||
finished.view("finish");
|
||||
assertFalse(finished.selectNextAwaiting());
|
||||
}
|
||||
@Test void urlValidationUsesExactSelectedUrlWithoutUnsafeSchemes() {
|
||||
assertEquals("https://example.org/a", GuidedVoteSession.httpUrl("https://example.org/a"));
|
||||
String authenticatedUrl = "https://" + UUID.randomUUID() + ":" + UUID.randomUUID() + "@example.org";
|
||||
for (String bad : List.of("", "javascript:alert(1)", "https://", authenticatedUrl, "not a url"))
|
||||
assertNull(GuidedVoteSession.httpUrl(bad));
|
||||
assertNull(GuidedVoteSession.httpUrl(null));
|
||||
assertEquals("https://example.org/vote", GuidedVoteSession.httpUrl("[Text=\"Vote here\",url=\"https://example.org/vote\"]"));
|
||||
assertNull(GuidedVoteSession.httpUrl("[Text=\"Vote here\",url=\"javascript:alert(1)\"]"));
|
||||
assertNull(GuidedVoteSession.httpUrl("[Text=\"Vote here\",url=\"" + authenticatedUrl + "\"]"));
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.bencodez.votingplugin.tests.core.session;
|
||||
|
||||
import com.bencodez.votingplugin.core.session.GuidedVoteSession;
|
||||
import com.bencodez.votingplugin.core.session.ProxyVoteSequence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
|
||||
class ProxyVoteSequenceTest {
|
||||
private GuidedVoteSession session() {
|
||||
var session = new GuidedVoteSession(Long.MAX_VALUE, Long.MAX_VALUE);
|
||||
session.bindCandidates(List.of(new GuidedVoteSession.Site("ore", "Site", "https://example.org", true, 0)));
|
||||
session.refresh(List.of(new GuidedVoteSession.Site("ore", "Site", "https://example.org", true, 0)));
|
||||
return session;
|
||||
}
|
||||
@Test void oldInFlightAndNewDelayedVotesUseOriginalOrderNotClocksOrDeliveryOrder() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID());
|
||||
UUID old = source.next(); var fence = source.fence(); UUID fresh = source.next();
|
||||
var guide = session(); guide.bindProxyFence(fence);
|
||||
guide.acceptedProxy("ore", old); assertEquals(0, guide.view("").received());
|
||||
guide.acceptedProxy("ore", fresh); guide.acceptedProxy("ore", fresh);
|
||||
assertEquals(1, guide.view("").received());
|
||||
assertEquals(8, fresh.version()); assertEquals(2, fresh.variant());
|
||||
assertEquals(fresh, UUID.fromString(fresh.toString()));
|
||||
}
|
||||
@Test void newReceiptMayOvertakeItsHandshakeAndWrongSourceCannotConfirm() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID()); var fence = source.fence();
|
||||
UUID fresh = source.next(); var guide = session(); guide.acceptedProxy("ore", fresh);
|
||||
guide.bindProxyFence(new ProxyVoteSequence(UUID.randomUUID()).fence());
|
||||
assertEquals(0, guide.view("").received());
|
||||
guide.bindProxyFence(fence); assertEquals(1, guide.view("").received());
|
||||
}
|
||||
@Test void outOfOrderNewVotesAndSiteIdentityDoNotDoubleCount() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID()); var fence = source.fence();
|
||||
UUID first = source.next(), later = source.next(); var guide = session(); guide.bindProxyFence(fence);
|
||||
guide.acceptedProxy("unrelated", later); assertEquals(0, guide.view("").received());
|
||||
guide.acceptedProxy("ore", later); guide.acceptedProxy("ore", first);
|
||||
assertEquals(1, guide.view("").received());
|
||||
}
|
||||
@Test void restartIncarnationAndLegacyIdsStayUnconfirmed() {
|
||||
var oldSource = new ProxyVoteSequence(UUID.randomUUID()); UUID old = oldSource.next();
|
||||
var current = new ProxyVoteSequence(UUID.randomUUID()); var guide = session(); guide.bindProxyFence(current.fence());
|
||||
guide.acceptedProxy("ore", old); guide.acceptedProxy("ore", UUID.randomUUID());
|
||||
assertEquals(0, guide.view("").received()); guide.acceptedProxy("ore", current.next());
|
||||
assertEquals(1, guide.view("").received());
|
||||
}
|
||||
@Test void wireNegotiationPreservesFullPrecisionAndIgnoresUnknownVersion() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID()); source.next();
|
||||
var request = new VotingPluginWire.SessionFenceRequest(UUID.randomUUID(), UUID.randomUUID(), "backend", "origin");
|
||||
var envelope = VotingPluginWire.sessionFenceReply(request, source.fence());
|
||||
var decoded = VotingPluginWire.readSessionFenceReply(envelope);
|
||||
assertNotNull(decoded); assertEquals(request, decoded.request()); assertEquals(source.fence(), decoded.fence());
|
||||
var unknown = com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder(envelope.getSubChannel())
|
||||
.schema(envelope.getSchema());
|
||||
envelope.getFields().forEach(unknown::put); unknown.put(VotingPluginWire.K_SESSION_FENCE_VERSION, 2);
|
||||
assertNull(VotingPluginWire.readSessionFenceReply(unknown.build()));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProxyVoteSequence.Fence(UUID.randomUUID(), 0));
|
||||
}
|
||||
@Test void statusReplyDoesNotAdvanceAnExistingSessionFence() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID()); var guide = session(); guide.bindProxyFence(source.fence());
|
||||
UUID occurrence = source.next(); guide.bindProxyFence(source.fence()); guide.acceptedProxy("ore", occurrence);
|
||||
assertEquals(1, guide.view("").received());
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
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
@@ -1,241 +0,0 @@
|
||||
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
@@ -1,982 +0,0 @@
|
||||
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
@@ -1,65 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+5
@@ -5,10 +5,15 @@ import com.bencodez.votingplugin.listeners.*;
|
||||
import com.bencodez.votingplugin.listeners.PlayerVoteListener.*;
|
||||
import java.util.UUID;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
|
||||
/** Typed test-only access to package-private PlayerVoteListener seams. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class PlayerVoteListenerAccess {
|
||||
private PlayerVoteListenerAccess() { }
|
||||
public static void copySessionObservation(PlayerPostVoteEvent post, PlayerVoteEvent input) {
|
||||
ReflectiveAccess.call("com.bencodez.votingplugin.listeners.PlayerVoteListener", null,
|
||||
"copySessionObservation", new String[]{"PlayerPostVoteEvent", "PlayerVoteEvent"}, new Object[]{post, input});
|
||||
}
|
||||
public static UUID resolveProxyVoteId(PlayerVoteEvent event) { return (UUID) ReflectiveAccess.call("com.bencodez.votingplugin.listeners.PlayerVoteListener", null, "resolveProxyVoteId", new String[]{"PlayerVoteEvent"}, new Object[]{event}); }
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.bencodez.votingplugin.tests.listeners;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
class SessionObservationMetadataTest {
|
||||
@Test void acceptedAdapterForwardsLocalOrderAndProxyClassificationWithoutChangingOccurrenceTime() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
input.setBungee(true); input.setProxyQueueClassificationKnown(true); input.setQueuedProxyVote(true);
|
||||
input.setUnconfirmedProxySessionDelivery(true);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, 1, false, "a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertEquals(input.getBackendObservationOrder(), post.getBackendObservationOrder());
|
||||
assertFalse(post.isLiveLocalSessionDelivery()); assertTrue(post.isProxySessionDelivery()); assertFalse(post.isBungee()); assertTrue(post.isProxyQueueClassificationKnown()); assertTrue(post.isQueuedProxyVote());
|
||||
assertEquals(1, post.getVoteTime()); assertTrue(post.isUnconfirmedProxySessionDelivery());
|
||||
}
|
||||
@Test void freshLocalIngressIsClassifiedWithoutReclassifyingHistoricalLocalDelivery() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, 100, true, "a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertTrue(post.isLiveLocalSessionDelivery()); assertFalse(post.isProxySessionDelivery());
|
||||
assertEquals(input.getBackendObservationOrder(), post.getBackendObservationOrder());
|
||||
assertEquals(100, post.getVoteTime()); // Cached rewards do not imply a historical local occurrence.
|
||||
input.setTime(99);
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertFalse(post.isLiveLocalSessionDelivery()); assertEquals(100, post.getVoteTime());
|
||||
}
|
||||
@Test void recoveredZeroTimestampLocalVoteDoesNotInventCurrentObservationOrder() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
input.setBackendObservationOrder(0L);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, System.currentTimeMillis(), false,
|
||||
"a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post,input);
|
||||
assertFalse(post.isLiveLocalSessionDelivery()); assertTrue(post.isUnconfirmedLocalSessionDelivery());
|
||||
assertEquals(0,post.getBackendObservationOrder()); assertTrue(post.getVoteTime()>0);
|
||||
}
|
||||
@Test void timeQueuedLiveVoteRetainsOrderDespiteHistoricalWallClock() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
input.setTime(1L); input.setBackendObservationOrder(42L); input.setTimeQueuedLocalSessionDelivery(true);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, 1L, false, "a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertTrue(post.isLiveLocalSessionDelivery()); assertFalse(post.isUnconfirmedLocalSessionDelivery());
|
||||
assertEquals(42L, post.getBackendObservationOrder()); assertEquals(1L, post.getVoteTime());
|
||||
}
|
||||
@Test void recoveredTimeQueueCannotInventFreshnessFromFutureTimestamp() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
input.setTime(Long.MAX_VALUE); input.setBackendObservationOrder(0L); input.setTimeQueuedLocalSessionDelivery(true);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, Long.MAX_VALUE, false, "a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertFalse(post.isLiveLocalSessionDelivery()); assertTrue(post.isUnconfirmedLocalSessionDelivery());
|
||||
assertEquals(0L, post.getBackendObservationOrder()); assertEquals(Long.MAX_VALUE, post.getVoteTime());
|
||||
}
|
||||
}
|
||||
+35
-11
@@ -156,24 +156,48 @@ public class VotiferEventDisabledVoteSiteTest {
|
||||
verify(pluginManager).callEvent(any(PlayerVoteEvent.class));
|
||||
}
|
||||
|
||||
@Test public void voteAdmissionBeforeGuideKeepsItsOrderWhenProcessingRunsAfterGuideOpens() {
|
||||
var pending=new java.util.ArrayDeque<Runnable>();
|
||||
doAnswer(call->{pending.add(call.getArgument(0));return CompletableFuture.completedFuture(null);})
|
||||
.when(voteTimer).submit(any(Runnable.class));
|
||||
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE));
|
||||
var guide=new com.bencodez.votingplugin.core.session.GuidedVoteSession(System.currentTimeMillis());
|
||||
guide.bindCandidates(java.util.List.of(new com.bencodez.votingplugin.core.session.GuidedVoteSession.Site("site","Site","https://example.org",true,0)));
|
||||
guide.refresh(java.util.List.of(new com.bencodez.votingplugin.core.session.GuidedVoteSession.Site("site","Site","https://example.org",true,0)));
|
||||
pending.remove().run();
|
||||
var events=org.mockito.ArgumentCaptor.forClass(PlayerVoteEvent.class);
|
||||
verify(pluginManager).callEvent(events.capture());
|
||||
long original=events.getValue().getBackendObservationOrder();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(original>0);
|
||||
guide.acceptedObserved("site",java.util.UUID.randomUUID(),original);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(0,guide.view("").received());
|
||||
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); pending.remove().run();
|
||||
verify(pluginManager,org.mockito.Mockito.times(2)).callEvent(events.capture());
|
||||
guide.acceptedObserved("site",java.util.UUID.randomUUID(),events.getValue().getBackendObservationOrder());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1,guide.view("").received());
|
||||
}
|
||||
@Test
|
||||
public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() {
|
||||
doThrow(new RejectedExecutionException("capacity exhausted"))
|
||||
.when(voteTimer).submit(any(Runnable.class));
|
||||
when(overflowQueue.enqueue("Steve", SERVICE_SITE)).thenReturn(true);
|
||||
when(overflowQueue.enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong())).thenReturn(true);
|
||||
|
||||
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE));
|
||||
|
||||
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);
|
||||
var order=org.mockito.ArgumentCaptor.forClass(Long.class);
|
||||
verify(overflowQueue).enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), order.capture());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(order.getValue()>0);
|
||||
verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class));
|
||||
}
|
||||
@Test public void timeChangeQueueReceivesCapturedIngressOrderBeforeDeferredProcessing() {
|
||||
when(plugin.getTimeChecker().isActiveProcessing()).thenReturn(true);
|
||||
when(plugin.getConfigFile().isQueueVotesDuringTimeChange()).thenReturn(true);
|
||||
var queue = mock(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class);
|
||||
when(plugin.getTimeQueueHandler()).thenReturn(queue);
|
||||
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE));
|
||||
var order = org.mockito.ArgumentCaptor.forClass(Long.class);
|
||||
verify(queue).addVote(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), order.capture());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(order.getValue() > 0);
|
||||
verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class));
|
||||
}
|
||||
}
|
||||
+22
@@ -29,6 +29,28 @@ import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue;
|
||||
|
||||
class VotifierVoteOverflowQueueTest {
|
||||
@Test void overflowRetainsProcessLocalOrderButRestartNeverRestoresIt(@TempDir Path dataFolder) throws Exception {
|
||||
var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS);
|
||||
var timer=mock(ScheduledExecutorService.class);
|
||||
when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); when(plugin.getVoteTimer()).thenReturn(timer);
|
||||
var callbacks=new java.util.concurrent.LinkedBlockingQueue<Runnable>();
|
||||
doAnswer(call->{callbacks.add(call.getArgument(0));return null;}).when(timer).submit(any(Runnable.class));
|
||||
var observed=new java.util.ArrayList<Long>();
|
||||
var queue=VotifierVoteOverflowQueue.withObservations(plugin,(site,user,order)->observed.add(order));
|
||||
try {
|
||||
assertTrue(queue.enqueue("Steve","example.org",123L)); queue.start();
|
||||
java.util.Objects.requireNonNull(callbacks.poll(5,TimeUnit.SECONDS)).run();
|
||||
assertEquals(java.util.List.of(123L),observed);
|
||||
assertTrue(queue.enqueue("Alex","other.example.org",456L));
|
||||
java.util.Objects.requireNonNull(callbacks.poll(5,TimeUnit.SECONDS)); // Deliberately unacknowledged.
|
||||
} finally {queue.close();}
|
||||
var restarted=VotifierVoteOverflowQueue.withObservations(plugin,(site,user,order)->observed.add(order));
|
||||
try {
|
||||
assertEquals(1,restarted.size()); restarted.start();
|
||||
java.util.Objects.requireNonNull(callbacks.poll(5,TimeUnit.SECONDS)).run();
|
||||
assertEquals(java.util.List.of(123L,0L),observed);
|
||||
} finally {restarted.close();}
|
||||
}
|
||||
@Test
|
||||
void enqueueCannotChangeVersionDuringDurableAdmission(@TempDir Path dataFolder) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
|
||||
-123
@@ -1,8 +1,6 @@
|
||||
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;
|
||||
@@ -10,14 +8,12 @@ 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;
|
||||
@@ -123,125 +119,6 @@ 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.PlatformScheduler;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
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);
|
||||
PlatformScheduler entityScheduler = mock(PlatformScheduler.class);
|
||||
ServerImplementation entityScheduler = mock(ServerImplementation.class);
|
||||
when(fixture.scheduler.getFoliaLib()).thenReturn(folia);
|
||||
when(folia.getImpl()).thenReturn(entityScheduler);
|
||||
when(entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class)))
|
||||
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
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
@@ -1,787 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+22
@@ -23,6 +23,28 @@ class ReliableVoteDeliveryOutboxTest {
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void retainedVotesLoseLiveFreshnessWithoutMutatingImmediateEnvelopeAndOldRowsAreSanitized() throws Exception {
|
||||
for (boolean oldRow : new boolean[] {false, true}) {
|
||||
Path file=directory.resolve(oldRow ? "old.dat" : "new.dat"); UUID id=UUID.randomUUID();
|
||||
JsonEnvelope live=VotingPluginWire.vote("Player",UUID.randomUUID().toString(),"site",10L,true,true,"",id,false,false,1,1);
|
||||
var previous=com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder(live.getSubChannel()).schema(live.getSchema());
|
||||
for(var field:live.getFields().entrySet()) previous.put(field.getKey(),field.getValue());
|
||||
live=previous.put(VotingPluginWire.K_SESSION_DELIVERY_FRESH,true).build();
|
||||
if(oldRow) {
|
||||
var encode=java.util.Base64.getUrlEncoder().withoutPadding();
|
||||
String server=encode.encodeToString("Survival".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
String envelope=encode.encodeToString(com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(live).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
Files.writeString(file,"VP-VOTE-OUTBOX-2\nA\t"+server+"\t"+envelope+"\n");
|
||||
} else assertTrue(new ReliableVoteDeliveryOutboxAccess(file).offer("Survival",live));
|
||||
var retained=new ReliableVoteDeliveryOutboxAccess(file).snapshot().getFirst().envelope();
|
||||
assertEquals("true",live.getFields().get(VotingPluginWire.K_SESSION_DELIVERY_FRESH));
|
||||
assertEquals("false",retained.getFields().get(VotingPluginWire.K_SESSION_DELIVERY_FRESH));
|
||||
var expected=new java.util.HashMap<>(live.getFields());expected.put(VotingPluginWire.K_SESSION_DELIVERY_FRESH,"false");
|
||||
assertEquals(expected,retained.getFields());assertEquals(live.getSchema(),retained.getSchema());assertEquals(live.getSubChannel(),retained.getSubChannel());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateQuotaDoesNotConsumeTheRewardEntryAllowance() throws Exception {
|
||||
Path file = directory.resolve("updates.dat");
|
||||
|
||||
+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.PlatformScheduler;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
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);
|
||||
PlatformScheduler implementation = mock(PlatformScheduler.class);
|
||||
ServerImplementation implementation = mock(ServerImplementation.class);
|
||||
Player player = mock(Player.class);
|
||||
PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence();
|
||||
UUID uuid = UUID.randomUUID();
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.bencodez.votingplugin.tests.session;
|
||||
|
||||
import com.bencodez.votingplugin.session.GuidedVotingSessions;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
|
||||
class GuidedSessionConnectionTest {
|
||||
@Test void queuedOldCommandCannotDisplaceReopenedGuide() { checkRetiredConnection(false); }
|
||||
@Test void queuedOldAutomaticRefreshCannotDisplaceReopenedGuide() { checkRetiredConnection(true); }
|
||||
|
||||
private void checkRetiredConnection(boolean automatic) {
|
||||
{
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
UUID uuid = UUID.randomUUID();
|
||||
Player old = mock(Player.class), current = mock(Player.class);
|
||||
var user = mock(VotingPluginUser.class);
|
||||
var site = mock(VoteSite.class);
|
||||
var timer = mock(ScheduledExecutorService.class);
|
||||
var config = new YamlConfiguration(); config.set("GuidedVotingSession.Enabled", true);
|
||||
when(plugin.getConfigFile().getData()).thenReturn(config);
|
||||
when(plugin.getOptions().isOnlineMode()).thenReturn(true);
|
||||
when(plugin.getTimer()).thenReturn(timer);
|
||||
when(plugin.getServer().getPlayer(uuid)).thenReturn(old);
|
||||
for (Player player : List.of(old, current)) {
|
||||
when(player.getUniqueId()).thenReturn(uuid);
|
||||
when(player.getName()).thenReturn("test-player");
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.hasPermission(anyString())).thenReturn(true);
|
||||
when(player.spigot()).thenReturn(mock(Player.Spigot.class));
|
||||
}
|
||||
when(plugin.getCommandLoader().canUseGuidedSession(any(Player.class))).thenReturn(true);
|
||||
when(site.getKey()).thenReturn("site-a"); when(site.getDisplayName()).thenReturn("Site A");
|
||||
when(site.getPermissionToView()).thenReturn(""); when(site.isEnabled()).thenReturn(true);
|
||||
when(site.getVoteURL(true)).thenReturn("https://example.org/vote");
|
||||
when(plugin.getVoteSiteManager().getVoteSites()).thenReturn(List.of(site));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, "test-player")).thenReturn(user);
|
||||
when(user.getLastVotes()).thenReturn(new java.util.HashMap<>());
|
||||
when(user.getJavaUUID()).thenReturn(uuid);
|
||||
when(user.getPlayerName()).thenReturn("test-player");
|
||||
when(user.canVoteSite(eq(site), anyLong())).thenReturn(true);
|
||||
var owners = new ArrayDeque<Runnable>(); var workers = new ArrayDeque<Runnable>();
|
||||
doAnswer(i -> { workers.add(i.getArgument(0)); return null; }).when(timer).execute(any());
|
||||
try (var scheduler = mockStatic(BukkitCompletionScheduler.class)) {
|
||||
scheduler.when(() -> BukkitCompletionScheduler.run(eq(plugin), any(Player.class), any(Runnable.class),
|
||||
any(Runnable.class), any(Runnable.class))).thenAnswer(i -> { owners.add(i.getArgument(2)); return null; });
|
||||
var sessions = SessionDialogViewAccess.sessions(plugin, () -> null);
|
||||
sessions.command(old, ""); owners.remove().run(); workers.remove().run(); owners.remove().run();
|
||||
if (automatic) {
|
||||
var event = new PlayerPostVoteEvent(site, user, true, false, Long.MAX_VALUE, true,
|
||||
"site-a", uuid, "test-player", UUID.randomUUID());
|
||||
sessions.credited(event);
|
||||
} else sessions.command(old, "check");
|
||||
assertEquals(1, owners.size()); Runnable stale = owners.remove();
|
||||
sessions.disconnected(new PlayerQuitEvent(old, ""));
|
||||
when(plugin.getServer().getPlayer(uuid)).thenReturn(current);
|
||||
sessions.command(current, "check"); owners.remove().run(); workers.remove().run(); owners.remove().run();
|
||||
clearInvocations(old, current, user);
|
||||
stale.run();
|
||||
assertTrue(workers.isEmpty(), "stale callback admitted work for a retired connection: " + automatic);
|
||||
assertTrue(owners.isEmpty());
|
||||
verifyNoInteractions(current, user);
|
||||
verify(old, never()).sendMessage(anyString());
|
||||
sessions.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.bencodez.votingplugin.tests.session;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
/** Contract coverage for the real bundled Spigot UniDialog adapter. */
|
||||
class GuidedSessionDialogsAdapterTest {
|
||||
@Test
|
||||
void showsNativeDialogRegistersItsEventBridgeAndReleasesOwnedActions() {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
Server server = mock(Server.class, RETURNS_DEEP_STUBS);
|
||||
PluginManager plugins = mock(PluginManager.class);
|
||||
Player player = mock(Player.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPluginManager()).thenReturn(plugins);
|
||||
|
||||
SessionDialogViewAccess adapter = SessionDialogViewAccess.nativeAdapter(plugin);
|
||||
verify(plugins).registerEvent(any(), any(), any(), any(), eq(plugin), eq(false));
|
||||
|
||||
BiConsumer<UUID, String> callback = mock(BiConsumer.class);
|
||||
Runnable cleanup = adapter.show(player, "Voting", "Choose a site", List.of(
|
||||
new SessionDialogViewAccess.Button("Vote", "Open", null, "https://example.test/vote"),
|
||||
new SessionDialogViewAccess.Button("Check", "Refresh", "check", null)), callback);
|
||||
assertNotNull(cleanup);
|
||||
verify(player).showDialog(any(net.md_5.bungee.api.dialog.Dialog.class));
|
||||
|
||||
cleanup.run();
|
||||
cleanup.run();
|
||||
verifyNoInteractions(callback);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user