mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-13 12:42:01 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b3f4c939c |
No files matched your search
@@ -330,7 +330,6 @@ Control behavior in production.
|
||||
|
||||
- Trace whether the code runs on the connector worker, proxy thread, Bukkit primary thread, or a SQL executor.
|
||||
- Preserve queued votes across saturation, reload, runtime replacement, scheduler rejection/cancellation, shutdown, and restart; overflow handling must be bounded, durable when promised, and observable rather than silently dropping work. Scheduler admission is never proof of durable ownership.
|
||||
- Local Votifier captures remain owned after STARTED until normal callback completion or confirmed overflow transfer. Transfer execution stays fenced while the original callback runs; successful non-interrupted completion reconciles only its exact occurrence, while interruption/exception or failed transfer retains bounded recovery ownership. A callback completing after the final overflow snapshot is an explicit uncertain reconciliation case; a retired queue must not overwrite a successor, and ordinary partial external effects retain the existing at-least-once boundary.
|
||||
- Proxy-to-backend guaranteed delivery is capability negotiated and at least once. Journal a reward-bearing envelope before
|
||||
reporting transport acceptance, retain it until the matching backend completion acknowledgement is durable, persist
|
||||
completed IDs before acknowledgement for restart-safe deduplication, retire receipts only through the durable
|
||||
|
||||
@@ -52,5 +52,3 @@ or disabling it; inspections gate disabled state immediately and report a newly
|
||||
```
|
||||
|
||||
`LATEST` resolves to the latest stable release.
|
||||
|
||||
Owner-defined [DateVoteMilestones](docs/date-vote-milestones.md) count qualifying accepted votes within explicit date windows, independently of normal totals. This optional feature is disabled until events are configured.
|
||||
+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>
|
||||
|
||||
@@ -287,7 +287,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Getter
|
||||
private TopVoterState topVoterState;
|
||||
private volatile com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter topVoterDisplayFilter;
|
||||
|
||||
|
||||
@Getter
|
||||
@@ -339,9 +338,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();
|
||||
@@ -351,13 +347,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private VotingPluginConfigHealth configHealth;
|
||||
private VotifierIntegration votifierIntegration;
|
||||
@Getter
|
||||
private volatile VotifierVoteOverflowQueue votifierVoteOverflowQueue;
|
||||
private VotiferEvent localVotifierListener;
|
||||
private boolean acceptedVoteIngressRegistered;
|
||||
private boolean timeQueueIngressRegistered;
|
||||
private volatile boolean localVotifierIngressReady;
|
||||
private volatile boolean localVotifierIngressClosed;
|
||||
private volatile long dateVoteIngressGeneration;
|
||||
private VotifierVoteOverflowQueue votifierVoteOverflowQueue;
|
||||
private VoteLogManager voteLogManager;
|
||||
private VotingPluginWebhookManager webhookManager;
|
||||
|
||||
@@ -536,84 +526,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();
|
||||
}
|
||||
@@ -717,11 +629,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
@Getter
|
||||
private VoteStreakHandler voteStreakHandler;
|
||||
|
||||
@Getter
|
||||
private final com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones dateVoteMilestones =
|
||||
new com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones(this);
|
||||
|
||||
|
||||
/**
|
||||
* Registers a directly editable rewards path from SpecialRewards.yml.
|
||||
*
|
||||
@@ -735,7 +642,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
VoteSiteRewardRegistrar.register(this);
|
||||
VotePartyRewardRegistrar.register(this);
|
||||
VoteMilestoneRewardRegistrar.register(this);
|
||||
dateVoteMilestones.reload();
|
||||
VoteStreakRewardRegistrar.register(this);
|
||||
TopVoterRewardRegistrar.register(this);
|
||||
VoteShopRewardRegistrar.register(this);
|
||||
@@ -807,37 +713,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
new VotingPluginMetrics().load(plugin);
|
||||
}
|
||||
|
||||
/** Publish accepted and transport ingress only after the complete vote pipeline is initialized. */
|
||||
void initializeDateVoteIngress(Runnable openProxy) {
|
||||
if (localVotifierIngressClosed) throw new IllegalStateException("Vote ingress is shutting down");
|
||||
if (localVotifierIngressReady) return;
|
||||
if (voteMilestonesManager == null || voteStreakHandler == null || voteParty == null || specialRewards == null
|
||||
|| topVoterHandler == null || voteShopManager == null || placeholders == null) {
|
||||
throw new IllegalStateException("Accepted-vote handlers must initialize before transport ingress");
|
||||
}
|
||||
getDateVoteMilestones().reload();
|
||||
if (!acceptedVoteIngressRegistered) {
|
||||
getServer().getPluginManager().registerEvents(new PlayerVoteListener(this), this);
|
||||
acceptedVoteIngressRegistered = true;
|
||||
}
|
||||
// Construction itself schedules persisted replay. Open it only after its consumer.
|
||||
if (!timeQueueIngressRegistered && (!getBungeeSettings().isUseBungeecoord() || !getBungeeSettings().isGloblalDataEnabled())) {
|
||||
timeQueueHandler = new TimeQueueHandler(this);
|
||||
getServer().getPluginManager().registerEvents(timeQueueHandler, this);
|
||||
timeQueueIngressRegistered = true;
|
||||
}
|
||||
if (getBungeeSettings().isUseBungeecoord()) openProxy.run();
|
||||
localVotifierIngressReady = true;
|
||||
VotifierVoteOverflowQueue overflow = votifierVoteOverflowQueue;
|
||||
if (overflow != null) overflow.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPostLoad() {
|
||||
loadVoteTimer();
|
||||
checkVotifier();
|
||||
registerEarlyVotifierIngress();
|
||||
serverData.refreshVotePartyDisplay();
|
||||
ensureCommunicationSecret();
|
||||
// auto conversion for Shop.yml
|
||||
if (plugin.getShopFile().isJustCreated()) {
|
||||
@@ -865,6 +742,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
voteTester = new VoteTester(plugin);
|
||||
|
||||
loadVoteTimer();
|
||||
BoundedScheduledExecutor setupReminderWorker = new BoundedScheduledExecutor(1, 4);
|
||||
setupReminderWorker.setThreadFactory(runnable -> {
|
||||
Thread worker = new Thread(runnable, "VotingPlugin-SiteSetupReminder");
|
||||
@@ -877,8 +755,13 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
autoCreateVoteSitesSetupReminder.start();
|
||||
getVotingPluginUserManager().startSharedPointTransferRecovery();
|
||||
|
||||
// Proxy ingress opens below, after every accepted-vote handler is initialized.
|
||||
if (bungeeSettings.isUseBungeecoord()) {
|
||||
loadBungeeHandler();
|
||||
}
|
||||
|
||||
if (!bungeeSettings.isUseBungeecoord() || !bungeeSettings.isGloblalDataEnabled()) {
|
||||
this.timeQueueHandler = new TimeQueueHandler(this);
|
||||
}
|
||||
|
||||
if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) {
|
||||
if (getConfigFile().isLoadInteralExpansion()) {
|
||||
@@ -893,6 +776,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
voteStreakHandler.reload();
|
||||
|
||||
registerCommands();
|
||||
checkVotifier();
|
||||
registerEvents();
|
||||
refreshPlaceholderPlayerPresence();
|
||||
|
||||
@@ -918,7 +802,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
});
|
||||
|
||||
topVoterState = new TopVoterState();
|
||||
initializeTopVoterDisplayFilter();
|
||||
topVoterHandler = new TopVoterHandler(this);
|
||||
|
||||
new AdminGUI(this).loadHook();
|
||||
@@ -983,7 +866,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
}
|
||||
|
||||
VotingPluginRewardRegistrar.register(this);
|
||||
initializeDateVoteIngress(this::loadBungeeHandler);
|
||||
// Recovered Votifier votes may now traverse the fully initialized vote,
|
||||
// reward, placeholder, shop, and vote-party pipeline.
|
||||
if (votifierVoteOverflowQueue != null) {
|
||||
votifierVoteOverflowQueue.start();
|
||||
}
|
||||
|
||||
plugin.getLogger().info("Enabled VotingPlugin " + plugin.getDescription().getVersion());
|
||||
if (plugin.getDescription().getVersion().contains("SNAPSHOT")) {
|
||||
@@ -2044,112 +1931,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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a fresh ingress generation only after the prior consumers retired. */
|
||||
private void resetDateVoteIngressForEnable() {
|
||||
if ((localVotifierListener != null || acceptedVoteIngressRegistered || timeQueueIngressRegistered)
|
||||
&& (completedUnloadSteps == null || !completedUnloadSteps.contains("event handlers")
|
||||
|| localVotifierListener != null && !completedUnloadSteps.contains("local Votifier ingress"))) {
|
||||
throw new IllegalStateException("Previous vote ingress did not retire");
|
||||
}
|
||||
dateVoteIngressGeneration++;
|
||||
localVotifierIngressReady = false;
|
||||
localVotifierIngressClosed = false;
|
||||
acceptedVoteIngressRegistered = false;
|
||||
timeQueueIngressRegistered = false;
|
||||
localVotifierListener = null;
|
||||
timeQueueHandler = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPreLoad() {
|
||||
resetDateVoteIngressForEnable();
|
||||
plugin = this;
|
||||
unloadInProgress.set(false);
|
||||
completedUnloadSteps = new java.util.HashSet<>();
|
||||
signShutdownPublication = null;
|
||||
|
||||
setupFiles();
|
||||
if ("SQLITE".equalsIgnoreCase(configFile.getData().getString("DataStorage", "SQLITE"))) {
|
||||
@@ -2191,68 +1975,15 @@ 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();
|
||||
});
|
||||
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,
|
||||
@@ -2280,112 +2011,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("local Votifier ingress", () -> {
|
||||
localVotifierIngressClosed = true;
|
||||
localVotifierIngressReady = false;
|
||||
if (localVotifierListener != null) localVotifierListener.stop();
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2414,44 +2083,25 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
// Export documentation only after both handler lists and the player command label are ready.
|
||||
// A filesystem problem must never stop registration or normal voting.
|
||||
try {
|
||||
if (com.bencodez.votingplugin.commands.CommandDocumentationExporter.write(getDataFolder().toPath(),
|
||||
getVoteCommandPrefix(), getVoteCommand(), getAdminVoteCommand())) {
|
||||
getLogger().info("Updated generated command documentation in GeneratedDocs/");
|
||||
}
|
||||
} catch (IOException | RuntimeException error) {
|
||||
getLogger().warning("Unable to write generated command documentation: " + error.getMessage());
|
||||
}
|
||||
|
||||
plugin.debug("Loaded Commands");
|
||||
|
||||
}
|
||||
|
||||
/** Capture external events early; only the existing bounded workers may buffer/process them. */
|
||||
void registerEarlyVotifierIngress() {
|
||||
if (localVotifierIngressClosed || !isVotifierLoaded() || localVotifierListener != null) return;
|
||||
long generation = dateVoteIngressGeneration;
|
||||
java.util.function.BooleanSupplier ready = () -> dateVoteIngressGeneration == generation
|
||||
&& localVotifierIngressReady && !localVotifierIngressClosed;
|
||||
VotiferEvent listener = new VotiferEvent(this, ready);
|
||||
// Publish the memory owner before capture. Its independent worker loads
|
||||
// disk state; vote-executor cancellation cannot discard queue setup.
|
||||
votifierVoteOverflowQueue = VotifierVoteOverflowQueue.initializeConfirmedAsync(this,
|
||||
listener::processVoteConfirmed, ready);
|
||||
localVotifierListener = listener;
|
||||
if (localVotifierIngressReady) votifierVoteOverflowQueue.start();
|
||||
getServer().getPluginManager().registerEvents(listener, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register events.
|
||||
*/
|
||||
private void registerEvents() {
|
||||
voteStreaksMenu = new com.bencodez.votingplugin.streaks.VoteStreaksMenu(this);
|
||||
getServer().getPluginManager().registerEvents(voteStreaksMenu, this);
|
||||
PluginManager pm = getServer().getPluginManager();
|
||||
|
||||
pm.registerEvents(new PlayerJoinEvent(this), this);
|
||||
pm.registerEvents(new com.bencodez.votingplugin.listeners.DateVoteMenuOpenGuard(), this);
|
||||
if (isVotifierLoaded()) {
|
||||
VotiferEvent votifierEvent = new VotiferEvent(this);
|
||||
votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, votifierEvent::processVote);
|
||||
pm.registerEvents(votifierEvent, this);
|
||||
}
|
||||
pm.registerEvents(new PlayerVoteListener(this), this);
|
||||
pm.registerEvents(new PlayerPostVoteLoggerListener(this), this);
|
||||
pm.registerEvents(new PlayerSpecialRewardLoggerListener(this), this);
|
||||
pm.registerEvents(new VoteShopPurchaseLoggerListener(this), this);
|
||||
@@ -2465,6 +2115,9 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
pm.registerEvents(new PlayerInteract(this), this);
|
||||
}
|
||||
|
||||
if (timeQueueHandler != null) {
|
||||
pm.registerEvents(timeQueueHandler, plugin);
|
||||
}
|
||||
|
||||
pm.registerEvents(new VotingPluginUpdateEvent(this), this);
|
||||
/*
|
||||
@@ -2495,19 +2148,15 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
/** Captures Bukkit presence while the lifecycle caller owns platform access. */
|
||||
public void refreshPlaceholderPlayerPresence() {
|
||||
boolean onlineMode = getOptions().isOnlineMode();
|
||||
placeholderPlayerPresence.refresh(Bukkit::getOnlinePlayers, (player, capture) ->
|
||||
com.bencodez.votingplugin.util.BukkitCompletionScheduler.run(this, player, () -> {
|
||||
if (!player.isOnline()) return;
|
||||
UUID storageUuid = placeholderPlayerPresence.storageUuid(player);
|
||||
capture.accept(storageUuid == null ? placeholderStorageUuid(player, onlineMode) : storageUuid);
|
||||
}, () -> { }, () -> { }));
|
||||
placeholderPlayerPresence.replace(Bukkit::getOnlinePlayers,
|
||||
player -> placeholderStorageUuid(player, onlineMode));
|
||||
}
|
||||
|
||||
static UUID placeholderStorageUuid(Player player, boolean onlineMode) {
|
||||
if (onlineMode) return player.getUniqueId();
|
||||
String resolvedUuid = UuidLookup.getInstance().getUUIDWithoutStorage(player.getName());
|
||||
String cachedUuid = UuidLookup.getInstance().getCachedUUID(player.getName());
|
||||
try {
|
||||
return UUID.fromString(resolvedUuid);
|
||||
return UUID.fromString(cachedUuid);
|
||||
} catch (IllegalArgumentException | NullPointerException ignored) {
|
||||
return player.getUniqueId();
|
||||
}
|
||||
@@ -2523,7 +2172,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl,
|
||||
boolean updateActiveBackendRuntime) {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.pause();
|
||||
retireTopVoterDisplayFilter();
|
||||
try {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.suspend();
|
||||
configFile.reloadData();
|
||||
@@ -2544,13 +2192,10 @@ 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();
|
||||
|
||||
getOptions().setServer(bungeeSettings.getServer());
|
||||
getDateVoteMilestones().reload();
|
||||
reloadBackendProxyRuntime(updateActiveBackendRuntime, userStorage);
|
||||
checkYMLError();
|
||||
|
||||
|
||||
+6
-20
@@ -569,8 +569,7 @@ public class BackendProxyMessageRouter {
|
||||
}
|
||||
|
||||
String totalsRaw = nvl(fields.get(VotingPluginWire.K_TOTALS));
|
||||
VoteTotalsSnapshot totals = !com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.hasPayload(totalsRaw)
|
||||
? null : VoteTotalsSnapshot.parseStorage(totalsRaw);
|
||||
VoteTotalsSnapshot totals = totalsRaw.isEmpty() ? null : VoteTotalsSnapshot.parseStorage(totalsRaw);
|
||||
VoteSite voteSite = plugin.getVoteSiteManager()
|
||||
.getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, service), true);
|
||||
if (voteSite == null) {
|
||||
@@ -716,29 +715,16 @@ public class BackendProxyMessageRouter {
|
||||
user.cache();
|
||||
boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : user.isOnline();
|
||||
boolean queuedDelivery = vote.queuedDeliveryKnown ? vote.queuedDelivery : vote.delayValidated;
|
||||
com.bencodez.votingplugin.events.PlayerVoteEvent dispatchedEvent = null;
|
||||
VotingPluginUser.DateMilestoneAccountingException dateAccountingFailure = null;
|
||||
try {
|
||||
// Keep totals correlation for replay fencing, but only the original wire ID
|
||||
// identifies a canonical transport occurrence in the native event.
|
||||
dispatchedEvent = user.bungeeVotePluginMessagingEvent(vote.service, vote.time, totals, !vote.manageTotals,
|
||||
wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown,
|
||||
vote.queuedDeliveryKnown, vote.voteId,
|
||||
VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()), vote.realVote,
|
||||
vote.canonicalOccurrenceTime);
|
||||
} catch (VotingPluginUser.DateMilestoneAccountingException incomplete) {
|
||||
dateAccountingFailure = incomplete;
|
||||
}
|
||||
if ((dispatchedEvent == null || !dispatchedEvent.isCancelled())
|
||||
&& plugin.getBungeeSettings().isPerServerPoints()) {
|
||||
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()) {
|
||||
user.addPoints(plugin.getConfigFile().getPointsOnVote());
|
||||
}
|
||||
if (vote.service != null && !vote.service.isEmpty()) {
|
||||
plugin.getServerData().addServiceSite(vote.service);
|
||||
}
|
||||
// Date accounting cannot suppress normal points/service observations. Keep
|
||||
// the reservation incomplete so reconciliation cannot replay normal rewards.
|
||||
if (dateAccountingFailure != null) throw dateAccountingFailure;
|
||||
return new WireVoteResult(voteId, true, false);
|
||||
}
|
||||
|
||||
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
package com.bencodez.votingplugin.commands;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.advancedcore.api.command.PlayerCommandHandler;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* Exports the actual Bukkit command-handler registrations as a deterministic
|
||||
* machine-readable manifest and an administrator-friendly Markdown reference.
|
||||
* It never executes a command or accesses user data.
|
||||
*/
|
||||
public final class CommandDocumentationExporter {
|
||||
public static final String DIRECTORY = "GeneratedDocs";
|
||||
public static final String JSON_NAME = "commands.json";
|
||||
public static final String MARKDOWN_NAME = "commands.md";
|
||||
|
||||
private CommandDocumentationExporter() {
|
||||
}
|
||||
|
||||
public static final class CommandRow {
|
||||
private final String root;
|
||||
private final String syntax;
|
||||
private final String description;
|
||||
private final String permissionExpression;
|
||||
private final List<String> permissions;
|
||||
private final List<String> allTargetPermissions;
|
||||
private final boolean multiplePermissionChecks;
|
||||
private final String sender;
|
||||
|
||||
private CommandRow(String root, String syntax, String description, String permissionExpression,
|
||||
List<String> permissions, List<String> allTargetPermissions,
|
||||
boolean multiplePermissionChecks, String sender) {
|
||||
this.root = root;
|
||||
this.syntax = syntax;
|
||||
this.description = description;
|
||||
this.permissionExpression = permissionExpression;
|
||||
this.permissions = List.copyOf(permissions);
|
||||
this.allTargetPermissions = List.copyOf(allTargetPermissions);
|
||||
this.multiplePermissionChecks = multiplePermissionChecks;
|
||||
this.sender = sender;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture only loaded handlers, including conditionally registered handlers. */
|
||||
public static List<CommandRow> capture(String playerRoot, List<? extends CommandHandler> playerHandlers,
|
||||
List<? extends CommandHandler> adminHandlers) {
|
||||
List<CommandRow> result = new ArrayList<>();
|
||||
result.addAll(captureGroup(playerRoot, playerHandlers));
|
||||
result.addAll(captureGroup("/av", adminHandlers));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static List<CommandRow> captureGroup(String root, List<? extends CommandHandler> handlers) {
|
||||
List<CommandRow> result = new ArrayList<>();
|
||||
if (handlers == null) return result;
|
||||
for (CommandHandler handler : handlers) {
|
||||
if (handler == null) continue;
|
||||
String syntax = safe(handler.getHelpLineCommand(root));
|
||||
if (syntax.isEmpty()) continue;
|
||||
String permission = safe(handler.getPerm());
|
||||
List<String> allTarget = List.of();
|
||||
if (handler instanceof PlayerCommandHandler) {
|
||||
List<String> additional = ((PlayerCommandHandler) handler).getAdditionalPermissions();
|
||||
if (additional != null) allTarget = new ArrayList<>(new LinkedHashSet<>(additional));
|
||||
}
|
||||
String sender = handler.isForceConsole() ? "console" :
|
||||
handler.isAllowConsole() ? "player or console" : "player";
|
||||
result.add(new CommandRow(root, syntax, safe(handler.getHelpMessage()), permission,
|
||||
splitPermissions(permission), allTarget, handler.isAllowMultiplePermissions(), sender));
|
||||
}
|
||||
result.sort(Comparator.comparing((CommandRow row) -> row.syntax, String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing(row -> row.permissionExpression, String.CASE_INSENSITIVE_ORDER));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String safe(String text) {
|
||||
return text == null ? "" : text.trim();
|
||||
}
|
||||
|
||||
private static List<String> splitPermissions(String expression) {
|
||||
Set<String> unique = new LinkedHashSet<>();
|
||||
for (String part : expression.split("\\|")) {
|
||||
String value = part.trim();
|
||||
if (!value.isEmpty()) unique.add(value);
|
||||
}
|
||||
return new ArrayList<>(unique);
|
||||
}
|
||||
|
||||
/** Stable JSON for future documentation bots; it contains no credentials or player data. */
|
||||
public static String toJson(List<CommandRow> rows) {
|
||||
JsonObject doc = new JsonObject();
|
||||
doc.addProperty("schemaVersion", 1);
|
||||
doc.addProperty("scope", "VotingPlugin Bukkit registered handlers");
|
||||
JsonArray commands = new JsonArray();
|
||||
for (CommandRow row : rows) {
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty("root", row.root);
|
||||
object.addProperty("syntax", row.syntax);
|
||||
object.addProperty("description", row.description);
|
||||
object.addProperty("permissionExpression", row.permissionExpression);
|
||||
object.add("permissions", asArray(row.permissions));
|
||||
object.add("allTargetPermissions", asArray(row.allTargetPermissions));
|
||||
object.addProperty("multiplePermissionChecks", row.multiplePermissionChecks);
|
||||
object.addProperty("sender", row.sender);
|
||||
commands.add(object);
|
||||
}
|
||||
doc.add("commands", commands);
|
||||
return new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create().toJson(doc) + "\n";
|
||||
}
|
||||
|
||||
private static JsonArray asArray(List<String> values) {
|
||||
JsonArray array = new JsonArray();
|
||||
for (String value : values) array.add(value);
|
||||
return array;
|
||||
}
|
||||
|
||||
/** Reader-friendly generated snapshot for reviewing changes without running code. */
|
||||
public static String toMarkdown(List<CommandRow> rows) {
|
||||
StringBuilder markdown = new StringBuilder();
|
||||
markdown.append("# VotingPlugin Bukkit commands (generated)\n\n");
|
||||
markdown.append("This page is generated from the command handlers loaded by the running server. ");
|
||||
markdown.append("It is not a canonical release reference: configured command labels and ");
|
||||
markdown.append("conditionally loaded commands may differ between servers.\n\n");
|
||||
markdown.append("Permission expressions separated by | may provide alternatives when ");
|
||||
markdown.append("multiple-permission checks are enabled. Extra all-target permissions ");
|
||||
markdown.append("apply only to supported bulk player commands.\n\n");
|
||||
markdown.append("This export does not cover separate Velocity/BungeeCord proxy commands ");
|
||||
markdown.append("or standalone plugin.yml convenience aliases.\n\n");
|
||||
|
||||
Map<String, List<CommandRow>> groups = new LinkedHashMap<>();
|
||||
for (CommandRow row : rows) groups.computeIfAbsent(row.root, key -> new ArrayList<>()).add(row);
|
||||
for (Map.Entry<String, List<CommandRow>> group : groups.entrySet()) {
|
||||
markdown.append("## ").append(escapeCell(group.getKey())).append("\n\n");
|
||||
markdown.append("| Command | Description | Permission expression | All-target permissions | Sender |\n");
|
||||
markdown.append("|---|---|---|---|---|\n");
|
||||
for (CommandRow row : group.getValue()) {
|
||||
markdown.append("| ").append(code(row.syntax));
|
||||
markdown.append(" | ").append(escapeCell(row.description));
|
||||
markdown.append(" | ").append(code(row.permissionExpression.isEmpty() ? "(none)" : row.permissionExpression));
|
||||
markdown.append(" | ").append(row.allTargetPermissions.isEmpty() ? "—" :
|
||||
code(String.join(", ", row.allTargetPermissions)));
|
||||
markdown.append(" | ").append(escapeCell(row.sender)).append(" |\n");
|
||||
}
|
||||
markdown.append("\n");
|
||||
}
|
||||
return markdown.toString();
|
||||
}
|
||||
|
||||
private static String code(String value) {
|
||||
return String.valueOf((char) 96) + escapeCell(value) + (char) 96;
|
||||
}
|
||||
|
||||
private static String escapeCell(String value) {
|
||||
return safe(value).replace("\r", " ").replace("\n", " ").replace("|", "\\|")
|
||||
.replace(String.valueOf((char) 96), "`");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write only when content changes, using temporary files and atomic moves
|
||||
* when supported. No writes occur outside the plugin data folder.
|
||||
*/
|
||||
public static boolean write(Path pluginDataFolder, String playerRoot,
|
||||
List<? extends CommandHandler> playerHandlers, List<? extends CommandHandler> adminHandlers)
|
||||
throws IOException {
|
||||
List<CommandRow> rows = capture(playerRoot, playerHandlers, adminHandlers);
|
||||
Path directory = pluginDataFolder.resolve(DIRECTORY);
|
||||
Files.createDirectories(directory);
|
||||
boolean jsonChanged = writeIfChanged(directory.resolve(JSON_NAME), toJson(rows));
|
||||
boolean markdownChanged = writeIfChanged(directory.resolve(MARKDOWN_NAME), toMarkdown(rows));
|
||||
return jsonChanged || markdownChanged;
|
||||
}
|
||||
|
||||
private static boolean writeIfChanged(Path target, String content) throws IOException {
|
||||
if (Files.isSymbolicLink(target)) throw new IOException("Refusing to overwrite symbolic link: " + target);
|
||||
if (Files.isRegularFile(target) && content.equals(Files.readString(target, StandardCharsets.UTF_8))) {
|
||||
return false;
|
||||
}
|
||||
Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".tmp");
|
||||
try {
|
||||
Files.writeString(temporary, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException unsupported) {
|
||||
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -354,7 +354,7 @@ public class CommandLoader {
|
||||
java.util.List<VotingPluginUser> users = new java.util.ArrayList<>();
|
||||
try {
|
||||
for (String uuidString : plugin.getUserManager().getAllUUIDs()) {
|
||||
users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuidString), true));
|
||||
users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuidString)));
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
plugin.debug(failure);
|
||||
@@ -607,31 +607,8 @@ 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<>());
|
||||
DateVoteMilestoneCommands.register(plugin, adminPerm);
|
||||
|
||||
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, new String[] { "CurrentPluginTime" },
|
||||
"VotingPlugin.Commands.AdminVote.CurrentPluginTime|" + adminPerm, "Current plugin time") {
|
||||
@@ -866,7 +843,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(
|
||||
@@ -2970,7 +2947,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());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3652,15 +3631,7 @@ public class CommandLoader {
|
||||
*/
|
||||
private void loadVoteCommand() {
|
||||
plugin.setVoteCommand(new ArrayList<>());
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "DateEvents" },
|
||||
"VotingPlugin.Commands.Vote.DateEvents|" + playerPerm, "View date-window vote milestone progress") {
|
||||
@Override public void execute(CommandSender sender, String[] args) {
|
||||
if (sender instanceof Player player) plugin.getDateVoteMilestones().progress(player);
|
||||
else sender.sendMessage("Run /vote dateevents as a player.");
|
||||
}
|
||||
});
|
||||
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" },
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" },
|
||||
"VotingPlugin.Commands.Vote.Help|" + playerPerm, "View help page") {
|
||||
|
||||
@Override
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
package com.bencodez.votingplugin.commands;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.simpleapi.command.TabCompleteHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones.AdminAction;
|
||||
import org.bukkit.command.CommandSender;
|
||||
|
||||
/** Exact-target administration; storage and confirmed completion belong to the manager. */
|
||||
final class DateVoteMilestoneCommands {
|
||||
private DateVoteMilestoneCommands() { }
|
||||
|
||||
static void register(VotingPluginMain plugin, String adminPermission) {
|
||||
// The SDK only matches registered variable tokens; its built-in player token also rewrites fuzzy names.
|
||||
for (String token : new String[] { "(player-id)", "(date-event)", "(vote-count)", "(date-milestone)" })
|
||||
TabCompleteHandler.getInstance().addTabCompleteOption(token, new java.util.ArrayList<>());
|
||||
for (AdminAction action : AdminAction.values()) {
|
||||
String[] arguments = switch (action) {
|
||||
case Status, Preview -> new String[] { "DateVoteMilestone&DVM", action.name(), "(player-id)", "(date-event)" };
|
||||
case SetVotes -> new String[] { "DateVoteMilestone&DVM", action.name(), "(player-id)", "(date-event)", "(vote-count)" };
|
||||
case Force, ForceNoLimits -> new String[] { "DateVoteMilestone&DVM", action.name(), "(player-id)", "(date-event)", "(date-milestone)" };
|
||||
};
|
||||
String description = switch (action) {
|
||||
case Status, Preview -> "Inspect date event counts and reward fences without submitting rewards";
|
||||
case SetVotes -> "Set date event count (0-4096), preserving receipt IDs and reward fences";
|
||||
case Force -> "Force selected date milestones, preserving prior submission and uncertain reservation fences";
|
||||
case ForceNoLimits -> "Repeat submitted date milestones; uncertain reservations remain blocked";
|
||||
};
|
||||
CommandHandler handler = new CommandHandler(plugin, arguments,
|
||||
"VotingPlugin.Commands.AdminVote.DateVoteMilestone." + action.name() + "|" + adminPermission, description) {
|
||||
@Override public void execute(CommandSender sender, String[] args) {
|
||||
var manager = plugin.getDateVoteMilestones();
|
||||
if (manager == null) { sender.sendMessage("Date milestones are unavailable."); return; }
|
||||
manager.administer(sender, action, args[2], args[3], args.length > 4 ? args[4] : null);
|
||||
}
|
||||
};
|
||||
handler.setAllowMultiplePermissions(true);
|
||||
plugin.getAdminVoteCommand().add(handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -80,7 +80,7 @@ public class AdminVoteTopPoints extends GUIHandler {
|
||||
LinkedHashMap<TopVoterPlayer, Integer> raw = new LinkedHashMap<>();
|
||||
for (String uuidString : plugin.getVotingPluginUserManager().getAllUUIDs()) {
|
||||
UUID uuid = UUID.fromString(uuidString);
|
||||
VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true);
|
||||
VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid);
|
||||
vpUser.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
int points = vpUser.getPoints();
|
||||
if (points <= 0) continue;
|
||||
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
package com.bencodez.votingplugin.commands.gui.player;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.entity.HumanEntity;
|
||||
|
||||
import com.bencodez.advancedcore.api.inventory.BInventory;
|
||||
import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent;
|
||||
import com.bencodez.advancedcore.api.inventory.BInventoryButton;
|
||||
import com.bencodez.advancedcore.api.item.ItemBuilder;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Availability;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Event;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Milestone;
|
||||
|
||||
/** Entity-thread presentation of an immutable storage-worker result. */
|
||||
public final class DateVoteEventsMenu {
|
||||
private static final int PAGE_SIZE = 45;
|
||||
private static final int LIMIT = 64;
|
||||
private static final String OPEN_GUARD = "DateVoteEventsOpenGuard";
|
||||
private record OpenGuard(java.util.UUID owner, BooleanSupplier eligible) { }
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss");
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final Player player;
|
||||
private final DateVoteProgressSnapshot snapshot;
|
||||
private final BooleanSupplier current;
|
||||
|
||||
private DateVoteEventsMenu(VotingPluginMain plugin, Player player, DateVoteProgressSnapshot snapshot,
|
||||
BooleanSupplier current) {
|
||||
this.plugin = plugin;
|
||||
this.player = player;
|
||||
this.snapshot = snapshot;
|
||||
this.current = current;
|
||||
}
|
||||
|
||||
public static void open(VotingPluginMain plugin, Player player, DateVoteProgressSnapshot snapshot,
|
||||
BooleanSupplier current) {
|
||||
new DateVoteEventsMenu(plugin, player, snapshot, current).list(0);
|
||||
}
|
||||
|
||||
private boolean eligible() {
|
||||
return current.getAsBoolean() && plugin.isEnabled() && player.isOnline()
|
||||
&& (player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")
|
||||
|| player.hasPermission("VotingPlugin.Player"));
|
||||
}
|
||||
|
||||
private BInventory inventory(String title) {
|
||||
BInventory inventory = new BInventory(title);
|
||||
inventory.setMaxInvSize(54);
|
||||
inventory.setPages(false);
|
||||
inventory.runClicksSync();
|
||||
inventory.dontClose();
|
||||
inventory.addData(OPEN_GUARD, new OpenGuard(player.getUniqueId(), this::eligible));
|
||||
return inventory;
|
||||
}
|
||||
|
||||
/** Called at Bukkit's final InventoryOpenEvent, after AdvancedCore's deferred callback. */
|
||||
public static boolean permitsOpen(BInventory inventory, HumanEntity viewer) {
|
||||
Object value = inventory.getData(OPEN_GUARD);
|
||||
return !(value instanceof OpenGuard guard)
|
||||
|| guard.owner().equals(viewer.getUniqueId()) && guard.eligible().getAsBoolean();
|
||||
}
|
||||
|
||||
private void list(int page) {
|
||||
if (!eligible()) return;
|
||||
List<Event> events = snapshot.events().stream().limit(LIMIT).toList();
|
||||
BInventory inventory = inventory("Date vote events " + (page + 1) + "/" + pages(events.size()));
|
||||
int end = Math.min(events.size(), (page + 1) * PAGE_SIZE);
|
||||
for (int index = page * PAGE_SIZE; index < end; index++) {
|
||||
Event event = events.get(index);
|
||||
inventory.addButton(index % PAGE_SIZE, button(Material.PAPER,
|
||||
event.definition().displayName(), eventLore(event), () -> detail(event, 0, page)));
|
||||
}
|
||||
List<String> summary = new ArrayList<>();
|
||||
if (events.isEmpty()) summary.add("No date vote events configured.");
|
||||
else if (events.stream().noneMatch(event -> event.definition().enabled()))
|
||||
summary.add("No enabled date vote events.");
|
||||
else summary.add("Select an event to view milestones.");
|
||||
summary.add("Snapshot: " + Instant.ofEpochMilli(snapshot.capturedAt()));
|
||||
inventory.addButton(49, button(Material.BOOK, "Date vote events", summary, () -> {}));
|
||||
if (page > 0) inventory.addButton(45, button(Material.ARROW, "Previous page", List.of(), () -> list(page - 1)));
|
||||
if (end < events.size()) inventory.addButton(53, button(Material.ARROW, "Next page", List.of(), () -> list(page + 1)));
|
||||
if (eligible()) inventory.openInventory(player);
|
||||
}
|
||||
|
||||
private void detail(Event event, int page, int listPage) {
|
||||
if (!eligible()) return;
|
||||
List<Milestone> milestones = event.milestones().stream().limit(LIMIT).toList();
|
||||
BInventory inventory = inventory("Date vote milestones " + (page + 1) + "/" + pages(milestones.size()));
|
||||
int end = Math.min(milestones.size(), (page + 1) * PAGE_SIZE);
|
||||
for (int index = page * PAGE_SIZE; index < end; index++) {
|
||||
Milestone milestone = milestones.get(index);
|
||||
List<String> lore = new ArrayList<>();
|
||||
lore.add("ID: " + milestone.id());
|
||||
lore.add("Enabled: " + milestone.enabled());
|
||||
lore.add("Award state: " + event.awardState(milestone.threshold()));
|
||||
List<String> types = milestone.rewardTypes().stream().limit(LIMIT)
|
||||
.map(type -> type.matches("[A-Za-z0-9_-]{1,64}") ? type : "Other").distinct().toList();
|
||||
lore.add("Reward types: " + (types.isEmpty() ? "none" : String.join(", ", types)));
|
||||
inventory.addButton(index % PAGE_SIZE, button(Material.PAPER, milestone.threshold() + " votes", lore, () -> {}));
|
||||
}
|
||||
List<String> summary = eventLore(event);
|
||||
if (milestones.isEmpty()) summary.add("No milestones configured.");
|
||||
inventory.addButton(48, button(Material.BOOK, event.definition().displayName(), summary, () -> {}));
|
||||
inventory.addButton(49, button(Material.ARROW, "Back to events", List.of(), () -> list(listPage)));
|
||||
if (page > 0) inventory.addButton(45, button(Material.ARROW, "Previous page", List.of(), () -> detail(event, page - 1, listPage)));
|
||||
if (end < milestones.size()) inventory.addButton(53, button(Material.ARROW, "Next page", List.of(), () -> detail(event, page + 1, listPage)));
|
||||
if (eligible()) inventory.openInventory(player);
|
||||
}
|
||||
|
||||
private List<String> eventLore(Event event) {
|
||||
var definition = event.definition();
|
||||
List<String> lore = new ArrayList<>();
|
||||
lore.add("ID: " + definition.id());
|
||||
lore.add("Status: " + event.status(snapshot.capturedAt()));
|
||||
ZoneId zone = ZoneId.of(definition.timezone());
|
||||
lore.add("Start: " + TIME.format(Instant.ofEpochMilli(definition.start()).atZone(zone)));
|
||||
lore.add("End (exclusive): " + TIME.format(Instant.ofEpochMilli(definition.end()).atZone(zone)));
|
||||
lore.add("Timezone: " + definition.timezone());
|
||||
if (event.availability() == Availability.OWNED) {
|
||||
lore.add("Votes: " + event.votes());
|
||||
lore.add("Next milestone: " + (event.nextThreshold().isPresent() ? event.nextThreshold().getAsInt() + " votes" : "none"));
|
||||
} else if (event.availability() == Availability.REMOTE) {
|
||||
lore.add("Progress owned by: " + definition.accountingServer());
|
||||
lore.add("Progress unavailable on this server.");
|
||||
} else {
|
||||
lore.add("Progress unavailable: accounting read failed.");
|
||||
}
|
||||
return lore;
|
||||
}
|
||||
|
||||
private static int pages(int size) {
|
||||
return Math.max(1, (size + PAGE_SIZE - 1) / PAGE_SIZE);
|
||||
}
|
||||
|
||||
private BInventoryButton button(Material material, String name, List<String> lore, Runnable action) {
|
||||
List<String> lines = List.copyOf(lore);
|
||||
return new BInventoryButton(new ItemBuilder(material)) {
|
||||
// ItemBuilder evaluates display Javascript and formatting while rendering. Render only
|
||||
// the blank material through it, then apply snapshot text literally to Bukkit metadata.
|
||||
@Override public ItemStack getItem() {
|
||||
ItemStack item = new ItemBuilder(material).toItemStack();
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
if (meta != null) {
|
||||
meta.setDisplayName(name);
|
||||
meta.setLore(lines);
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
@Override public ItemStack getItem(Player ignored) { return getItem(); }
|
||||
@Override public ItemStack getItem(Player ignored, HashMap<String, String> placeholders) { return getItem(); }
|
||||
@Override public void onClick(ClickEvent click) {
|
||||
if (eligible()) action.run();
|
||||
}
|
||||
}.dontClose();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -60,7 +60,7 @@ public class VoteToday extends GUIHandler {
|
||||
Player clicked = player.getServer().getPlayer(payload.owner());
|
||||
if (clicked != null) {
|
||||
VotingPluginUser clickedUser = plugin.getVotingPluginUserManager()
|
||||
.getVotingPluginUser(uuid, true);
|
||||
.getVotingPluginUser(uuid);
|
||||
new VoteGUI(plugin, clicked, clickedUser).open(
|
||||
GUIMethod.valueOf(plugin.getGui().getGuiMethodGUI().toUpperCase()));
|
||||
}
|
||||
@@ -161,7 +161,7 @@ public class VoteToday extends GUIHandler {
|
||||
inv.addButton(inv.getNextSlot(), new BInventoryButton(item) {
|
||||
@Override
|
||||
public void onClick(ClickEvent clickEvent) {
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true);
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid);
|
||||
new VoteGUI(plugin, player, user)
|
||||
.open(GUIMethod.valueOf(plugin.getGui().getGuiMethodGUI().toUpperCase()));
|
||||
}
|
||||
|
||||
+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);
|
||||
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
package com.bencodez.votingplugin.core.datemilestones;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable owner-defined event accounting contract; display text is not identity. */
|
||||
public record DateVoteEvent(String id, String displayName, boolean enabled, long start, long end,
|
||||
String timezone, List<Integer> thresholds, Set<String> sites, String accountingServer) {
|
||||
public DateVoteEvent {
|
||||
if (id == null || !id.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalArgumentException("Invalid event ID");
|
||||
if (start <= 0 || end <= start) throw new IllegalArgumentException("End must be after start");
|
||||
ZoneId.of(timezone);
|
||||
if (thresholds.isEmpty() || thresholds.size() > 64 || thresholds.stream().anyMatch(n -> n <= 0 || n > 4096)
|
||||
|| thresholds.stream().distinct().count() != thresholds.size()) throw new IllegalArgumentException("Invalid thresholds (1–4096, unique, max 64)");
|
||||
thresholds = thresholds.stream().sorted().toList();
|
||||
if (sites.size() > 100 || sites.stream().anyMatch(s -> s == null || s.length() > 128 || s.isBlank()))
|
||||
throw new IllegalArgumentException("Invalid site filters");
|
||||
sites = Set.copyOf(sites);
|
||||
if (accountingServer == null || accountingServer.length() > 128) throw new IllegalArgumentException("Invalid accounting server");
|
||||
if (displayName == null || displayName.length() > 256) throw new IllegalArgumentException("Invalid display name");
|
||||
}
|
||||
public boolean matches(long occurredAt, String site, boolean real, boolean proxy, String server) {
|
||||
return enabled && real && occurredAt >= start && occurredAt < end && (sites.isEmpty() || sites.contains(site))
|
||||
&& (!proxy || !accountingServer.isBlank() && accountingServer.equals(server));
|
||||
}
|
||||
public String fingerprint() {
|
||||
try {
|
||||
var bytes = new ByteArrayOutputStream();
|
||||
try (var data = new DataOutputStream(bytes)) {
|
||||
data.writeLong(start); data.writeLong(end); data.writeUTF(timezone);
|
||||
data.writeInt(thresholds.size());
|
||||
for (int threshold : thresholds) data.writeInt(threshold);
|
||||
data.writeInt(sites.size());
|
||||
for (String site : sites.stream().sorted().toList()) data.writeUTF(site);
|
||||
data.writeUTF(accountingServer);
|
||||
}
|
||||
return hash(bytes.toByteArray());
|
||||
} catch (java.io.IOException impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
/** Portable case-sensitive identity, including on case-insensitive filesystems. */
|
||||
public String fileId() { return fileId(id); }
|
||||
public static String fileId(String id) {
|
||||
if (id == null || !id.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalArgumentException("Invalid event ID");
|
||||
return hash(id.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
private static String hash(byte[] bytes) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
|
||||
catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
public static long timestamp(String localIso, String timezone) {
|
||||
LocalDateTime time = LocalDateTime.parse(localIso);
|
||||
ZoneId zone = ZoneId.of(timezone);
|
||||
if (zone.getRules().getValidOffsets(time).size() != 1)
|
||||
throw new IllegalArgumentException("Ambiguous/nonexistent local time; choose an unambiguous boundary");
|
||||
return time.atZone(zone).toInstant().toEpochMilli();
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
package com.bencodez.votingplugin.core.datemilestones;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable worker result; viewing this snapshot never mutates accounting or rewards. */
|
||||
public record DateVoteProgressSnapshot(long capturedAt, List<Event> events) {
|
||||
public DateVoteProgressSnapshot { events = List.copyOf(events); }
|
||||
public enum Availability { OWNED, REMOTE, UNAVAILABLE }
|
||||
public record Milestone(int threshold, String id, boolean enabled, List<String> rewardTypes) {
|
||||
public Milestone { rewardTypes = List.copyOf(rewardTypes); }
|
||||
}
|
||||
public record Event(DateVoteEvent definition, Availability availability, int votes,
|
||||
Set<Integer> submitted, Set<Integer> deferred, Set<Integer> reserved, List<Milestone> milestones) {
|
||||
public Event {
|
||||
submitted = Set.copyOf(submitted); deferred = Set.copyOf(deferred); reserved = Set.copyOf(reserved);
|
||||
milestones = List.copyOf(milestones);
|
||||
}
|
||||
public String status(long now) {
|
||||
return !definition.enabled() ? "disabled" : now < definition.start() ? "upcoming"
|
||||
: now >= definition.end() ? "ended" : "active";
|
||||
}
|
||||
public OptionalInt nextThreshold() {
|
||||
return availability == Availability.OWNED ? milestones.stream().filter(m -> m.enabled() && m.threshold() > votes)
|
||||
.mapToInt(Milestone::threshold).min() : OptionalInt.empty();
|
||||
}
|
||||
public String awardState(int threshold) {
|
||||
if (availability != Availability.OWNED) return "progress unavailable";
|
||||
if (reserved.contains(threshold)) return "pending review";
|
||||
if (submitted.contains(threshold)) return "submitted";
|
||||
if (deferred.contains(threshold)) return "deferred";
|
||||
return votes >= threshold ? "reached" : "not reached";
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -1,5 +1,7 @@
|
||||
package com.bencodez.votingplugin.core.vote;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.UUID;
|
||||
|
||||
/** The accepted vote sequence. Platform operations are supplied by the receiving adapter. */
|
||||
@@ -65,15 +67,12 @@ public final class SharedVoteProcessor {
|
||||
boolean forceProxyRouting();
|
||||
boolean wasOnline();
|
||||
boolean realVote();
|
||||
default long currentTimeMillis() { return System.currentTimeMillis(); }
|
||||
boolean addTotals();
|
||||
boolean broadcastEnabled();
|
||||
boolean hasBroadcastHandler();
|
||||
void broadcast(UUID uuid, String name, String siteDisplayName, boolean online);
|
||||
boolean hasProxyTextTotals();
|
||||
UUID proxyVoteId();
|
||||
/** Local receipt identity, without implying proxy provenance. */
|
||||
default UUID localOccurrenceId() { return null; }
|
||||
default boolean identifiedQueuedProxyVote() { return false; }
|
||||
default boolean proxyQueueClassificationKnown() { return false; }
|
||||
default boolean proxyDelayValidationKnown() { return false; }
|
||||
@@ -106,7 +105,6 @@ public final class SharedVoteProcessor {
|
||||
int enabledSiteCount();
|
||||
void setMonthTotal(U user, int total);
|
||||
void milestones(U user, UUID voteId, boolean forceProxyRouting);
|
||||
default void dateMilestones(U user, S site, long voteTime, UUID voteId) { }
|
||||
void cooldown(U user, S site);
|
||||
void voteStreak(U user, long voteTime, UUID voteId);
|
||||
void postVote(S site, U user, String playerName, long voteTime, UUID voteId, boolean cached);
|
||||
@@ -188,8 +186,10 @@ public final class SharedVoteProcessor {
|
||||
+ (legacyRecordedProxyVote ? "; legacy timestamp matches LastVotes: " + ops.incomingTime()
|
||||
: "; stable vote ID identifies this queued delivery: " + ops.proxyVoteId()));
|
||||
}
|
||||
UUID voteId = ops.proxyVote() ? ops.proxyVoteId() : ops.localOccurrenceId();
|
||||
if (voteId == null) voteId = UUID.randomUUID();
|
||||
UUID voteId = UUID.randomUUID();
|
||||
if (ops.proxyVote() && ops.proxyVoteId() != null) {
|
||||
voteId = ops.proxyVoteId();
|
||||
}
|
||||
String userId = ops.userId(user);
|
||||
ops.cache(user);
|
||||
ops.updateName(user);
|
||||
@@ -217,7 +217,7 @@ public final class SharedVoteProcessor {
|
||||
voteTime = ops.incomingTime();
|
||||
} else {
|
||||
ops.setTimeNow(user, site);
|
||||
voteTime = ops.currentTimeMillis();
|
||||
voteTime = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
}
|
||||
boolean cached = false;
|
||||
if (SharedVoteDelivery.shouldDeliverNow(ops::proxyVote, () -> ops.userOnline(user),
|
||||
@@ -251,7 +251,6 @@ public final class SharedVoteProcessor {
|
||||
}
|
||||
}
|
||||
ops.milestones(user, voteId, ops.forceProxyRouting());
|
||||
ops.dateMilestones(user, site, voteTime, voteId);
|
||||
ops.cooldown(user, site);
|
||||
ops.voteStreak(user, voteTime, voteId);
|
||||
ops.postVote(site, user, playerName, voteTime, voteId, cached);
|
||||
|
||||
-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)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,14 +87,10 @@ public class ServerData {
|
||||
* @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());
|
||||
getData().set("TimedVoteCache." + num + ".LocalOccurrenceId", vote.getLocalOccurrenceId() == null ? null : vote.getLocalOccurrenceId().toString());
|
||||
getData().set("TimedVoteCache." + num + ".CanonicalOccurrenceTime", vote.getCanonicalOccurrenceTime() == 0L ? null : vote.getCanonicalOccurrenceTime());
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
getData().set("TimedVoteCache." + num + ".Name", vote.getName());
|
||||
getData().set("TimedVoteCache." + num + ".Service", vote.getService());
|
||||
getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,28 +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));
|
||||
}
|
||||
|
||||
/** Assign a legacy local cache row its identity before replay is scheduled. */
|
||||
public void setTimedVoteLocalOccurrenceId(String key, UUID occurrenceId) {
|
||||
mutateData(() -> {
|
||||
getData().set("TimedVoteCache." + key + ".LocalOccurrenceId", occurrenceId.toString());
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the timed vote cache.
|
||||
*/
|
||||
public void clearTimedVoteCache() {
|
||||
mutateData(() -> {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
public void clearTimedVoteCache() {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,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<>());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,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"));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,11 +339,8 @@ public class ServerData {
|
||||
/**
|
||||
* Reload data.
|
||||
*/
|
||||
public void reloadData() {
|
||||
mutateData(() -> {
|
||||
getFile().reloadData();
|
||||
captureVotePartyDisplay();
|
||||
});
|
||||
public void reloadData() {
|
||||
plugin.getServerDataFile().reloadData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -562,168 +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());
|
||||
if (vote.getLocalOccurrenceId() != null) fields.put("LocalOccurrenceId", vote.getLocalOccurrenceId().toString());
|
||||
if (vote.getCanonicalOccurrenceTime() != 0L) fields.put("CanonicalOccurrenceTime", vote.getCanonicalOccurrenceTime());
|
||||
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());
|
||||
getData().set(path + ".LocalOccurrenceId", vote.getLocalOccurrenceId() == null ? null : vote.getLocalOccurrenceId().toString());
|
||||
getData().set(path + ".CanonicalOccurrenceTime", vote.getCanonicalOccurrenceTime() == 0L ? null : vote.getCanonicalOccurrenceTime());
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -731,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -742,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -751,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -760,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -769,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -778,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -793,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -810,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -831,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -843,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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,11 +13,6 @@ import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class PlayerPostVoteEvent extends Event {
|
||||
|
||||
/** Original accepted occurrence time; ordinary cooldown/event time remains separate. */
|
||||
@Getter
|
||||
@Setter
|
||||
private Long canonicalOccurrenceTime;
|
||||
|
||||
/** The Constant handlers. */
|
||||
private static final HandlerList handlers = new HandlerList();
|
||||
@@ -82,11 +77,6 @@ public class PlayerPostVoteEvent extends Event {
|
||||
@Setter
|
||||
private UUID voteUUID;
|
||||
|
||||
/** Original explicit proxy transport ID; voteUUID retains the legacy correlation fallback. */
|
||||
@Getter
|
||||
@Setter
|
||||
private UUID proxyVoteId;
|
||||
|
||||
/**
|
||||
* Constructs a new PlayerPostVoteEvent.
|
||||
*
|
||||
|
||||
@@ -19,17 +19,7 @@ import lombok.Setter;
|
||||
* <p>Cancellation follows Bukkit's {@code ignoreCancelled} dispatch contract.
|
||||
* Integrations must cancel before the MONITOR vote consumer runs.</p>
|
||||
*/
|
||||
public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
|
||||
/** Stable local receipt identity, separate from proxy transport provenance. */
|
||||
@Getter
|
||||
@Setter
|
||||
private UUID localOccurrenceId;
|
||||
|
||||
/** Original accepted occurrence time; ordinary cooldown/event time remains separate. */
|
||||
@Getter
|
||||
@Setter
|
||||
private Long canonicalOccurrenceTime;
|
||||
public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
|
||||
/** The Constant handlers. */
|
||||
private static final HandlerList handlers = new HandlerList();
|
||||
@@ -55,7 +45,7 @@ public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
@Setter
|
||||
private VoteTotalsSnapshot bungeeTextTotals;
|
||||
|
||||
/** Original explicit proxy transport ID; null for legacy totals-only correlation. */
|
||||
/** Stable identity supplied by a proxy delivery, independent of totals. */
|
||||
@Getter
|
||||
@Setter
|
||||
private UUID proxyVoteId;
|
||||
@@ -96,20 +86,6 @@ public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
@Setter
|
||||
private boolean wasOnline;
|
||||
|
||||
/** Native receipt ownership is released only after the accepted consumer returns. */
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean nativeProcessingStarted;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean nativeProcessingCompleted;
|
||||
|
||||
/** Missing date accounting is surfaced after event dispatch so durable proxy envelopes are not acknowledged. */
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean dateMilestoneAccountingFailed;
|
||||
|
||||
/** Whether this is an identified queued proxy delivery. */
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.bencodez.votingplugin.listeners;
|
||||
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryOpenEvent;
|
||||
import com.bencodez.advancedcore.api.inventory.GUISession;
|
||||
import com.bencodez.votingplugin.commands.gui.player.DateVoteEventsMenu;
|
||||
|
||||
/** Fence the actual platform open, including AdvancedCore's additional scheduling handoff. */
|
||||
public final class DateVoteMenuOpenGuard implements Listener {
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onOpen(InventoryOpenEvent event) {
|
||||
GUISession session = GUISession.extractSession(event.getInventory());
|
||||
if (session != null && !DateVoteEventsMenu.permitsOpen(session.getInventoryGUI(), event.getPlayer()))
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public class PlayerJoinEvent implements Listener {
|
||||
public void onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent event) {
|
||||
if (event == null || event.getPlayer() == null) return;
|
||||
Player player = event.getPlayer();
|
||||
plugin.getPlaceholderPlayerPresence().captureOnline(placeholderUuid(player), player);
|
||||
plugin.getPlaceholderPlayerPresence().playerOnline(placeholderUuid(player), player);
|
||||
}
|
||||
|
||||
private UUID placeholderUuid(Player player) {
|
||||
@@ -83,7 +83,7 @@ public class PlayerJoinEvent implements Listener {
|
||||
|
||||
Player player = event.getPlayer();
|
||||
if (player != null) {
|
||||
if (!plugin.getPlaceholderPlayerPresence().updateStorageUuid(user.getJavaUUID(), player)) return;
|
||||
plugin.getPlaceholderPlayerPresence().playerOnline(user.getJavaUUID(), player);
|
||||
}
|
||||
|
||||
if (player != null && plugin.isYmlError()) {
|
||||
@@ -160,19 +160,17 @@ public class PlayerJoinEvent implements Listener {
|
||||
plugin.getBackendProxyHandler().playerOffline(player.getName());
|
||||
}
|
||||
|
||||
final UUID serverUuid = player.getUniqueId();
|
||||
final String playerName = player.getName();
|
||||
plugin.getLoginTimer().execute(new Runnable() {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
VotingPluginMain.plugin.getAdvancedTab().remove(serverUuid);
|
||||
VotingPluginMain.plugin.getAdvancedTab().remove(player.getUniqueId());
|
||||
|
||||
// Use AdvancedCore UUIDLookup to derive the correct UUID (online/offline aware)
|
||||
String uuid = UuidLookup.getInstance().getUUID(playerName);
|
||||
String uuid = UuidLookup.getInstance().getUUID(player.getName());
|
||||
if (isBlank(uuid)) {
|
||||
// Fallback to online UUID if lookup fails
|
||||
uuid = serverUuid.toString();
|
||||
uuid = player.getUniqueId().toString();
|
||||
}
|
||||
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid);
|
||||
|
||||
+2
-26
@@ -35,9 +35,7 @@ public class PlayerVoteListener implements Listener {
|
||||
// Keep the production boundary defensive for direct/custom dispatchers
|
||||
// as well as Bukkit's Cancellable-aware event dispatcher.
|
||||
if (event.isCancelled()) return;
|
||||
BukkitOperations operations = new BukkitOperations(plugin, event);
|
||||
SharedVoteProcessor.process(operations);
|
||||
if (event.isNativeProcessingStarted()) event.setNativeProcessingCompleted(true);
|
||||
SharedVoteProcessor.process(new BukkitOperations(plugin, event));
|
||||
}
|
||||
|
||||
private static final class BukkitOperations implements SharedVoteProcessor.Operations<VoteSite, VotingPluginUser> {
|
||||
@@ -49,11 +47,7 @@ public class PlayerVoteListener implements Listener {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
@Override public boolean enabled() {
|
||||
boolean enabled = plugin.isEnabled();
|
||||
if (enabled) event.setNativeProcessingStarted(true);
|
||||
return enabled;
|
||||
}
|
||||
@Override public boolean enabled() { return plugin.isEnabled(); }
|
||||
@Override public String incomingName() { return event.getPlayer(); }
|
||||
@Override public String serviceSite() { return event.getServiceSite(); }
|
||||
@Override public void debug(String message) { plugin.debug(message); }
|
||||
@@ -137,7 +131,6 @@ public class PlayerVoteListener implements Listener {
|
||||
}
|
||||
@Override public boolean hasProxyTextTotals() { return event.getBungeeTextTotals() != null; }
|
||||
@Override public UUID proxyVoteId() { return resolveProxyVoteId(event); }
|
||||
@Override public UUID localOccurrenceId() { return event.getLocalOccurrenceId(); }
|
||||
@Override public boolean identifiedQueuedProxyVote() { return event.isQueuedProxyVote(); }
|
||||
@Override public boolean proxyQueueClassificationKnown() { return event.isProxyQueueClassificationKnown(); }
|
||||
@Override public boolean proxyDelayValidationKnown() { return event.isProxyDelayValidationKnown(); }
|
||||
@@ -183,20 +176,6 @@ public class PlayerVoteListener implements Listener {
|
||||
plugin.getVoteMilestonesManager().handleVote(user, event.getBungeeTextTotals(), forceProxyRouting,
|
||||
voteId, new HashMap<String, String>());
|
||||
}
|
||||
@Override public void dateMilestones(VotingPluginUser user, VoteSite site, long voteTime, UUID voteId) {
|
||||
long occurrenceTime = event.getCanonicalOccurrenceTime() == null
|
||||
? proxyVote() ? incomingTime() : voteTime : event.getCanonicalOccurrenceTime();
|
||||
if (com.bencodez.votingplugin.util.BukkitCompletionScheduler.isPlatformOwnedThread()) {
|
||||
// An asynchronous event flag does not identify the thread that dispatched it.
|
||||
// A proxy caller cannot acknowledge accounting that has only been admitted.
|
||||
event.setDateMilestoneAccountingFailed(!plugin.getDateVoteMilestones().deferAccepted(user, site.getKey(), voteId, occurrenceTime,
|
||||
realVote(), proxyVote(), event.getProxyVoteId() != null && occurrenceTime > 0,
|
||||
targetedProxyVote(), forceProxyRouting()));
|
||||
return;
|
||||
}
|
||||
if (!plugin.getDateVoteMilestones().accepted(user, site.getKey(), voteId, occurrenceTime, realVote(), proxyVote(),
|
||||
event.getProxyVoteId() != null && occurrenceTime > 0, targetedProxyVote(), forceProxyRouting())) event.setDateMilestoneAccountingFailed(true);
|
||||
}
|
||||
@Override public void cooldown(VotingPluginUser user, VoteSite site) { plugin.getCoolDownCheck().vote(user, site); }
|
||||
@Override public void voteStreak(VotingPluginUser user, long voteTime, UUID voteId) {
|
||||
plugin.getVoteStreakHandler().processVote(user, voteTime, voteId);
|
||||
@@ -205,9 +184,6 @@ 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);
|
||||
post.setProxyVoteId(event.getProxyVoteId());
|
||||
post.setCanonicalOccurrenceTime(event.getCanonicalOccurrenceTime() == null
|
||||
? event.isBungee() ? event.getTime() : voteTime : event.getCanonicalOccurrenceTime());
|
||||
plugin.getServer().getPluginManager().callEvent(post);
|
||||
}
|
||||
@Override public boolean placeholderCacheAlways() { return plugin.getConfigFile().getPlaceholderCacheLevel().isCacheAlways(); }
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.bencodez.votingplugin.listeners;
|
||||
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.UUID;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -20,133 +17,16 @@ import com.vexsoftware.votifier.model.VotifierEvent;
|
||||
|
||||
public class VotiferEvent implements Listener {
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final java.util.function.BooleanSupplier ready;
|
||||
private volatile boolean accepting = true;
|
||||
private static final int MAX_CAPTURED = VotifierVoteOverflowQueue.MAX_CAPTURED;
|
||||
private boolean retirementQueued;
|
||||
private final Object captureLock = new Object();
|
||||
// Receipt ownership only. Entries leave at completed processing or confirmed
|
||||
// overflow transfer; STARTED work remains owned until then.
|
||||
private final Map<UUID, CapturedVote> captured = new LinkedHashMap<>();
|
||||
private static final class CapturedVote {
|
||||
final String username, site;
|
||||
final long occurredAt;
|
||||
final UUID id;
|
||||
boolean transferring, transferFailureReported;
|
||||
volatile boolean started, completed, finished, transferred;
|
||||
VotifierVoteOverflowQueue recoveryOwner;
|
||||
CapturedVote(String username, String site, long occurredAt, UUID id) {
|
||||
this.username = username; this.site = site; this.occurredAt = occurredAt; this.id = id;
|
||||
}
|
||||
}
|
||||
private VotingPluginMain plugin;
|
||||
|
||||
/**
|
||||
* Instantiates a new votifer event.
|
||||
*
|
||||
* @param plugin the plugin
|
||||
*/
|
||||
public VotiferEvent(VotingPluginMain plugin) { this(plugin, () -> true); }
|
||||
|
||||
public VotiferEvent(VotingPluginMain plugin, java.util.function.BooleanSupplier ready) {
|
||||
this.plugin = plugin; this.ready = ready;
|
||||
}
|
||||
/** Retire admission and reconcile tasks before the vote executor is cancelled. */
|
||||
public void stop() {
|
||||
synchronized (captureLock) {
|
||||
accepting = false;
|
||||
if (retirementQueued) return;
|
||||
retirementQueued = true;
|
||||
var overflow = plugin.getVotifierVoteOverflowQueue();
|
||||
if (overflow == null) {
|
||||
for (CapturedVote vote : java.util.List.copyOf(captured.values())) transferLocked(vote);
|
||||
return;
|
||||
}
|
||||
var snapshot = captured.values().stream().map(vote -> new VotifierVoteOverflowQueue.RetiringCapture(
|
||||
vote.username, vote.site, vote.occurredAt, vote.id,
|
||||
() -> !vote.started || vote.finished && !vote.completed, () -> vote.completed,
|
||||
admitted -> transferred(vote, admitted))).toList();
|
||||
overflow.retireCaptures(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public int getPendingCaptureCount() {
|
||||
synchronized (captureLock) { return captured.size(); }
|
||||
}
|
||||
|
||||
private void runCaptured(CapturedVote vote) {
|
||||
synchronized (captureLock) {
|
||||
if (!captured.containsKey(vote.id) || vote.transferring || vote.started) return;
|
||||
if (!accepting || !ready.getAsBoolean()) {
|
||||
if (accepting || !retirementQueued) transferLocked(vote);
|
||||
return;
|
||||
}
|
||||
vote.started = true;
|
||||
}
|
||||
boolean completed = false;
|
||||
try { completed = processCapturedVote(vote.site, vote.username, vote.occurredAt, vote.id)
|
||||
&& !Thread.currentThread().isInterrupted(); }
|
||||
finally {
|
||||
VotifierVoteOverflowQueue acknowledge = null;
|
||||
synchronized (captureLock) {
|
||||
vote.completed = completed;
|
||||
vote.finished = true;
|
||||
if (completed) {
|
||||
captured.remove(vote.id);
|
||||
if (vote.transferred) acknowledge = vote.recoveryOwner;
|
||||
} else if (accepting && !vote.transferred && !vote.transferring) {
|
||||
// Retain the same occurrence and hand it to recovery after
|
||||
// the normal callback has finished, including partial failures.
|
||||
transferLocked(vote);
|
||||
}
|
||||
}
|
||||
if (completed && vote.recoveryOwner != null) vote.recoveryOwner.releaseCapture(vote.id);
|
||||
if (acknowledge != null) acknowledge.acknowledgeOccurrence(vote.id);
|
||||
else if (vote.transferred && vote.recoveryOwner != null) vote.recoveryOwner.resumeTransfers();
|
||||
}
|
||||
}
|
||||
|
||||
private void transferLocked(CapturedVote vote) {
|
||||
if (vote.transferring) return;
|
||||
vote.transferring = true;
|
||||
VotifierVoteOverflowQueue overflow = vote.recoveryOwner != null
|
||||
? vote.recoveryOwner : plugin.getVotifierVoteOverflowQueue();
|
||||
vote.recoveryOwner = overflow;
|
||||
if (overflow == null) { transferred(vote, false); return; }
|
||||
overflow.enqueueAfterInitialization(vote.username, vote.site, vote.occurredAt, vote.id,
|
||||
() -> !vote.started || vote.finished && !vote.completed,
|
||||
accepted -> transferred(vote, accepted));
|
||||
}
|
||||
|
||||
private void transferred(CapturedVote vote, boolean admitted) {
|
||||
boolean completed, retry, reportFailure;
|
||||
synchronized (captureLock) {
|
||||
if (vote.transferred && !admitted) return; // Older failure cannot revoke confirmed queue ownership.
|
||||
vote.transferred = admitted; completed = vote.completed;
|
||||
if (!admitted) vote.transferring = false;
|
||||
if (admitted || completed) captured.remove(vote.id);
|
||||
retry = !admitted && !completed && captured.get(vote.id) == vote;
|
||||
reportFailure = !admitted && !completed && !vote.transferFailureReported;
|
||||
if (reportFailure) vote.transferFailureReported = true;
|
||||
}
|
||||
if (admitted && completed) vote.recoveryOwner.acknowledgeOccurrence(vote.id);
|
||||
if (completed) return; // Current normal processing won; do not leave an ordinary duplicate retry.
|
||||
if (reportFailure) plugin.getLogger().severe("Votifier vote queue unavailable or full; "
|
||||
+ (retry ? "captured receipt retained for retry for " : "vote was not admitted for ")
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(vote.username));
|
||||
else if (admitted) plugin.getLogger().warning("Vote pipeline unavailable or retiring; queued Votifier vote for retry");
|
||||
if (retry && vote.recoveryOwner != null) vote.recoveryOwner.retryCaptureTransfers(this::retryCapturedTransfers);
|
||||
}
|
||||
|
||||
private void retryCapturedTransfers() {
|
||||
synchronized (captureLock) {
|
||||
for (CapturedVote vote : java.util.List.copyOf(captured.values())) {
|
||||
// Ordinary queued/running callbacks retain their original execution.
|
||||
// Only receipts which already attempted a failed handoff need retry.
|
||||
if (vote.transferFailureReported && !vote.transferring) transferLocked(vote);
|
||||
}
|
||||
}
|
||||
}
|
||||
public VotiferEvent(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a validated vote. The overflow queue invokes this callback from
|
||||
@@ -155,23 +35,7 @@ public class VotiferEvent implements Listener {
|
||||
* @param voteSite the validated service site
|
||||
* @param voteUsername the validated player name
|
||||
*/
|
||||
public void processVote(String voteSite, String voteUsername) { processVote(voteSite, voteUsername, 0L); }
|
||||
|
||||
public void processVote(String voteSite, String voteUsername, long occurredAt) {
|
||||
processVote(voteSite, voteUsername, occurredAt, UUID.randomUUID());
|
||||
}
|
||||
|
||||
public void processVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) {
|
||||
processCapturedVote(voteSite, voteUsername, occurredAt, localOccurrenceId);
|
||||
}
|
||||
|
||||
/** False retains overflow ownership; partial ordinary effects may still have occurred. */
|
||||
public boolean processVoteConfirmed(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) {
|
||||
return processCapturedVote(voteSite, voteUsername, occurredAt, localOccurrenceId);
|
||||
}
|
||||
|
||||
private boolean processCapturedVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) {
|
||||
java.util.Objects.requireNonNull(localOccurrenceId, "localOccurrenceId");
|
||||
public void processVote(String voteSite, String voteUsername) {
|
||||
try {
|
||||
plugin.getServerData().addServiceSite(voteSite);
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass()
|
||||
@@ -182,7 +46,7 @@ public class VotiferEvent implements Listener {
|
||||
|| plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) {
|
||||
plugin.getLogger().severe(
|
||||
"Ignoring vote from votifier since a proxy vote transport is enabled; receive votes on the proxy or enable VotifierBypass, then check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups");
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
String matchSite = "";
|
||||
@@ -213,29 +77,22 @@ 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, occurredAt, localOccurrenceId);
|
||||
return true;
|
||||
plugin.getTimeQueueHandler().addVote(voteUsername, voteSite);
|
||||
return;
|
||||
}
|
||||
|
||||
String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite);
|
||||
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true);
|
||||
voteEvent.setLocalOccurrenceId(localOccurrenceId);
|
||||
if (occurredAt > 0) voteEvent.setCanonicalOccurrenceTime(occurredAt);
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
|
||||
if (voteEvent.isCancelled()) {
|
||||
plugin.debug("Vote cancelled");
|
||||
}
|
||||
// Bukkit may skip disabled listeners or catch their exceptions while
|
||||
// returning normally. Dispatch alone is not confirmed processing.
|
||||
return voteEvent.isNativeProcessingCompleted()
|
||||
|| voteEvent.isCancelled() && !voteEvent.isNativeProcessingStarted();
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().severe("Error occured during vote processing");
|
||||
e.printStackTrace();
|
||||
return false; // An exception does not prove ordinary accounting/rewards completed.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,9 +103,6 @@ public class VotiferEvent implements Listener {
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
|
||||
public void onVotiferEvent(VotifierEvent event) {
|
||||
if (!accepting) return;
|
||||
final long occurredAt = System.currentTimeMillis();
|
||||
final UUID localOccurrenceId = UUID.randomUUID();
|
||||
|
||||
Vote vote = event.getVote();
|
||||
String str = vote.getServiceName();
|
||||
@@ -281,58 +135,16 @@ public class VotiferEvent implements Listener {
|
||||
plugin.debug("VoteSite: " + voteSite);
|
||||
plugin.debug("IP: " + IP);
|
||||
|
||||
CapturedVote capturedVote = new CapturedVote(voteUsername, voteSite, occurredAt, localOccurrenceId);
|
||||
VotifierVoteOverflowQueue.CaptureAdmission admission;
|
||||
synchronized (captureLock) {
|
||||
if (!accepting) return;
|
||||
var overflow = plugin.getVotifierVoteOverflowQueue();
|
||||
capturedVote.recoveryOwner = overflow;
|
||||
if (captured.size() >= MAX_CAPTURED) {
|
||||
// The overflow owns its independent bounded admission lane.
|
||||
transferLocked(capturedVote);
|
||||
return;
|
||||
}
|
||||
admission = overflow == null ? VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED
|
||||
: overflow.reserveCapture(localOccurrenceId);
|
||||
if (admission == VotifierVoteOverflowQueue.CaptureAdmission.REJECTED) {
|
||||
plugin.getLogger().severe("Votifier capture capacity unavailable; vote was not admitted");
|
||||
return;
|
||||
}
|
||||
// Reserve known capacity and publish capture ownership together with
|
||||
// respect to stop's snapshot. Unknown capacity remains provisional.
|
||||
captured.put(localOccurrenceId, capturedVote);
|
||||
}
|
||||
if (admission == VotifierVoteOverflowQueue.CaptureAdmission.WAITING_FOR_LOAD) {
|
||||
capturedVote.recoveryOwner.reserveCaptureAfterInitialization(localOccurrenceId,
|
||||
admitted -> admitCaptured(capturedVote, admitted));
|
||||
} else admitCaptured(capturedVote, true);
|
||||
try {
|
||||
plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue();
|
||||
if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) {
|
||||
plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for "
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(voteUsername));
|
||||
} else {
|
||||
plugin.getLogger().warning("Vote executor saturated; queued Votifier vote for retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void admitCaptured(CapturedVote vote, boolean admitted) {
|
||||
synchronized (captureLock) {
|
||||
if (captured.get(vote.id) != vote) {
|
||||
if (admitted && vote.recoveryOwner != null) vote.recoveryOwner.releaseCapture(vote.id);
|
||||
return;
|
||||
}
|
||||
if (!admitted) {
|
||||
captured.remove(vote.id);
|
||||
plugin.getLogger().severe("Votifier capture capacity unavailable; vote was not admitted");
|
||||
return;
|
||||
}
|
||||
if (!accepting) {
|
||||
if (!retirementQueued) transferLocked(vote);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
var timer = plugin.getVoteTimer();
|
||||
if (timer == null) {
|
||||
synchronized (captureLock) { transferLocked(vote); }
|
||||
return;
|
||||
}
|
||||
timer.submit(() -> runCaptured(vote));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
synchronized (captureLock) { transferLocked(vote); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-632
@@ -4,9 +4,6 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
@@ -20,15 +17,10 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
@@ -45,65 +37,22 @@ import com.bencodez.votingplugin.util.ServiceSiteValidator;
|
||||
public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
/* Keep the overflow bounded independently of the executor's 256 admissions. */
|
||||
private static final int MAX_ENTRIES = 256;
|
||||
public static final int MAX_CAPTURED = 256;
|
||||
private static final int MAX_RETAINED_ENTRIES = MAX_ENTRIES + MAX_CAPTURED;
|
||||
private static final long MAX_FILE_BYTES = 4L * 1024L * 1024L;
|
||||
private static final long RETRY_DELAY_MILLIS = 250L;
|
||||
private static final String QUEUE_FILE = "VotifierVoteQueue.yml";
|
||||
private static final String OWNER_FILE = "VotifierVoteQueue.yml.owner";
|
||||
private static final String SHARED_OWNER_PROPERTY_PREFIX = "com.bencodez.votingplugin.VotifierVoteOverflowQueue.owner.";
|
||||
private static final int MAX_OWNER_RESERVATIONS = 1024;
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
@FunctionalInterface
|
||||
public interface VoteProcessor { void process(String serviceSite, String username, long occurredAt); }
|
||||
@FunctionalInterface
|
||||
public interface OccurrenceProcessor {
|
||||
void process(String serviceSite, String username, long occurredAt, UUID localOccurrenceId);
|
||||
/** Legacy callbacks confirm completion by returning normally. */
|
||||
default boolean processConfirmed(String serviceSite, String username, long occurredAt, UUID localOccurrenceId) {
|
||||
process(serviceSite, username, occurredAt, localOccurrenceId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@FunctionalInterface
|
||||
public interface ConfirmedOccurrenceProcessor {
|
||||
boolean processConfirmed(String serviceSite, String username, long occurredAt, UUID localOccurrenceId);
|
||||
}
|
||||
private final OccurrenceProcessor processor;
|
||||
private final java.util.function.BooleanSupplier processingAllowed;
|
||||
private final BiConsumer<String, String> processor;
|
||||
private final Path file;
|
||||
private final Path ownerFile;
|
||||
private FileChannel ownerChannel;
|
||||
private FileLock ownerLock;
|
||||
private String ownerKey;
|
||||
private String ownerToken;
|
||||
private Properties ownerProperties;
|
||||
private final ScheduledThreadPoolExecutor worker;
|
||||
private final Object lock = new Object();
|
||||
private final Object persistenceWriteLock = new Object();
|
||||
private final ArrayDeque<PendingVote> entries = new ArrayDeque<>();
|
||||
private boolean drainScheduled;
|
||||
private boolean drainRetryScheduled;
|
||||
private boolean captureRetryScheduled;
|
||||
private boolean persistenceScheduled;
|
||||
private boolean persistenceDirty;
|
||||
private boolean started;
|
||||
private boolean closed;
|
||||
private boolean closing;
|
||||
private final java.util.concurrent.CompletableFuture<Void> closePublication = new java.util.concurrent.CompletableFuture<>();
|
||||
private boolean closeFailureReported;
|
||||
private volatile boolean loaded;
|
||||
private volatile boolean loadFailed;
|
||||
private int pendingIngress;
|
||||
private int pendingCaptureAdmissions;
|
||||
private int pendingRetirementCaptures;
|
||||
private boolean retirementRequested;
|
||||
private boolean initializationRetryScheduled;
|
||||
private boolean initializationFailureReported;
|
||||
private boolean queueFileObserved;
|
||||
private final Set<UUID> captureReservations = new java.util.HashSet<>();
|
||||
private final boolean asynchronousInitialization;
|
||||
private long stateVersion;
|
||||
private long durableVersion;
|
||||
|
||||
@@ -114,56 +63,16 @@ 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, user, occurredAt, id) -> processor.accept(site, user), () -> true);
|
||||
}
|
||||
|
||||
/** Additive callback retains receipt occurrence and fences a retired startup generation. */
|
||||
public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processor,
|
||||
java.util.function.BooleanSupplier processingAllowed) {
|
||||
this(plugin, (site, user, time, id) -> processor.process(site, user, time), processingAllowed);
|
||||
}
|
||||
|
||||
public VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor,
|
||||
java.util.function.BooleanSupplier processingAllowed) {
|
||||
this(plugin, processor, processingAllowed, false);
|
||||
}
|
||||
|
||||
/** Memory-only construction; all loading and handoffs run on the existing queue worker. */
|
||||
public static VotifierVoteOverflowQueue initializeAsync(VotingPluginMain plugin, OccurrenceProcessor processor,
|
||||
java.util.function.BooleanSupplier processingAllowed) {
|
||||
return new VotifierVoteOverflowQueue(plugin, processor, processingAllowed, true);
|
||||
}
|
||||
|
||||
/** Named factory preserves existing void callback/lambda contracts without ambiguous overloads. */
|
||||
public static VotifierVoteOverflowQueue initializeConfirmedAsync(VotingPluginMain plugin,
|
||||
ConfirmedOccurrenceProcessor processor, java.util.function.BooleanSupplier processingAllowed) {
|
||||
java.util.Objects.requireNonNull(processor, "processor");
|
||||
return initializeAsync(plugin, new OccurrenceProcessor() {
|
||||
@Override public void process(String site, String user, long time, UUID id) {
|
||||
processor.processConfirmed(site, user, time, id);
|
||||
}
|
||||
@Override public boolean processConfirmed(String site, String user, long time, UUID id) {
|
||||
return processor.processConfirmed(site, user, time, id);
|
||||
}
|
||||
}, processingAllowed);
|
||||
}
|
||||
|
||||
private VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor,
|
||||
java.util.function.BooleanSupplier processingAllowed, boolean asynchronousInitialization) {
|
||||
this.asynchronousInitialization = asynchronousInitialization;
|
||||
this.plugin = plugin;
|
||||
this.processor = processor;
|
||||
this.processingAllowed = processingAllowed;
|
||||
this.file = new File(plugin.getDataFolder(), QUEUE_FILE).toPath();
|
||||
this.ownerFile = new File(plugin.getDataFolder(), OWNER_FILE).toPath();
|
||||
this.worker = new ScheduledThreadPoolExecutor(1, runnable -> {
|
||||
Thread thread = new Thread(runnable, "VotingPlugin-Votifier-Overflow");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
this.worker.setRemoveOnCancelPolicy(true);
|
||||
if (asynchronousInitialization) worker.execute(this::load);
|
||||
else load();
|
||||
load();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,88 +97,10 @@ 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, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public boolean enqueue(String username, String serviceSite, long occurredAt) {
|
||||
return enqueue(username, serviceSite, occurredAt, UUID.randomUUID());
|
||||
}
|
||||
|
||||
public boolean enqueue(String username, String serviceSite, long occurredAt, UUID localOccurrenceId) {
|
||||
return enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, false);
|
||||
}
|
||||
|
||||
/** Transfer stays behind initial loading and is bounded independently of executor admissions. */
|
||||
public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId,
|
||||
Consumer<Boolean> completion) {
|
||||
enqueueAfterInitialization(username, serviceSite, occurredAt, localOccurrenceId, () -> true, completion);
|
||||
}
|
||||
|
||||
/** A STARTED original callback retains execution ownership until its outcome is known. */
|
||||
public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId,
|
||||
java.util.function.BooleanSupplier executionReleased, Consumer<Boolean> completion) {
|
||||
if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) {
|
||||
completion.accept(false);
|
||||
return;
|
||||
}
|
||||
var finished = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
class Transfer implements Runnable {
|
||||
void finish(boolean accepted) {
|
||||
if (!finished.compareAndSet(false, true)) return;
|
||||
synchronized (lock) { pendingIngress--; }
|
||||
completion.accept(accepted);
|
||||
}
|
||||
@Override public void run() {
|
||||
try {
|
||||
synchronized (lock) {
|
||||
if (!closed && !loaded) {
|
||||
// Admission owns this receipt, including the saturated
|
||||
// path which has no entry in the listener's capture map.
|
||||
scheduleInitializationRetryLocked();
|
||||
worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (RejectedExecutionException rejected) { finish(false); return; }
|
||||
boolean accepted = false;
|
||||
try { accepted = enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, true, executionReleased); }
|
||||
finally { finish(accepted); }
|
||||
}
|
||||
}
|
||||
boolean submitted = false;
|
||||
synchronized (lock) {
|
||||
if (!closed && !closing && pendingIngress < MAX_ENTRIES) {
|
||||
pendingIngress++;
|
||||
try {
|
||||
worker.execute(new Transfer());
|
||||
submitted = true;
|
||||
} catch (RejectedExecutionException rejected) { pendingIngress--; }
|
||||
}
|
||||
}
|
||||
if (!submitted) completion.accept(false);
|
||||
}
|
||||
|
||||
private boolean enqueueStored(String username, String serviceSite, long occurredAt, UUID localOccurrenceId,
|
||||
boolean admittedTransfer) {
|
||||
return enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, admittedTransfer, () -> true);
|
||||
}
|
||||
|
||||
private boolean enqueueStored(String username, String serviceSite, long occurredAt, UUID localOccurrenceId,
|
||||
boolean admittedTransfer, java.util.function.BooleanSupplier executionReleased) {
|
||||
if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) return false;
|
||||
if (username == null || serviceSite == null) return false;
|
||||
synchronized (lock) {
|
||||
if (closed || closing && !admittedTransfer || !loaded || loadFailed) return false;
|
||||
var existing = occurrence(localOccurrenceId);
|
||||
if (existing != null) {
|
||||
boolean same = matches(existing, username, serviceSite, occurredAt);
|
||||
if (same) captureReservations.remove(localOccurrenceId);
|
||||
return same;
|
||||
}
|
||||
int entryLimit = closing && admittedTransfer ? MAX_RETAINED_ENTRIES : MAX_ENTRIES;
|
||||
if (entries.size() >= entryLimit || !captureReservations.contains(localOccurrenceId)
|
||||
&& entries.size() + captureReservations.size() >= MAX_RETAINED_ENTRIES) return false;
|
||||
entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId, executionReleased));
|
||||
captureReservations.remove(localOccurrenceId);
|
||||
if (closed || entries.size() >= MAX_ENTRIES) return false;
|
||||
entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis()));
|
||||
stateVersion++;
|
||||
requestPersistenceLocked();
|
||||
scheduleDrainLocked();
|
||||
@@ -277,162 +108,6 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum CaptureAdmission { ACCEPTED, WAITING_FOR_LOAD, REJECTED }
|
||||
|
||||
/** Reserve final-snapshot capacity before the normal executor owns this receipt. */
|
||||
public CaptureAdmission reserveCapture(UUID id) {
|
||||
java.util.Objects.requireNonNull(id);
|
||||
synchronized (lock) {
|
||||
if (closed || closing || loadFailed) return CaptureAdmission.REJECTED;
|
||||
if (!loaded) return CaptureAdmission.WAITING_FOR_LOAD;
|
||||
if (captureReservations.contains(id)) return CaptureAdmission.ACCEPTED;
|
||||
if (captureReservations.size() >= MAX_CAPTURED
|
||||
|| entries.size() + captureReservations.size() >= MAX_RETAINED_ENTRIES)
|
||||
return CaptureAdmission.REJECTED;
|
||||
captureReservations.add(id);
|
||||
return CaptureAdmission.ACCEPTED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unknown disk capacity is provisional; keep at most one deferred admission per capture. */
|
||||
public void reserveCaptureAfterInitialization(UUID id, Consumer<Boolean> completion) {
|
||||
java.util.Objects.requireNonNull(id); java.util.Objects.requireNonNull(completion);
|
||||
boolean admitted;
|
||||
synchronized (lock) {
|
||||
admitted = !closed && !closing && pendingCaptureAdmissions < MAX_CAPTURED;
|
||||
if (admitted) pendingCaptureAdmissions++;
|
||||
}
|
||||
if (!admitted) { completion.accept(false); return; }
|
||||
var finished = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
class Admission implements Runnable {
|
||||
void finish(boolean accepted) {
|
||||
if (!finished.compareAndSet(false, true)) return;
|
||||
synchronized (lock) { pendingCaptureAdmissions--; }
|
||||
completion.accept(accepted);
|
||||
}
|
||||
@Override public void run() {
|
||||
var result = reserveCapture(id);
|
||||
if (result != CaptureAdmission.WAITING_FOR_LOAD) {
|
||||
finish(result == CaptureAdmission.ACCEPTED);
|
||||
return;
|
||||
}
|
||||
try { worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); }
|
||||
catch (RejectedExecutionException rejected) { finish(false); }
|
||||
}
|
||||
}
|
||||
var admission = new Admission();
|
||||
try { worker.execute(admission); }
|
||||
catch (RejectedExecutionException rejected) { admission.finish(false); }
|
||||
}
|
||||
|
||||
public void releaseCapture(UUID id) {
|
||||
synchronized (lock) { captureReservations.remove(id); }
|
||||
}
|
||||
|
||||
public record RetiringCapture(String username, String serviceSite, long time, UUID id,
|
||||
java.util.function.BooleanSupplier executionReleased,
|
||||
java.util.function.BooleanSupplier completed, Consumer<Boolean> completion) {
|
||||
public RetiringCapture {
|
||||
java.util.Objects.requireNonNull(username); java.util.Objects.requireNonNull(serviceSite);
|
||||
java.util.Objects.requireNonNull(id); java.util.Objects.requireNonNull(executionReleased);
|
||||
java.util.Objects.requireNonNull(completed); java.util.Objects.requireNonNull(completion);
|
||||
if (time <= 0) throw new IllegalArgumentException("capture receipt time must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
/** One FIFO batch, before close's final snapshot; normal overflow admission stays capped at256. */
|
||||
public void retireCaptures(List<RetiringCapture> captures) {
|
||||
var snapshot = List.copyOf(captures);
|
||||
if (snapshot.size() > MAX_CAPTURED) throw new IllegalArgumentException("capture retirement exceeds capacity");
|
||||
if (snapshot.isEmpty()) return;
|
||||
class Transfer implements Runnable {
|
||||
@Override public void run() {
|
||||
synchronized (lock) {
|
||||
if (!closed && (!loaded || loadFailed)) {
|
||||
scheduleInitializationRetryLocked();
|
||||
worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
boolean[] results = new boolean[snapshot.size()];
|
||||
try {
|
||||
for (int index = 0; index < snapshot.size(); index++) {
|
||||
var capture = snapshot.get(index);
|
||||
boolean completed = false;
|
||||
try { completed = capture.completed().getAsBoolean(); }
|
||||
catch (RuntimeException | Error unknown) { /* Retain unconfirmed completion. */ }
|
||||
synchronized (lock) {
|
||||
if (!closed && loaded && !loadFailed) {
|
||||
var existing = occurrence(capture.id());
|
||||
if (completed) {
|
||||
captureReservations.remove(capture.id());
|
||||
results[index] = true;
|
||||
} else if (existing != null) {
|
||||
results[index] = matches(existing, capture.username(), capture.serviceSite(), capture.time());
|
||||
if (results[index]) captureReservations.remove(capture.id());
|
||||
} else if (entries.size() < MAX_RETAINED_ENTRIES
|
||||
&& (captureReservations.contains(capture.id())
|
||||
|| entries.size() + captureReservations.size() < MAX_RETAINED_ENTRIES)) {
|
||||
entries.addLast(new PendingVote(capture.username(), capture.serviceSite(), capture.time(),
|
||||
capture.id(), capture.executionReleased()));
|
||||
captureReservations.remove(capture.id());
|
||||
stateVersion++; requestPersistenceLocked();
|
||||
results[index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retain the whole batch before any external completion callback.
|
||||
for (int index = 0; index < snapshot.size(); index++) {
|
||||
try { snapshot.get(index).completion().accept(results[index]); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { plugin.getLogger().warning("Votifier retirement callback failed; queue ownership is retained"); }
|
||||
catch (RuntimeException | Error ignored) { }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
synchronized (lock) { pendingRetirementCaptures -= snapshot.size(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean submitted = false;
|
||||
synchronized (lock) {
|
||||
if (!closed && !closing && pendingRetirementCaptures + snapshot.size() <= MAX_CAPTURED) {
|
||||
pendingRetirementCaptures += snapshot.size();
|
||||
try { worker.execute(new Transfer()); retirementRequested = true; submitted = true; }
|
||||
catch (RejectedExecutionException rejected) { pendingRetirementCaptures -= snapshot.size(); }
|
||||
}
|
||||
}
|
||||
if (!submitted) for (var capture : snapshot) {
|
||||
try { capture.completion().accept(false); }
|
||||
catch (RuntimeException | Error ignored) { /* Reject every bounded caller, including the suffix. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleInitializationRetryLocked() {
|
||||
if (closed || initializationRetryScheduled || loaded && !loadFailed) return;
|
||||
initializationRetryScheduled = true;
|
||||
worker.schedule(() -> {
|
||||
synchronized (lock) {
|
||||
initializationRetryScheduled = false;
|
||||
if (closed) return;
|
||||
}
|
||||
load();
|
||||
}, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private PendingVote occurrence(UUID id) {
|
||||
for (var vote : entries) if (vote.localOccurrenceId.equals(id)) return vote;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean matches(PendingVote vote, String username, String site, long time) {
|
||||
return vote.username.equals(username) && vote.serviceSite.equals(site) && vote.time == time;
|
||||
}
|
||||
|
||||
/** Completion only; transfers separately report load/capacity admission failure. */
|
||||
public boolean isInitializationComplete() { return loaded; }
|
||||
|
||||
/**
|
||||
* Returns the number of votes waiting for admission or completion.
|
||||
*
|
||||
@@ -451,7 +126,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void scheduleDrainLocked() {
|
||||
if (closed || closing || !loaded || loadFailed || !started || drainScheduled) return;
|
||||
if (closed || !started || drainScheduled) return;
|
||||
drainScheduled = true;
|
||||
try {
|
||||
worker.schedule(this::drain, 0, TimeUnit.MILLISECONDS);
|
||||
@@ -462,14 +137,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
|
||||
private void drain() {
|
||||
while (true) {
|
||||
// A generation fence parks its receipts instead of repeatedly submitting
|
||||
// callbacks which are forbidden to execute (including inline submitters).
|
||||
if (!processingAllowed.getAsBoolean()) {
|
||||
synchronized (lock) { drainScheduled = false; }
|
||||
return;
|
||||
}
|
||||
synchronized (lock) {
|
||||
if (closed || closing || !loaded || loadFailed) { drainScheduled = false; return; }
|
||||
if (durableVersion != stateVersion) {
|
||||
drainScheduled = false;
|
||||
return;
|
||||
@@ -484,23 +152,10 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
// Serialize admission with enqueue so the version proven durable
|
||||
// above cannot change in the gap before submit accepts this vote.
|
||||
plugin.getVoteTimer().submit(() -> {
|
||||
boolean attempted = false;
|
||||
boolean completed = false;
|
||||
try {
|
||||
if (!processingAllowed.getAsBoolean()) return;
|
||||
synchronized (lock) {
|
||||
if (closed || closing || !entries.contains(pending)) return;
|
||||
}
|
||||
attempted = true;
|
||||
completed = processor.processConfirmed(pending.serviceSite, pending.username,
|
||||
pending.time, pending.localOccurrenceId) && !Thread.currentThread().isInterrupted();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
plugin.getLogger().warning("Queued Votifier processing failed; retained receipt for retry: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
throw failure;
|
||||
processor.accept(pending.serviceSite, pending.username);
|
||||
} finally {
|
||||
if (completed) acknowledge(pending);
|
||||
else retainFailedAttempt(pending, attempted);
|
||||
acknowledge(pending);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
@@ -513,102 +168,27 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
// The queue is closing; the persisted entry remains for restart.
|
||||
}
|
||||
return;
|
||||
} catch (RuntimeException | Error failure) {
|
||||
// An inline submitter can expose callback failure on this worker.
|
||||
// Retain retry ownership without leaving drainScheduled stuck true.
|
||||
drainScheduled = false;
|
||||
retainFailedAttempt(pending, true);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PendingVote nextUnsubmittedLocked() {
|
||||
long now = System.nanoTime();
|
||||
boolean retryWaiting = false;
|
||||
for (PendingVote pending : entries) {
|
||||
if (pending.submitted || !pending.executionReleased.getAsBoolean()) continue;
|
||||
if (pending.retryNotBeforeNanos != 0 && now - pending.retryNotBeforeNanos < 0) {
|
||||
retryWaiting = true;
|
||||
continue;
|
||||
}
|
||||
return pending;
|
||||
if (!pending.submitted) return pending;
|
||||
}
|
||||
if (retryWaiting) scheduleDrainRetryLocked();
|
||||
return null;
|
||||
}
|
||||
|
||||
private void retainFailedAttempt(PendingVote pending, boolean attempted) {
|
||||
synchronized (lock) {
|
||||
pending.submitted = false;
|
||||
if (closed || closing || !entries.contains(pending) || !attempted) return;
|
||||
// Inline submitters must not retry this same receipt in the current drain.
|
||||
pending.retryNotBeforeNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RETRY_DELAY_MILLIS);
|
||||
scheduleDrainRetryLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleDrainRetryLocked() {
|
||||
if (closed || closing || !loaded || loadFailed || !started || drainRetryScheduled) return;
|
||||
drainRetryScheduled = true;
|
||||
try {
|
||||
worker.schedule(() -> {
|
||||
synchronized (lock) {
|
||||
drainRetryScheduled = false;
|
||||
scheduleDrainLocked();
|
||||
}
|
||||
}, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
} catch (RejectedExecutionException rejected) { drainRetryScheduled = false; }
|
||||
}
|
||||
|
||||
private void acknowledge(PendingVote pending) {
|
||||
synchronized (lock) {
|
||||
if (closed) {
|
||||
plugin.getLogger().warning("Completed Votifier callback outlived queue shutdown; retained occurrence requires reconciliation");
|
||||
return;
|
||||
}
|
||||
if (!entries.remove(pending)) return;
|
||||
entries.remove(pending);
|
||||
stateVersion++;
|
||||
requestPersistenceLocked();
|
||||
scheduleDrainLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile a receipt whose original normal callback completed during transfer. */
|
||||
public void acknowledgeOccurrence(UUID localOccurrenceId) {
|
||||
synchronized (lock) {
|
||||
if (closed) {
|
||||
// A nonterminating callback outlived the final snapshot. A retired
|
||||
// writer cannot overwrite a successor's queue; retain uncertainty.
|
||||
plugin.getLogger().warning("Completed Votifier callback outlived queue shutdown; retained occurrence requires reconciliation");
|
||||
return;
|
||||
}
|
||||
if (!entries.removeIf(pending -> pending.localOccurrenceId.equals(localOccurrenceId))) return;
|
||||
stateVersion++; requestPersistenceLocked(); scheduleDrainLocked();
|
||||
}
|
||||
}
|
||||
/** Wake the same bounded drain when an original transferred callback finishes uncertainly. */
|
||||
public void resumeTransfers() { scheduleDrain(); }
|
||||
|
||||
/** One wakeup for the ingress listener's bounded failed-transfer captures.
|
||||
* Retry runs outside the queue monitor and never adds another executor or waiter list. */
|
||||
public void retryCaptureTransfers(Runnable retry) {
|
||||
synchronized (lock) {
|
||||
if (closed || closing || captureRetryScheduled) return;
|
||||
captureRetryScheduled = true;
|
||||
try {
|
||||
worker.schedule(() -> {
|
||||
synchronized (lock) {
|
||||
captureRetryScheduled = false;
|
||||
if (closed || closing) return;
|
||||
}
|
||||
retry.run();
|
||||
}, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
} catch (RejectedExecutionException rejected) { captureRetryScheduled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
private void requestPersistenceLocked() {
|
||||
persistenceDirty = true;
|
||||
if (persistenceScheduled) return;
|
||||
@@ -659,32 +239,18 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void load() {
|
||||
ArrayDeque<PendingVote> recovered = new ArrayDeque<>();
|
||||
boolean successful = false;
|
||||
try {
|
||||
synchronized (lock) { if (closed || loaded && !loadFailed) return; }
|
||||
if (!acquireOwner()) {
|
||||
synchronized (lock) {
|
||||
if (asynchronousInitialization && !closing || pendingRetirementCaptures > 0 || pendingIngress > 0) {
|
||||
scheduleInitializationRetryLocked();
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new IOException("queue file is owned by another active instance");
|
||||
}
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (queueFileObserved) throw new IOException("queue file disappeared before recovery");
|
||||
successful = true;
|
||||
return;
|
||||
}
|
||||
queueFileObserved = true;
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.loadFromString(readQueueFile());
|
||||
Object raw = yaml.get("Votes");
|
||||
if (!(raw instanceof List<?> values)) throw new IOException("queue votes are not a list");
|
||||
if (values.size() > MAX_RETAINED_ENTRIES) throw new IOException("queue exceeds entry capacity");
|
||||
if (!(raw instanceof List<?> values)) return;
|
||||
boolean skipped = false;
|
||||
for (Object value : values) {
|
||||
if (entries.size() >= MAX_ENTRIES) {
|
||||
skipped = true;
|
||||
break;
|
||||
}
|
||||
if (!(value instanceof Map<?, ?> map)) {
|
||||
skipped = true;
|
||||
continue;
|
||||
@@ -699,111 +265,16 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
UUID localOccurrenceId;
|
||||
if (!map.containsKey("LocalOccurrenceId")) {
|
||||
localOccurrenceId = UUID.randomUUID();
|
||||
skipped = true; // Publish the migration before admitting any recovered row.
|
||||
} else {
|
||||
Object rawId = map.get("LocalOccurrenceId");
|
||||
try {
|
||||
if (!(rawId instanceof String id)) throw new IllegalArgumentException();
|
||||
localOccurrenceId = UUID.fromString(id);
|
||||
if (!localOccurrenceId.toString().equalsIgnoreCase(id)) throw new IllegalArgumentException();
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
skipped = true;
|
||||
continue; // Explicit corrupt IDs cannot become fresh occurrences.
|
||||
}
|
||||
}
|
||||
recovered.addLast(new PendingVote(name, site, timestamp.longValue(), localOccurrenceId));
|
||||
entries.addLast(new PendingVote(name, site, timestamp.longValue()));
|
||||
}
|
||||
synchronized (lock) { entries.clear(); entries.addAll(recovered); }
|
||||
if (skipped) {
|
||||
synchronized (lock) {
|
||||
stateVersion++;
|
||||
requestPersistenceLocked();
|
||||
}
|
||||
}
|
||||
successful = true;
|
||||
} catch (Exception failure) {
|
||||
loadFailed = true;
|
||||
if (!initializationFailureReported) {
|
||||
initializationFailureReported = true;
|
||||
plugin.getLogger().warning("Unable to load queued Votifier votes: " + failure.getClass().getSimpleName());
|
||||
}
|
||||
} finally {
|
||||
if (ownerLock == null && !loadFailed) return;
|
||||
if (successful) loadFailed = false;
|
||||
loaded = true;
|
||||
synchronized (lock) {
|
||||
scheduleDrainLocked();
|
||||
if (loadFailed && pendingRetirementCaptures > 0) scheduleInitializationRetryLocked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean acquireOwner() throws IOException {
|
||||
if (ownerLock != null && ownerLock.isValid()) return true;
|
||||
Path parent = ownerFile.toAbsolutePath().normalize().getParent();
|
||||
if (parent == null || Files.isSymbolicLink(ownerFile)) throw new IOException("queue ownership file is unsafe");
|
||||
Files.createDirectories(parent);
|
||||
String key = SHARED_OWNER_PROPERTY_PREFIX + hashOwnerPath(parent.toRealPath().resolve(ownerFile.getFileName()));
|
||||
String token = UUID.randomUUID().toString();
|
||||
Properties properties = System.getProperties();
|
||||
synchronized (properties) {
|
||||
if (properties.getProperty(key) != null) return false;
|
||||
int reservations = 0;
|
||||
for (Object property : properties.keySet())
|
||||
if (property instanceof String name && name.startsWith(SHARED_OWNER_PROPERTY_PREFIX)) reservations++;
|
||||
if (reservations >= MAX_OWNER_RESERVATIONS) return false;
|
||||
properties.setProperty(key, token);
|
||||
}
|
||||
try {
|
||||
FileChannel channel = FileChannel.open(ownerFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
try {
|
||||
FileLock acquired;
|
||||
try { acquired = channel.tryLock(); }
|
||||
catch (OverlappingFileLockException busy) { acquired = null; }
|
||||
if (acquired == null) { channel.close(); clearOwnerReservation(properties, key, token); return false; }
|
||||
ownerKey = key; ownerToken = token; ownerProperties = properties; ownerChannel = channel; ownerLock = acquired;
|
||||
return true;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
channel.close();
|
||||
clearOwnerReservation(properties, key, token);
|
||||
throw failure;
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
clearOwnerReservation(properties, key, token);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseOwner() {
|
||||
try { if (ownerLock != null) ownerLock.release(); } catch (IOException ignored) { }
|
||||
try { if (ownerChannel != null) ownerChannel.close(); } catch (IOException ignored) { }
|
||||
clearOwnerReservation(ownerProperties, ownerKey, ownerToken);
|
||||
ownerLock = null;
|
||||
ownerChannel = null;
|
||||
ownerKey = null;
|
||||
ownerToken = null;
|
||||
ownerProperties = null;
|
||||
}
|
||||
|
||||
private static void clearOwnerReservation(Properties properties, String key, String token) {
|
||||
if (properties == null || key == null || token == null) return;
|
||||
synchronized (properties) {
|
||||
if (token.equals(properties.getProperty(key))) properties.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static String hashOwnerPath(Path path) throws IOException {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(path.toString().getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder(digest.length * 2);
|
||||
for (byte value : digest) result.append(String.format("%02x", value));
|
||||
return result.toString();
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IOException("SHA-256 unavailable", impossible);
|
||||
plugin.getLogger().warning("Unable to load queued Votifier votes: " + failure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,8 +298,6 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void writeSnapshotLocked(List<PendingVote> snapshot) throws IOException {
|
||||
if (ownerLock == null || !ownerLock.isValid()) throw new IOException("queue file ownership is unavailable");
|
||||
if (snapshot.size() > MAX_RETAINED_ENTRIES) throw new IOException("queue snapshot exceeds entry capacity");
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
List<Map<String, Object>> values = new ArrayList<>();
|
||||
for (PendingVote pending : snapshot) {
|
||||
@@ -836,7 +305,6 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
value.put("Username", pending.username);
|
||||
value.put("ServiceSite", pending.serviceSite);
|
||||
value.put("Time", pending.time);
|
||||
value.put("LocalOccurrenceId", pending.localOccurrenceId.toString());
|
||||
values.add(value);
|
||||
}
|
||||
yaml.set("Votes", values);
|
||||
@@ -862,26 +330,21 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
List<PendingVote> snapshot = null;
|
||||
boolean initializedWorker;
|
||||
List<PendingVote> snapshot;
|
||||
synchronized (lock) {
|
||||
initializedWorker = asynchronousInitialization || retirementRequested || pendingIngress > 0;
|
||||
if (!initializedWorker) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
// Legacy callers keep their existing close path until retirement admission.
|
||||
snapshot = new ArrayList<>(entries);
|
||||
persistenceScheduled = false;
|
||||
}
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
// At-least-once delivery: retain any unacknowledged callback. A callback
|
||||
// interrupted during shutdown must be replayed rather than silently lost.
|
||||
snapshot = new ArrayList<>(entries);
|
||||
persistenceScheduled = false;
|
||||
}
|
||||
if (initializedWorker) { closeInitializedWorker(); return; }
|
||||
worker.shutdownNow();
|
||||
try {
|
||||
worker.awaitTermination(1, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
if (loadFailed) { releaseOwner(); return; }
|
||||
try {
|
||||
synchronized (persistenceWriteLock) {
|
||||
writeSnapshotLocked(snapshot);
|
||||
@@ -890,87 +353,18 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
plugin.getLogger().warning("Unable to persist queued Votifier votes during shutdown: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
}
|
||||
releaseOwner();
|
||||
}
|
||||
|
||||
private void closeInitializedWorker() {
|
||||
synchronized (lock) {
|
||||
if (!closing && !closed) {
|
||||
closing = true;
|
||||
// Keep the existing worker and file owner through failed final publication.
|
||||
worker.execute(this::finishClosePublication);
|
||||
}
|
||||
}
|
||||
try { closePublication.get(1, TimeUnit.SECONDS); }
|
||||
catch (java.util.concurrent.TimeoutException pending) {
|
||||
plugin.getLogger().warning("Votifier queue shutdown publication is still pending on its worker; recovery is unconfirmed");
|
||||
} catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
|
||||
catch (java.util.concurrent.ExecutionException failure) {
|
||||
throw new IllegalStateException("Votifier queue shutdown publication failed", failure.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private void finishClosePublication() {
|
||||
List<PendingVote> snapshot;
|
||||
long version;
|
||||
synchronized (lock) {
|
||||
if (closed) return;
|
||||
if (pendingRetirementCaptures > 0 || pendingIngress > 0) {
|
||||
if (!loaded || loadFailed) scheduleInitializationRetryLocked();
|
||||
worker.schedule(this::finishClosePublication, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
return;
|
||||
}
|
||||
snapshot = new ArrayList<>(entries);
|
||||
version = stateVersion;
|
||||
}
|
||||
if (loadFailed || !loaded) {
|
||||
// Unreadable/unowned storage is unknown; never replace it with an empty guess.
|
||||
synchronized (lock) { closed = true; }
|
||||
releaseOwner(); worker.shutdown(); closePublication.complete(null);
|
||||
return;
|
||||
}
|
||||
try { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); } }
|
||||
catch (IOException failure) {
|
||||
if (!closeFailureReported) {
|
||||
closeFailureReported = true;
|
||||
try { plugin.getLogger().warning("Unable to publish final Votifier queue; its worker retains ownership for retry"); }
|
||||
catch (RuntimeException | Error ignored) { }
|
||||
}
|
||||
worker.schedule(this::finishClosePublication, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
return;
|
||||
}
|
||||
synchronized (lock) {
|
||||
// A confirmed original callback may have removed a receipt during IO.
|
||||
if (version != stateVersion) {
|
||||
worker.execute(this::finishClosePublication);
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
durableVersion = version;
|
||||
}
|
||||
releaseOwner(); worker.shutdown(); closePublication.complete(null);
|
||||
}
|
||||
|
||||
private static final class PendingVote {
|
||||
private final String username;
|
||||
private final String serviceSite;
|
||||
private final long time;
|
||||
private final UUID localOccurrenceId;
|
||||
private final java.util.function.BooleanSupplier executionReleased;
|
||||
private boolean submitted;
|
||||
private long retryNotBeforeNanos;
|
||||
|
||||
private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId) {
|
||||
this(username, serviceSite, time, localOccurrenceId, () -> true);
|
||||
}
|
||||
|
||||
private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId,
|
||||
java.util.function.BooleanSupplier executionReleased) {
|
||||
private PendingVote(String username, String serviceSite, long time) {
|
||||
this.username = username;
|
||||
this.serviceSite = serviceSite;
|
||||
this.time = time;
|
||||
this.localOccurrenceId = localOccurrenceId;
|
||||
this.executionReleased = java.util.Objects.requireNonNull(executionReleased);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-267
@@ -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;
|
||||
@@ -132,8 +130,6 @@ public class PlaceHolders {
|
||||
public String getPlaceHolder(OfflinePlayer p, String identifier1, boolean javascript) {
|
||||
boolean forceProcess = false;
|
||||
boolean useCache = true;
|
||||
if (identifier1.regionMatches(true, 0, "DateVoteMilestones_", 0, "DateVoteMilestones_".length()))
|
||||
return getPlaceholderValue(p, identifier1, false, true, false);
|
||||
String identifier = identifier1.toLowerCase();
|
||||
boolean custom = false;
|
||||
if (identifier.endsWith("_process")
|
||||
@@ -172,9 +168,7 @@ public class PlaceHolders {
|
||||
* @param identifier the placeholder identifier
|
||||
* @return the placeholder value
|
||||
*/
|
||||
public String getPlaceHolder(Player p, String identifier) {
|
||||
if (identifier.regionMatches(true, 0, "DateVoteMilestones_", 0, "DateVoteMilestones_".length()))
|
||||
return getPlaceholderValue(p, identifier, false, true, false);
|
||||
public String getPlaceHolder(Player p, String identifier) {
|
||||
if (plugin.getConfigFile().isUseJavascriptPlaceholders()) {
|
||||
identifier = PlaceholderUtils.replaceJavascript(p, identifier);
|
||||
}
|
||||
@@ -191,118 +185,7 @@ 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) {
|
||||
// This family owns a bounded snapshot cache and preserves case-sensitive event IDs.
|
||||
// Bypass legacy cache enrollment/name resolution, which can perform storage work.
|
||||
String datePrefix = "DateVoteMilestones_";
|
||||
if (identifier.regionMatches(true, 0, datePrefix, 0, datePrefix.length())) {
|
||||
if (p == null) return "no player";
|
||||
var manager = plugin.getDateVoteMilestones();
|
||||
if (manager == null) return "unavailable";
|
||||
if (p instanceof Player online) {
|
||||
UUID storageId = plugin.getPlaceholderPlayerPresence().storageUuid(online);
|
||||
return storageId == null ? "unavailable" : manager.placeholder(storageId, identifier.substring(datePrefix.length()));
|
||||
}
|
||||
UUID playerId = p.getUniqueId();
|
||||
return playerId == null ? "unavailable" : manager.placeholder(playerId, p::getName, identifier.substring(datePrefix.length()));
|
||||
}
|
||||
if (plugin.getConfigFile().isUseJavascriptPlaceholders() && javascript && p != null) {
|
||||
identifier = PlaceholderUtils.replaceJavascript(p, identifier);
|
||||
}
|
||||
@@ -322,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)) {
|
||||
@@ -543,14 +420,6 @@ public class PlaceHolders {
|
||||
}
|
||||
}.withDescription("Best monthly total").updateDataKey("HighestMonthlyTotal"));
|
||||
|
||||
placeholders.add(new PlaceHolder<VotingPluginUser>("DateVoteMilestones_") {
|
||||
@Override public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
var manager = plugin.getDateVoteMilestones();
|
||||
return manager == null || user == null ? "unavailable"
|
||||
: manager.placeholder(user.getJavaUUID(), identifier.substring("DateVoteMilestones_".length()));
|
||||
}
|
||||
}.withDescription("Date event votes/next/status/award snapshots; loading and unavailable are distinct").useStartsWith());
|
||||
|
||||
placeholders.add(new PlaceHolder<VotingPluginUser>("DailyVoteStreak") {
|
||||
|
||||
@Override
|
||||
@@ -623,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"));
|
||||
|
||||
@@ -633,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"));
|
||||
|
||||
@@ -742,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;
|
||||
}
|
||||
@@ -760,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";
|
||||
@@ -840,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();
|
||||
}
|
||||
@@ -859,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();
|
||||
}
|
||||
@@ -875,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;
|
||||
}
|
||||
@@ -892,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;
|
||||
}
|
||||
@@ -910,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();
|
||||
}
|
||||
@@ -926,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();
|
||||
}
|
||||
@@ -947,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();
|
||||
}
|
||||
@@ -963,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();
|
||||
}
|
||||
@@ -979,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;
|
||||
}
|
||||
@@ -998,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();
|
||||
}
|
||||
@@ -1014,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();
|
||||
}
|
||||
@@ -1030,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;
|
||||
}
|
||||
@@ -1049,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();
|
||||
}
|
||||
@@ -1073,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();
|
||||
}
|
||||
@@ -1089,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"));
|
||||
|
||||
@@ -1097,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"));
|
||||
|
||||
@@ -1106,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"));
|
||||
|
||||
@@ -1544,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;
|
||||
@@ -1571,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;
|
||||
@@ -1584,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,
|
||||
@@ -1611,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
|
||||
@@ -1702,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<>();
|
||||
@@ -1749,8 +1551,6 @@ public class PlaceHolders {
|
||||
* Reload placeholders.
|
||||
*/
|
||||
public void reload() {
|
||||
pointFence.invalidateLifecycle();
|
||||
pointRecoveryWorkers.clear();
|
||||
platformUpdateGenerations.clear();
|
||||
plugin.refreshPlaceholderPlayerPresence();
|
||||
cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel();
|
||||
@@ -1784,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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
-106
@@ -7,8 +7,6 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
@@ -21,97 +19,6 @@ import org.bukkit.entity.Player;
|
||||
public final class PlaceholderPlayerPresence {
|
||||
private final AtomicReference<Map<UUID, Player>> onlinePlayers = new AtomicReference<>(Map.of());
|
||||
private final Object lifecycleLock = new Object();
|
||||
private final Map<Player, LiveIdentity> identities = new IdentityHashMap<>();
|
||||
private final Map<Player, Object> refreshReservations = new IdentityHashMap<>();
|
||||
|
||||
/** Immutable identity; lookup callers never need to access its Bukkit owner. */
|
||||
public record LiveIdentity(UUID serverUuid, String playerName, UUID storageUuid) { }
|
||||
|
||||
/** Capture only on this player's owning thread (join or entity scheduler). */
|
||||
public void captureOnline(UUID storageUuid, Player player) {
|
||||
if (storageUuid == null || player == null) return;
|
||||
LiveIdentity identity = new LiveIdentity(player.getUniqueId(), player.getName(), storageUuid);
|
||||
synchronized (lifecycleLock) {
|
||||
playerOnline(storageUuid, player);
|
||||
identities.put(player, identity);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rekey only the current owner. Delayed login cannot resurrect a retired session. */
|
||||
public boolean updateStorageUuid(UUID uuid, Player player) {
|
||||
if (uuid == null || player == null) return false;
|
||||
synchronized (lifecycleLock) {
|
||||
if (storageUuid(player) == null) return false;
|
||||
Player currentOwner = onlinePlayers.get().get(uuid);
|
||||
if (currentOwner != null && currentOwner != player) return false;
|
||||
LiveIdentity identity = identities.get(player);
|
||||
playerOnline(uuid, player);
|
||||
if (identity != null) identities.put(player,
|
||||
new LiveIdentity(identity.serverUuid(), identity.playerName(), uuid));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot-only lookup, with exact storage identities taking precedence over aliases. */
|
||||
public LiveIdentity liveIdentity(UUID uuid) {
|
||||
if (uuid == null) return null;
|
||||
synchronized (lifecycleLock) {
|
||||
Player exact = onlinePlayers.get().get(uuid);
|
||||
if (exact != null) return identities.get(exact);
|
||||
LiveIdentity found = null;
|
||||
for (LiveIdentity identity : identities.values()) {
|
||||
if (!uuid.equals(identity.serverUuid())) continue;
|
||||
if (found != null) return null; // Uncertain or ambiguous: preserve the supplied UUID.
|
||||
found = identity;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh membership without reading another region's players. Each scheduled
|
||||
* capture is fenced by its reservation; quit, join, another refresh and clear
|
||||
* invalidate it before it can publish stale identity data. The capture consumer
|
||||
* must run on that player's owning thread.
|
||||
*/
|
||||
public void refresh(Supplier<? extends Collection<? extends Player>> playersSupplier,
|
||||
BiConsumer<Player, Consumer<UUID>> scheduleCapture) {
|
||||
Map<Player, Object> reservations = new IdentityHashMap<>();
|
||||
synchronized (lifecycleLock) {
|
||||
Collection<? extends Player> players = playersSupplier.get();
|
||||
Map<Player, Boolean> members = new IdentityHashMap<>();
|
||||
if (players != null) for (Player player : players) {
|
||||
if (player != null) members.put(player, Boolean.TRUE);
|
||||
}
|
||||
Map<UUID, Player> retained = new HashMap<>(onlinePlayers.get());
|
||||
retained.entrySet().removeIf(entry -> !members.containsKey(entry.getValue()));
|
||||
onlinePlayers.set(Map.copyOf(retained));
|
||||
retainCurrentIdentities();
|
||||
refreshReservations.clear();
|
||||
members.keySet().forEach(player -> {
|
||||
Object reservation = new Object();
|
||||
refreshReservations.put(player, reservation);
|
||||
reservations.put(player, reservation);
|
||||
});
|
||||
}
|
||||
reservations.forEach((player, reservation) -> scheduleCapture.accept(player, storageUuid -> {
|
||||
synchronized (lifecycleLock) {
|
||||
if (refreshReservations.get(player) != reservation) return;
|
||||
refreshReservations.remove(player);
|
||||
if (storageUuid == null || !player.isOnline()) return;
|
||||
Player currentOwner = onlinePlayers.get().get(storageUuid);
|
||||
if (currentOwner != null && currentOwner != player) return;
|
||||
UUID serverUuid = player.getUniqueId();
|
||||
if (identities.entrySet().stream().anyMatch(entry -> entry.getKey() != player
|
||||
&& serverUuid.equals(entry.getValue().serverUuid()))) return;
|
||||
captureOnline(storageUuid, player);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void retainCurrentIdentities() {
|
||||
identities.entrySet().removeIf(entry -> onlinePlayers.get().get(entry.getValue().storageUuid()) != entry.getKey());
|
||||
}
|
||||
|
||||
public boolean isOnline(UUID uuid) {
|
||||
return uuid != null && onlinePlayers.get().containsKey(uuid);
|
||||
@@ -139,31 +46,24 @@ public final class PlaceholderPlayerPresence {
|
||||
public void playerOnline(UUID uuid, Player player) {
|
||||
if (uuid == null || player == null) return;
|
||||
synchronized (lifecycleLock) {
|
||||
refreshReservations.remove(player);
|
||||
Player displacedOwner = onlinePlayers.get().get(uuid);
|
||||
if (displacedOwner != null && displacedOwner != player) refreshReservations.remove(displacedOwner);
|
||||
update(current -> {
|
||||
Map<UUID, Player> next = new HashMap<>(current);
|
||||
next.entrySet().removeIf(entry -> entry.getValue() == player && !entry.getKey().equals(uuid));
|
||||
next.put(uuid, player);
|
||||
return Map.copyOf(next);
|
||||
});
|
||||
retainCurrentIdentities();
|
||||
}
|
||||
}
|
||||
|
||||
public void playerOffline(UUID uuid) {
|
||||
if (uuid == null) return;
|
||||
synchronized (lifecycleLock) {
|
||||
Player owner = onlinePlayers.get().get(uuid);
|
||||
if (owner != null) refreshReservations.remove(owner);
|
||||
update(current -> {
|
||||
if (!current.containsKey(uuid)) return current;
|
||||
Map<UUID, Player> next = new HashMap<>(current);
|
||||
next.remove(uuid);
|
||||
return Map.copyOf(next);
|
||||
});
|
||||
retainCurrentIdentities();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,13 +71,11 @@ public final class PlaceholderPlayerPresence {
|
||||
public boolean playerOffline(UUID uuid, Player expectedOwner) {
|
||||
if (uuid == null || expectedOwner == null) return false;
|
||||
synchronized (lifecycleLock) {
|
||||
refreshReservations.remove(expectedOwner);
|
||||
Map<UUID, Player> current = onlinePlayers.get();
|
||||
if (current.get(uuid) != expectedOwner) return false;
|
||||
Map<UUID, Player> next = new HashMap<>(current);
|
||||
next.remove(uuid);
|
||||
onlinePlayers.set(Map.copyOf(next));
|
||||
retainCurrentIdentities();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -215,8 +113,6 @@ public final class PlaceholderPlayerPresence {
|
||||
}
|
||||
}
|
||||
onlinePlayers.set(Map.copyOf(next));
|
||||
refreshReservations.clear();
|
||||
retainCurrentIdentities();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,11 +122,11 @@ public final class PlaceholderPlayerPresence {
|
||||
if (players != null) players.forEach((uuid, player) -> {
|
||||
if (uuid != null && player != null) next.put(uuid, player);
|
||||
});
|
||||
synchronized (lifecycleLock) { onlinePlayers.set(Map.copyOf(next)); refreshReservations.clear(); retainCurrentIdentities(); }
|
||||
synchronized (lifecycleLock) { onlinePlayers.set(Map.copyOf(next)); }
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
synchronized (lifecycleLock) { onlinePlayers.set(Map.of()); identities.clear(); refreshReservations.clear(); }
|
||||
synchronized (lifecycleLock) { onlinePlayers.set(Map.of()); }
|
||||
}
|
||||
|
||||
/** Publish an offline-only result atomically with player lifecycle capture. */
|
||||
|
||||
-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();
|
||||
}
|
||||
}
|
||||
@@ -40,19 +40,6 @@ public class OfflineBungeeVote {
|
||||
@Getter
|
||||
@Setter
|
||||
private String text;
|
||||
|
||||
public long getCanonicalOccurrenceTime() {
|
||||
Long original = VoteOccurrenceMetadata.read(text);
|
||||
return original == null ? 0L : original;
|
||||
}
|
||||
|
||||
public void setCanonicalOccurrenceTime(long occurredAt) {
|
||||
text = VoteOccurrenceMetadata.store(text, occurredAt);
|
||||
}
|
||||
|
||||
public void setText(String updated) {
|
||||
text = VoteOccurrenceMetadata.preserve(text, updated);
|
||||
}
|
||||
@Getter
|
||||
private long time;
|
||||
@Getter
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
package com.bencodez.votingplugin.proxy;
|
||||
|
||||
/** Optional occurrence metadata in existing opaque cache payloads, separate from cooldown time. */
|
||||
public final class VoteOccurrenceMetadata {
|
||||
private static final String MARKER = "//vp-occurrence-time:";
|
||||
|
||||
private VoteOccurrenceMetadata() { }
|
||||
|
||||
/** Null means an older payload without metadata; -1 means explicitly invalid metadata. */
|
||||
public static Long read(String payload) {
|
||||
if (payload == null) return null;
|
||||
int marker = payload.lastIndexOf(MARKER);
|
||||
if (marker < 0) return null;
|
||||
if (marker != payload.indexOf(MARKER)) return -1L;
|
||||
return parse(payload.substring(marker + MARKER.length(),
|
||||
Math.min(payload.length(), marker + MARKER.length() + 21)));
|
||||
}
|
||||
|
||||
public static long parse(String raw) {
|
||||
if (raw == null || raw.isEmpty() || raw.length() > 20) return -1L;
|
||||
try {
|
||||
long time = Long.parseLong(raw);
|
||||
return time > 0 ? time : -1L;
|
||||
} catch (NumberFormatException invalid) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing v1/v2 totals parsers ignore this tagged trailing token. */
|
||||
public static String store(String payload, long occurredAt) {
|
||||
String text = payload == null ? "" : payload;
|
||||
int marker = text.indexOf(MARKER);
|
||||
if (marker >= 0) text = text.substring(0, marker);
|
||||
return text + MARKER + (occurredAt > 0 ? occurredAt : -1L);
|
||||
}
|
||||
|
||||
/** An occurrence tag alone does not represent a processed totals snapshot. */
|
||||
public static boolean hasPayload(String payload) {
|
||||
return payload != null && !payload.isEmpty() && !payload.startsWith(MARKER);
|
||||
}
|
||||
|
||||
public static String preserve(String oldPayload, String updatedPayload) {
|
||||
Long original = read(oldPayload);
|
||||
return original == null ? updatedPayload : store(updatedPayload, original);
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,6 @@ public abstract class VotingPluginProxy {
|
||||
private String service;
|
||||
private String uuid;
|
||||
private long time;
|
||||
private long canonicalOccurrenceTime;
|
||||
private boolean realVote;
|
||||
private boolean playerOnline;
|
||||
private boolean delayValidated;
|
||||
@@ -201,7 +200,6 @@ public abstract class VotingPluginProxy {
|
||||
private final boolean realVote;
|
||||
private final boolean timeQueue;
|
||||
private final long queueTime;
|
||||
private final long canonicalOccurrenceTime;
|
||||
private final VoteTotalsSnapshot totals;
|
||||
private final String uuid;
|
||||
private final UUID voteId;
|
||||
@@ -214,8 +212,7 @@ public abstract class VotingPluginProxy {
|
||||
|
||||
private MultiProxyVoteRetry(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
|
||||
VoteTotalsSnapshot totals, String uuid, UUID voteId, String origin, boolean delayValidated,
|
||||
boolean delayValidationKnown, long canonicalOccurrenceTime) {
|
||||
this.canonicalOccurrenceTime = canonicalOccurrenceTime;
|
||||
boolean delayValidationKnown) {
|
||||
this.player = player;
|
||||
this.service = service;
|
||||
this.realVote = realVote;
|
||||
@@ -2898,8 +2895,7 @@ public abstract class VotingPluginProxy {
|
||||
@Override
|
||||
public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
|
||||
VoteTotalsSnapshot text, String uuid) {
|
||||
// Origin-less forwarding cannot establish an original canonical occurrence.
|
||||
vote(player, service, realVote, timeQueue, queueTime, text, uuid, null, null, false, false, -1L);
|
||||
vote(player, service, realVote, timeQueue, queueTime, text, uuid);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -2922,14 +2918,6 @@ public abstract class VotingPluginProxy {
|
||||
delayValidated, delayValidationKnown);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
|
||||
VoteTotalsSnapshot text, String uuid, UUID voteId, String origin, boolean delayValidated,
|
||||
boolean delayValidationKnown, long canonicalOccurrenceTime) {
|
||||
receiveMultiProxyVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin,
|
||||
delayValidated, delayValidationKnown, canonicalOccurrenceTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMultiProxyVoteAcknowledged(UUID voteId, String recipient) {
|
||||
handleMultiProxyVoteAcknowledgement(voteId, recipient);
|
||||
@@ -2966,13 +2954,6 @@ public abstract class VotingPluginProxy {
|
||||
protected synchronized void receiveMultiProxyVote(String player, String service, boolean realVote,
|
||||
boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID wireVoteId, String origin,
|
||||
boolean delayValidated, boolean delayValidationKnown) {
|
||||
receiveMultiProxyVote(player, service, realVote, timeQueue, queueTime, totals, uuid, wireVoteId, origin,
|
||||
delayValidated, delayValidationKnown, queueTime);
|
||||
}
|
||||
|
||||
protected synchronized void receiveMultiProxyVote(String player, String service, boolean realVote,
|
||||
boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID wireVoteId, String origin,
|
||||
boolean delayValidated, boolean delayValidationKnown, long canonicalOccurrenceTime) {
|
||||
UUID voteId = wireVoteId == null ? UUID.randomUUID() : wireVoteId;
|
||||
if (completedMultiProxyVotes.containsKey(voteId)) {
|
||||
acknowledgeCompletedMultiProxyVote(voteId, origin);
|
||||
@@ -2991,7 +2972,7 @@ public abstract class VotingPluginProxy {
|
||||
// this process heap and is loaded again after a restart. It also lets the
|
||||
// normal queue processor drain the spill as retry slots become available.
|
||||
if (retainForwardedVoteOverflow(player, service, realVote, totals, uuid, voteId, queueTime, origin,
|
||||
delayValidated, delayValidationKnown, canonicalOccurrenceTime)) {
|
||||
delayValidated, delayValidationKnown)) {
|
||||
scheduleTimeVoteRetry();
|
||||
return;
|
||||
}
|
||||
@@ -2999,7 +2980,7 @@ public abstract class VotingPluginProxy {
|
||||
return;
|
||||
}
|
||||
retry = new MultiProxyVoteRetry(player, service, realVote, timeQueue, queueTime, totals, uuid, voteId, origin,
|
||||
delayValidated, delayValidationKnown, canonicalOccurrenceTime);
|
||||
delayValidated, delayValidationKnown);
|
||||
multiProxyVoteRetries.put(voteId, retry);
|
||||
}
|
||||
if (!retry.scheduled) attemptMultiProxyVote(retry);
|
||||
@@ -3008,7 +2989,7 @@ public abstract class VotingPluginProxy {
|
||||
/** Durably spills an over-capacity forwarded vote into the normal replay queue. */
|
||||
private boolean retainForwardedVoteOverflow(String player, String service, boolean realVote,
|
||||
VoteTotalsSnapshot totals, String uuid, UUID voteId, long queueTime, String origin, boolean delayValidated,
|
||||
boolean delayValidationKnown, long canonicalOccurrenceTime) {
|
||||
boolean delayValidationKnown) {
|
||||
if (voteId == null || player == null || service == null || uuid == null) return false;
|
||||
for (VoteTimeQueue queued : getVoteCacheHandler().getTimeChangeQueue()) {
|
||||
if (voteId.equals(queued.getVoteId())) return true;
|
||||
@@ -3016,7 +2997,6 @@ public abstract class VotingPluginProxy {
|
||||
long time = queueTime == 0L ? System.currentTimeMillis() : queueTime;
|
||||
VoteTimeQueue queued = new VoteTimeQueue(voteId, player, service, time, false,
|
||||
Collections.emptySet(), Collections.emptySet(), totals == null ? "" : totals.toString(), false, uuid);
|
||||
queued.setCanonicalOccurrenceTime(canonicalOccurrenceTime);
|
||||
queued.setRealVote(realVote);
|
||||
queued.setMultiProxyOrigin(origin == null ? "" : origin);
|
||||
if (delayValidationKnown) queued.setDelayValidated(delayValidated);
|
||||
@@ -3094,7 +3074,6 @@ public abstract class VotingPluginProxy {
|
||||
private boolean persistDeferredMultiProxyReceipt(MultiProxyVoteRetry retry) {
|
||||
VoteTimeQueue queued = findUnprocessedQueuedVote(retry.voteId);
|
||||
if (queued == null) return false;
|
||||
queued.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime);
|
||||
queued.setMultiProxyOrigin(retry.origin);
|
||||
queued.setRealVote(retry.realVote);
|
||||
if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated);
|
||||
@@ -3641,7 +3620,6 @@ public abstract class VotingPluginProxy {
|
||||
// completion fence: its origin is persisted by PERSIST_DEFERRED_RECEIPT.
|
||||
if (!queued.isProcessed()) return false;
|
||||
queued.setProcessed(true);
|
||||
queued.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime);
|
||||
queued.setRealVote(retry.realVote);
|
||||
queued.setMultiProxyOrigin(retry.origin);
|
||||
if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated);
|
||||
@@ -3653,7 +3631,6 @@ public abstract class VotingPluginProxy {
|
||||
retry.queueTime == 0L ? System.currentTimeMillis() : retry.queueTime, false,
|
||||
Collections.emptySet(), Collections.emptySet(), retry.totals == null ? "" : retry.totals.toString(), true,
|
||||
retry.uuid);
|
||||
quarantine.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime);
|
||||
quarantine.setRealVote(retry.realVote);
|
||||
quarantine.setMultiProxyOrigin(retry.origin);
|
||||
if (retry.delayValidationKnown) quarantine.setDelayValidated(retry.delayValidated);
|
||||
@@ -4085,7 +4062,7 @@ public abstract class VotingPluginProxy {
|
||||
vote.setRealVote(queuedRealVote);
|
||||
vote.setDeliveryStateDirty(true);
|
||||
}
|
||||
VoteTotalsSnapshot queuedTotals = !VoteOccurrenceMetadata.hasPayload(forwardedTotals[1]) ? null
|
||||
VoteTotalsSnapshot queuedTotals = forwardedTotals[1].isEmpty() ? null
|
||||
: VoteTotalsSnapshot.parseStorage(forwardedTotals[1]);
|
||||
QueuedVoteResult result = replayQueuedVote(vote, queuedTotals, queuedRealVote);
|
||||
if (result == QueuedVoteResult.RETRY || result == QueuedVoteResult.RETRY_NONBLOCKING) {
|
||||
@@ -6074,9 +6051,6 @@ public abstract class VotingPluginProxy {
|
||||
|
||||
private JsonEnvelope cachedVoteEnvelope(OfflineBungeeVote vote, boolean online, boolean broadcast,
|
||||
int num, int numberOfVotes) {
|
||||
// Older cache rows may have been timestamped by an intermediate receiver.
|
||||
// Preserve their normal time, but do not invent date occurrence provenance.
|
||||
String payload = VoteOccurrenceMetadata.store(vote.getText(), vote.getCanonicalOccurrenceTime());
|
||||
if (vote.hasCachedVoteBatch()) {
|
||||
num = vote.getCachedBatchNumber();
|
||||
numberOfVotes = vote.getCachedBatchSize();
|
||||
@@ -6085,18 +6059,18 @@ public abstract class VotingPluginProxy {
|
||||
if (vote.isDelayValidationKnown()) {
|
||||
envelope = online
|
||||
? VotingPluginWire.voteOnline(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(),
|
||||
getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes, vote.isDelayValidated())
|
||||
: VotingPluginWire.vote(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(),
|
||||
getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes, vote.isDelayValidated());
|
||||
} else {
|
||||
envelope = online
|
||||
? VotingPluginWire.voteOnline(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(),
|
||||
getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes)
|
||||
: VotingPluginWire.vote(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(),
|
||||
getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes);
|
||||
}
|
||||
// A cache entry is only known to have passed the proxy's delay gate when
|
||||
@@ -6247,16 +6221,6 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserves captured receipt time independently of ordinary processing/cooldown time. */
|
||||
public synchronized void vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
|
||||
VoteTotalsSnapshot text, String uuid, UUID voteId, long occurredAt) {
|
||||
VoteTimeQueue retained = findQueuedVote(voteId);
|
||||
if (vote(player, service, realVote, timeQueue, queueTime, text, uuid, retained, voteId,
|
||||
false, false, occurredAt) == QueuedVoteResult.RETRY) {
|
||||
throw new VoteRetryException();
|
||||
}
|
||||
}
|
||||
|
||||
/** Releases retry-only state after the bounded event-listener retries are exhausted. */
|
||||
public synchronized void abandonLiveVoteRetry(UUID voteId) {
|
||||
if (voteId != null) liveVoteRetries.remove(voteId);
|
||||
@@ -6281,7 +6245,6 @@ public abstract class VotingPluginProxy {
|
||||
public synchronized VoteTimeQueue snapshotIncomingVoteForRestart(PendingIncomingVote pending) {
|
||||
VoteTimeQueue queued = new VoteTimeQueue(pending.getVoteId(), pending.getPlayer(), pending.getService(),
|
||||
pending.getAcceptedAt());
|
||||
queued.setCanonicalOccurrenceTime(pending.getAcceptedAt());
|
||||
LiveVoteRetryState retry = liveVoteRetries.get(pending.getVoteId());
|
||||
if (retry != null) {
|
||||
queued.setUuid(retry.uuid == null ? "" : retry.uuid);
|
||||
@@ -6312,7 +6275,6 @@ public abstract class VotingPluginProxy {
|
||||
queued.setMultiProxyForwardingHandled(retry.multiProxyForwardingHandled);
|
||||
if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated);
|
||||
if (retry.totals != null) queued.setTotals(retry.totals.toString());
|
||||
if (retry.canonicalOccurrenceTime != 0L) queued.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime);
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
@@ -6337,7 +6299,7 @@ public abstract class VotingPluginProxy {
|
||||
if (retry == null && !recovery.isVotePartyApplied() && !recovery.isTotalsApplied()
|
||||
&& recovery.isRealVote() && !recovery.isWasOnlineKnown()
|
||||
&& (recovery.getUuid() == null || recovery.getUuid().isEmpty())
|
||||
&& !VoteOccurrenceMetadata.hasPayload(recovery.getTotals())) return true;
|
||||
&& (recovery.getTotals() == null || recovery.getTotals().isEmpty())) return true;
|
||||
VoteTimeQueue state = retry == null ? recovery
|
||||
: snapshotIncomingVoteForRestart(new PendingIncomingVote(recovery.getVoteId(), recovery.getName(),
|
||||
recovery.getService(), recovery.getTime()));
|
||||
@@ -6385,13 +6347,6 @@ public abstract class VotingPluginProxy {
|
||||
private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue,
|
||||
long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId,
|
||||
boolean upstreamDelayValidated, boolean upstreamDelayValidationKnown) {
|
||||
return vote(player, service, realVote, timeQueue, queueTime, text, uuid, queuedVote, requestedVoteId,
|
||||
upstreamDelayValidated, upstreamDelayValidationKnown, 0L);
|
||||
}
|
||||
|
||||
private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue,
|
||||
long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId,
|
||||
boolean upstreamDelayValidated, boolean upstreamDelayValidationKnown, long upstreamOccurrenceTime) {
|
||||
try {
|
||||
String requestPlayer = player;
|
||||
if (!ServiceSiteValidator.isValid(service)) {
|
||||
@@ -6515,13 +6470,6 @@ public abstract class VotingPluginProxy {
|
||||
: (queueTime != 0 ? queueTime
|
||||
: LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
|
||||
|
||||
MultiProxyVoteRetry forwardedOccurrence = multiProxyVoteRetries.get(voteId);
|
||||
long canonicalOccurrenceTime = retryState != null ? retryState.canonicalOccurrenceTime
|
||||
: upstreamOccurrenceTime != 0L ? upstreamOccurrenceTime
|
||||
: forwardedOccurrence != null ? forwardedOccurrence.canonicalOccurrenceTime
|
||||
: queuedVote == null ? time
|
||||
: queuedVote.getCanonicalOccurrenceTime() == 0L ? -1L : queuedVote.getCanonicalOccurrenceTime();
|
||||
|
||||
Set<String> broadcastTargets = queuedVote == null ? new LinkedHashSet<>()
|
||||
: new LinkedHashSet<>(queuedVote.getBroadcastTargets());
|
||||
Set<String> broadcastForwardedServers = queuedVote == null ? new LinkedHashSet<>()
|
||||
@@ -6591,7 +6539,6 @@ public abstract class VotingPluginProxy {
|
||||
VoteTimeQueue delayedVote = new VoteTimeQueue(voteId, player, service, time,
|
||||
proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers,
|
||||
projectedTotals == null ? "" : projectedTotals.toString(), false, uuid);
|
||||
if (canonicalOccurrenceTime != 0L) delayedVote.setCanonicalOccurrenceTime(canonicalOccurrenceTime);
|
||||
delayedVote.setRealVote(realVote);
|
||||
delayedVote.setWasOnline(playerOnline);
|
||||
if (authoritativeDelayValidationKnown) {
|
||||
@@ -6632,14 +6579,12 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
retryState.totalsInput = data;
|
||||
if (queuedVote != null && queuedVote.isTotalsApplied()) {
|
||||
retryState.totals = text != null ? text : VoteOccurrenceMetadata.hasPayload(queuedVote.getTotals())
|
||||
? VoteTotalsSnapshot.parseStorage(queuedVote.getTotals()) : null;
|
||||
retryState.totals = text != null ? text : VoteTotalsSnapshot.parseStorage(queuedVote.getTotals());
|
||||
}
|
||||
retryState.player = requestPlayer;
|
||||
retryState.service = service;
|
||||
retryState.uuid = uuid;
|
||||
retryState.time = time;
|
||||
retryState.canonicalOccurrenceTime = canonicalOccurrenceTime;
|
||||
retryState.realVote = realVote;
|
||||
retryState.playerOnline = playerOnline;
|
||||
retryState.delayValidated = authoritativeDelayValidated;
|
||||
@@ -6748,9 +6693,6 @@ public abstract class VotingPluginProxy {
|
||||
text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0);
|
||||
}
|
||||
|
||||
String votePayload = canonicalOccurrenceTime == 0L ? text.toString()
|
||||
: VoteOccurrenceMetadata.store(text.toString(), canonicalOccurrenceTime);
|
||||
|
||||
VoteLogStatus voteStatus = VoteLogStatus.IMMEDIATE;
|
||||
boolean standaloneProxyBroadcast = canValidateStandaloneBroadcast && (proxyBroadcastHandled
|
||||
|| proxyBroadcastDecider.usesImmediateForwarding(playerOnline));
|
||||
@@ -6785,7 +6727,7 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
if (standaloneBroadcastState == null) {
|
||||
standaloneBroadcastState = new OfflineBungeeVote(voteId, player, uuid, service, time, realVote,
|
||||
text == null ? "" : votePayload, false, true, proxyBroadcastTargets,
|
||||
text == null ? "" : text.toString(), false, true, proxyBroadcastTargets,
|
||||
broadcastForwardedServers, !sendVotesToAllServers, Collections.emptyMap(), queuedVote == null
|
||||
? Collections.emptyMap() : queuedVote.getHttpBroadcastDeliveryIds());
|
||||
standaloneBroadcastState.setWasOnline(playerOnline);
|
||||
@@ -6833,7 +6775,7 @@ public abstract class VotingPluginProxy {
|
||||
if (method == BungeeMethod.PLUGINMESSAGING || retryState.pluginMessageRouting)
|
||||
rewardJournalOwner = findCachedBroadcastOwner(uuid, voteId);
|
||||
if (rewardJournalOwner == null) rewardJournalOwner = createCachedRewardVote(voteId, player, uuid, service, time, realVote,
|
||||
votePayload, false, playerOnline);
|
||||
text.toString(), false, playerOnline);
|
||||
if (retryState.delayValidationKnown) rewardJournalOwner.setDelayValidated(authoritativeDelayValidated);
|
||||
markPendingRewardJournalTargets(rewardJournalOwner, rewardServers, retryState.deliveredRewardServers);
|
||||
retryState.rewardJournalOwner = rewardJournalOwner;
|
||||
@@ -6851,7 +6793,7 @@ public abstract class VotingPluginProxy {
|
||||
OfflineBungeeVote rewardState = retryState.rewardStates.get(server.toLowerCase(Locale.ROOT));
|
||||
if (rewardState == null) {
|
||||
rewardState = createCachedRewardVote(voteId, player, uuid, service, time,
|
||||
realVote, votePayload, standaloneProxyBroadcast, playerOnline);
|
||||
realVote, text.toString(), standaloneProxyBroadcast, playerOnline);
|
||||
if (retryState.delayValidationKnown) rewardState.setDelayValidated(authoritativeDelayValidated);
|
||||
retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), rewardState);
|
||||
}
|
||||
@@ -6904,10 +6846,10 @@ public abstract class VotingPluginProxy {
|
||||
|
||||
OfflineBungeeVote pendingVote = retryState.rewardStates.get(s.toLowerCase(Locale.ROOT));
|
||||
JsonEnvelope rewardEnvelope = retryState.delayValidationKnown
|
||||
? VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, votePayload,
|
||||
? VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, text.toString(),
|
||||
voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1,
|
||||
authoritativeDelayValidated)
|
||||
: VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, votePayload,
|
||||
: VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, text.toString(),
|
||||
voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
boolean rewardAccepted = sendVoteEnvelopeAccepted(s, 2, rewardEnvelope, pendingVote);
|
||||
if (!rewardAccepted) {
|
||||
@@ -6952,10 +6894,10 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
if (pendingVote == null) {
|
||||
pendingVote = standaloneProxyBroadcast
|
||||
? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, votePayload, false,
|
||||
? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), false,
|
||||
true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(),
|
||||
standaloneBroadcastState.getHttpBroadcastDeliveryIds())
|
||||
: createCachedRewardVote(voteId, player, uuid, service, time, realVote, votePayload, false, playerOnline);
|
||||
: createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false, playerOnline);
|
||||
retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), pendingVote);
|
||||
}
|
||||
pendingVote.setWasOnline(playerOnline);
|
||||
@@ -6964,10 +6906,10 @@ public abstract class VotingPluginProxy {
|
||||
if (!rewardAccepted) {
|
||||
JsonEnvelope rewardEnvelope = retryState.delayValidationKnown
|
||||
? VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote,
|
||||
votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1,
|
||||
text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1,
|
||||
authoritativeDelayValidated)
|
||||
: VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote,
|
||||
votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
rewardAccepted = sendVoteEnvelopeAccepted(server, 1, rewardEnvelope, pendingVote);
|
||||
if (rewardAccepted) {
|
||||
retryState.deliveredRewardServers.add(server);
|
||||
@@ -7028,7 +6970,7 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
if (standaloneBroadcastState == null) {
|
||||
standaloneBroadcastState = new OfflineBungeeVote(voteId, player, uuid, service, time,
|
||||
realVote, votePayload, false, true, targets, broadcastForwardedServers,
|
||||
realVote, text.toString(), false, true, targets, broadcastForwardedServers,
|
||||
true, Collections.emptyMap(), Collections.emptyMap());
|
||||
standaloneBroadcastState.setWasOnline(true);
|
||||
retryState.standaloneBroadcastState = standaloneBroadcastState;
|
||||
@@ -7054,7 +6996,7 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
|
||||
JsonEnvelope broadcast = VotingPluginWire.voteBroadcast(uuid, player, service, time,
|
||||
text == null ? "" : votePayload, true);
|
||||
text == null ? "" : text.toString(), true);
|
||||
if (method == BungeeMethod.HTTP) {
|
||||
boolean forwarded = sendStableHttpEnvelope(targetServer,
|
||||
stableLiveHttpBroadcastDeliveryId(voteId, targetServer), broadcast);
|
||||
@@ -7086,10 +7028,10 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
}
|
||||
OfflineBungeeVote cachedReward = standaloneProxyBroadcast
|
||||
? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, votePayload, false,
|
||||
? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), false,
|
||||
true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(),
|
||||
standaloneBroadcastState.getHttpBroadcastDeliveryIds())
|
||||
: createCachedRewardVote(voteId, player, uuid, service, time, realVote, votePayload, false, playerOnline);
|
||||
: createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false, playerOnline);
|
||||
cachedReward.setWasOnline(playerOnline);
|
||||
if (retryState.delayValidationKnown) cachedReward.setDelayValidated(authoritativeDelayValidated);
|
||||
retryState.pendingOnlineRewardState = cachedReward;
|
||||
@@ -7267,10 +7209,6 @@ public abstract class VotingPluginProxy {
|
||||
String uuid, String service, long time, boolean realVote, VoteTotalsSnapshot totals,
|
||||
boolean delayValidated, boolean delayValidationKnown) {
|
||||
if (multiProxyHandler == null) return false;
|
||||
String forwardingTotals = VoteOccurrenceMetadata.store(totals == null ? "" : totals.toString(),
|
||||
retryState != null && retryState.canonicalOccurrenceTime != 0
|
||||
? retryState.canonicalOccurrenceTime : queuedVote == null ? time
|
||||
: queuedVote.getCanonicalOccurrenceTime());
|
||||
// A corrupt or unpersistable peer-classification file cannot safely identify
|
||||
// which targets are legacy. Stop before creating an ACK outbox that a legacy
|
||||
// peer could never complete; the handler emits the operator recovery message.
|
||||
@@ -7320,12 +7258,12 @@ public abstract class VotingPluginProxy {
|
||||
// Older peers do not understand acknowledgements. Preserve their historical
|
||||
// fire-and-forget route instead of creating an outbox they can never ACK.
|
||||
return multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.vote(player, uuid, service, time,
|
||||
false, realVote, forwardingTotals, findLiveVoteId(retryState), false, false,
|
||||
false, realVote, totals == null ? "" : totals.toString(), findLiveVoteId(retryState), false, false,
|
||||
1, 1), legacyRecipients);
|
||||
}
|
||||
if (outbox == null) {
|
||||
outbox = new VoteTimeQueue(null, player, service, time, false, Collections.emptySet(),
|
||||
Collections.emptySet(), forwardingTotals, true, uuid);
|
||||
Collections.emptySet(), totals == null ? "" : totals.toString(), true, uuid);
|
||||
// The live retry key is the stable vote ID; copy it from the enclosing state
|
||||
// by locating its identity rather than creating a new duplicate record.
|
||||
outbox.setVoteId(findLiveVoteId(retryState));
|
||||
@@ -7380,7 +7318,7 @@ public abstract class VotingPluginProxy {
|
||||
outbox.setDeliveryStateDirty(true);
|
||||
if (!persistTimeVoteDelivery(outbox)) return false;
|
||||
if (!multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.vote(player, uuid, service, time,
|
||||
false, realVote, forwardingTotals, outbox.getVoteId(), false, false, 1, 1),
|
||||
false, realVote, totals == null ? "" : totals.toString(), outbox.getVoteId(), false, false, 1, 1),
|
||||
legacyPending)) {
|
||||
outbox.setMultiProxyLegacyPendingRecipients(legacyPending);
|
||||
outbox.setDeliveryStateDirty(true);
|
||||
@@ -7410,14 +7348,10 @@ public abstract class VotingPluginProxy {
|
||||
private VoteTimeQueue admitMultiProxyDiscoveryOutbox(LiveVoteRetryState retryState, VoteTimeQueue queuedVote,
|
||||
String player, String uuid, String service, long time, boolean realVote, VoteTotalsSnapshot totals,
|
||||
boolean delayValidated, boolean delayValidationKnown) {
|
||||
String forwardingTotals = VoteOccurrenceMetadata.store(totals == null ? "" : totals.toString(),
|
||||
retryState != null && retryState.canonicalOccurrenceTime != 0
|
||||
? retryState.canonicalOccurrenceTime : queuedVote == null ? time
|
||||
: queuedVote.getCanonicalOccurrenceTime());
|
||||
VoteTimeQueue outbox = queuedVote;
|
||||
if (outbox == null) {
|
||||
outbox = new VoteTimeQueue(null, player, service, time, false, Collections.emptySet(),
|
||||
Collections.emptySet(), forwardingTotals, true, uuid);
|
||||
Collections.emptySet(), totals == null ? "" : totals.toString(), true, uuid);
|
||||
outbox.setVoteId(findLiveVoteId(retryState));
|
||||
if (outbox.getVoteId() == null) return null;
|
||||
retryState.queuedVote = outbox;
|
||||
|
||||
@@ -34,7 +34,6 @@ public final class VotingPluginWire {
|
||||
}
|
||||
|
||||
public static final int SCHEMA_VERSION = 1;
|
||||
public static final String K_CANONICAL_OCCURRENCE_TIME = "canonicalOccurrenceTime";
|
||||
|
||||
// =========================
|
||||
// Subchannels (canonical)
|
||||
@@ -183,16 +182,13 @@ public final class VotingPluginWire {
|
||||
private static JsonEnvelope.Builder voteBuilder(String subChannel, String player, String uuid, String service,
|
||||
long time, boolean wasOnline, boolean realVote, String totals, UUID voteId, boolean manageTotals,
|
||||
boolean bungeeBroadcast, int num, int numberOfVotes) {
|
||||
JsonEnvelope.Builder builder = base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service))
|
||||
return base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service))
|
||||
.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_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num)
|
||||
.put(K_NUMBER_OF_VOTES, numberOfVotes);
|
||||
Long occurrence = VoteOccurrenceMetadata.read(totals);
|
||||
builder.put(K_CANONICAL_OCCURRENCE_TIME, occurrence == null ? time : occurrence);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,15 +214,12 @@ public final class VotingPluginWire {
|
||||
private static JsonEnvelope.Builder multiProxyVoteBuilder(String subChannel, String player, String uuid,
|
||||
String service, long time, boolean wasOnline, boolean realVote, String totals, UUID voteId,
|
||||
boolean manageTotals, boolean bungeeBroadcast, int num, int numberOfVotes, String origin) {
|
||||
JsonEnvelope.Builder builder = base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service))
|
||||
return base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service))
|
||||
.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_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num)
|
||||
.put(K_NUMBER_OF_VOTES, numberOfVotes).put(K_MULTI_PROXY_ORIGIN, safe(origin));
|
||||
Long occurrence = VoteOccurrenceMetadata.read(totals);
|
||||
builder.put(K_CANONICAL_OCCURRENCE_TIME, occurrence == null ? time : occurrence);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/** Reliable multi-proxy variant of {@link #voteOnline}. */
|
||||
@@ -645,7 +638,6 @@ public final class VotingPluginWire {
|
||||
public final String uuid;
|
||||
public final String service;
|
||||
public final long time;
|
||||
public final long canonicalOccurrenceTime;
|
||||
|
||||
public final boolean wasOnline;
|
||||
public final boolean wasOnlineKnown;
|
||||
@@ -667,13 +659,12 @@ public final class VotingPluginWire {
|
||||
boolean wasOnlineKnown, boolean realVote, String totals, UUID voteId, boolean setTotals,
|
||||
boolean manageTotals, boolean delayValidated, boolean delayValidationKnown, boolean queuedDelivery,
|
||||
boolean queuedDeliveryKnown,
|
||||
boolean broadcast, int num, int numberOfVotes, long canonicalOccurrenceTime) {
|
||||
boolean broadcast, int num, int numberOfVotes) {
|
||||
this.subChannel = subChannel;
|
||||
this.player = player;
|
||||
this.uuid = uuid;
|
||||
this.service = service;
|
||||
this.time = time;
|
||||
this.canonicalOccurrenceTime = canonicalOccurrenceTime;
|
||||
this.wasOnline = wasOnline;
|
||||
this.wasOnlineKnown = wasOnlineKnown;
|
||||
this.realVote = realVote;
|
||||
@@ -704,13 +695,6 @@ public final class VotingPluginWire {
|
||||
final boolean wasOnlineKnown = readBool(f, K_WAS_ONLINE_KNOWN, false);
|
||||
final boolean realVote = readBool(f, K_REAL_VOTE, false);
|
||||
final String totals = safe(f.get(K_TOTALS));
|
||||
Long cachedOccurrence = VoteOccurrenceMetadata.read(totals);
|
||||
// Missing or malformed provenance excludes only date accounting. An older
|
||||
// intermediate proxy may have replaced ordinary time with its receipt time.
|
||||
long occurrence = f.containsKey(K_CANONICAL_OCCURRENCE_TIME)
|
||||
? VoteOccurrenceMetadata.parse(f.get(K_CANONICAL_OCCURRENCE_TIME))
|
||||
: cachedOccurrence == null ? -1L : cachedOccurrence;
|
||||
if (cachedOccurrence != null && (cachedOccurrence <= 0 || cachedOccurrence != occurrence)) occurrence = -1L;
|
||||
final UUID voteId = readUuid(f, K_VOTE_ID);
|
||||
|
||||
final boolean setTotals = readBool(f, K_SET_TOTALS, true);
|
||||
@@ -727,7 +711,7 @@ public final class VotingPluginWire {
|
||||
|
||||
return new Vote(sub, player, uuid, service, time, wasOnline, wasOnlineKnown, realVote, totals, voteId,
|
||||
setTotals, manageTotals, delayValidated, delayValidationKnown, queuedDelivery, queuedDeliveryKnown,
|
||||
broadcast, num, numberOfVotes, occurrence);
|
||||
broadcast, num, numberOfVotes);
|
||||
}
|
||||
|
||||
public static final class VoteDelayRejected {
|
||||
|
||||
+1
-13
@@ -614,18 +614,6 @@ public class VotingPluginBungee extends Plugin implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/** Captured ingress survives scheduler/reload/identity-resolution delays. */
|
||||
public IncomingVoteRuntimeResult processIncomingVote(String player, String service, UUID voteId, long occurredAt) {
|
||||
if (reloading) return IncomingVoteRuntimeResult.RETRY_AFTER_RELOAD;
|
||||
synchronized (reloadLock) {
|
||||
if (reloading) return IncomingVoteRuntimeResult.RETRY_AFTER_RELOAD;
|
||||
VotingPluginProxy runtime = votingPluginProxy;
|
||||
if (!runtimeOperational || runtime == null) return IncomingVoteRuntimeResult.RUNTIME_UNAVAILABLE;
|
||||
runtime.vote(player, service, true, true, 0, null, null, voteId, occurredAt);
|
||||
return IncomingVoteRuntimeResult.PROCESSED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns a Votifier event before any fallible scheduler handoff. */
|
||||
public void acceptIncomingVote(String player, String service) {
|
||||
PendingIncomingVote pending = pendingIncomingVotes.admit(player, service);
|
||||
@@ -684,7 +672,7 @@ public class VotingPluginBungee extends Plugin implements Listener {
|
||||
try {
|
||||
IncomingVoteRuntimeResult result;
|
||||
synchronized (reloadLock) {
|
||||
result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId(), pending.getAcceptedAt());
|
||||
result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId());
|
||||
if (result == IncomingVoteRuntimeResult.PROCESSED) pendingIncomingVotes.complete(pending);
|
||||
}
|
||||
if (result == IncomingVoteRuntimeResult.PROCESSED) {
|
||||
|
||||
Vendored
-2
@@ -132,8 +132,6 @@ public abstract class ProxyOnlineVoteCacheTable extends AbstractSqlTable {
|
||||
return;
|
||||
}
|
||||
|
||||
newText = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.preserve(vote.getText(), newText);
|
||||
|
||||
String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("text") + " = ?" + " WHERE " + qi("uuid")
|
||||
+ " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ?;";
|
||||
|
||||
|
||||
-2
@@ -97,8 +97,6 @@ public abstract class ProxyVoteCacheTable extends AbstractSqlTable {
|
||||
return;
|
||||
}
|
||||
|
||||
newText = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.preserve(vote.getText(), newText);
|
||||
|
||||
String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("text") + " = ?" + " WHERE " + qi("uuid")
|
||||
+ " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ? AND " + qi("server") + " = ?;";
|
||||
|
||||
|
||||
+1
-15
@@ -444,14 +444,6 @@ public abstract class MultiProxyHandler {
|
||||
triggerVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin);
|
||||
}
|
||||
|
||||
/** Additive occurrence metadata; queueTime remains the normal receiver clock convention. */
|
||||
public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
|
||||
VoteTotalsSnapshot text, String uuid, UUID voteId, String origin, boolean delayValidated,
|
||||
boolean delayValidationKnown, long canonicalOccurrenceTime) {
|
||||
triggerVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin,
|
||||
delayValidated, delayValidationKnown);
|
||||
}
|
||||
|
||||
/** Called when a receiver acknowledges this proxy's stable vote ID. */
|
||||
public void onMultiProxyVoteAcknowledged(UUID voteId, String recipient) {
|
||||
// Optional for legacy implementations.
|
||||
@@ -1284,14 +1276,8 @@ public abstract class MultiProxyHandler {
|
||||
if (origin.isBlank()) {
|
||||
triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid);
|
||||
} else {
|
||||
// Older reliable origin envelopes still carry the sender's original
|
||||
// time. Keep it separate from receiver-local cooldown processing.
|
||||
long occurrenceTime = !f.containsKey(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME)
|
||||
&& com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.read(totals) == null
|
||||
? wireVote.time : wireVote.canonicalOccurrenceTime;
|
||||
triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid,
|
||||
wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown,
|
||||
occurrenceTime);
|
||||
wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
+1
-13
@@ -509,18 +509,6 @@ public class VotingPluginVelocity {
|
||||
}
|
||||
}
|
||||
|
||||
/** Captured ingress survives scheduler/reload/identity-resolution delays. */
|
||||
public IncomingVoteRuntimeResult processIncomingVote(String player, String service, UUID voteId, long occurredAt) {
|
||||
if (reloading) return IncomingVoteRuntimeResult.RETRY_AFTER_RELOAD;
|
||||
synchronized (reloadLock) {
|
||||
if (reloading) return IncomingVoteRuntimeResult.RETRY_AFTER_RELOAD;
|
||||
VotingPluginProxy runtime = votingPluginProxy;
|
||||
if (!runtimeOperational || runtime == null) return IncomingVoteRuntimeResult.RUNTIME_UNAVAILABLE;
|
||||
runtime.vote(player, service, true, false, 0, null, null, voteId, occurredAt);
|
||||
return IncomingVoteRuntimeResult.PROCESSED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns a Votifier event before any fallible executor handoff. */
|
||||
public void acceptIncomingVote(String player, String service) {
|
||||
PendingIncomingVote pending = pendingIncomingVotes.admit(player, service);
|
||||
@@ -570,7 +558,7 @@ public class VotingPluginVelocity {
|
||||
try {
|
||||
IncomingVoteRuntimeResult result;
|
||||
synchronized (reloadLock) {
|
||||
result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId(), pending.getAcceptedAt());
|
||||
result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId());
|
||||
if (result == IncomingVoteRuntimeResult.PROCESSED) pendingIncomingVotes.complete(pending);
|
||||
}
|
||||
if (result == IncomingVoteRuntimeResult.PROCESSED) {
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
-294
@@ -1,294 +0,0 @@
|
||||
package com.bencodez.votingplugin.specialrewards.datemilestones;
|
||||
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent;
|
||||
import com.bencodez.votingplugin.util.DurableFiles;
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Single local accounting owner. All entry points must run on a persistence worker. */
|
||||
public final class DateVoteLedger {
|
||||
public record Progress(int votes, Set<Integer> reservedAwards, Set<Integer> submittedAwards, Set<Integer> deferredAwards) { }
|
||||
private static final int MAX_RECORDS = 100_000;
|
||||
private final Path directory;
|
||||
private final int recordCapacity;
|
||||
public DateVoteLedger(Path directory) { this(directory, MAX_RECORDS); }
|
||||
// Smaller limits keep boundary regression fixtures bounded; production uses MAX_RECORDS.
|
||||
DateVoteLedger(Path directory, int recordCapacity) {
|
||||
if (recordCapacity < 1 || recordCapacity > MAX_RECORDS) throw new IllegalArgumentException("Invalid milestone record capacity");
|
||||
this.directory = directory.toAbsolutePath().normalize();
|
||||
this.recordCapacity = recordCapacity;
|
||||
}
|
||||
|
||||
public synchronized List<Integer> record(DateVoteEvent event, UUID player, UUID occurrence) throws IOException {
|
||||
return record(event, player, occurrence, true);
|
||||
}
|
||||
public synchronized List<Integer> record(DateVoteEvent event, UUID player, UUID occurrence, boolean processRewards) throws IOException {
|
||||
return withLedgerLock(() -> recordLocked(event, player, occurrence, processRewards));
|
||||
}
|
||||
private List<Integer> recordLocked(DateVoteEvent event, UUID player, UUID occurrence, boolean processRewards) throws IOException {
|
||||
seal(event);
|
||||
Path file = playerFile(event, player);
|
||||
Properties state = read(file);
|
||||
String fingerprint = state.getProperty("fingerprint");
|
||||
if (fingerprint != null && !fingerprint.equals(event.fingerprint())) throw new IOException("Event accounting changed; use a new event ID");
|
||||
validateFingerprint(event, state);
|
||||
Set<UUID> seen = occurrences(state);
|
||||
boolean changed = !seen.contains(occurrence);
|
||||
if (changed && (seen.size() >= 4096 || votes(state) >= 4096))
|
||||
throw new IOException("Event occurrence capacity reached (4096); progress retained");
|
||||
seen.add(occurrence);
|
||||
state.setProperty("fingerprint", event.fingerprint());
|
||||
state.setProperty("seen", String.join(",", seen.stream().map(UUID::toString).sorted().toList()));
|
||||
int votes = votes(state);
|
||||
List<Integer> awards = new ArrayList<>();
|
||||
for (int threshold : event.thresholds()) {
|
||||
String key = "award." + threshold;
|
||||
if (votes >= threshold && !state.containsKey(key)) {
|
||||
state.setProperty(key, "DEFERRED");
|
||||
changed = true;
|
||||
}
|
||||
if (processRewards && votes >= threshold && "DEFERRED".equals(state.getProperty(key))) awards.add(threshold);
|
||||
}
|
||||
// Persist counts and known unattempted awards; each submission claims only its own threshold.
|
||||
if (changed) write(file, state);
|
||||
return List.copyOf(awards);
|
||||
}
|
||||
/** Explicit administrative adjustment; retains receipt IDs and all prior reward fences. */
|
||||
public synchronized Progress setVotes(DateVoteEvent event, UUID player, int value) throws IOException {
|
||||
if (value < 0 || value > 4096) throw new IllegalArgumentException("Votes must be between 0 and 4096");
|
||||
return withLedgerLock(() -> {
|
||||
seal(event);
|
||||
Path file = playerFile(event, player);
|
||||
Properties state = read(file);
|
||||
validateFingerprint(event, state);
|
||||
state.setProperty("fingerprint", event.fingerprint());
|
||||
state.putIfAbsent("seen", "");
|
||||
state.setProperty("adjustment", Integer.toString(value - occurrences(state).size()));
|
||||
for (int threshold : event.thresholds()) if (value >= threshold)
|
||||
state.putIfAbsent("award." + threshold, "DEFERRED");
|
||||
write(file, state);
|
||||
return progressLocked(event, player);
|
||||
});
|
||||
}
|
||||
/** Force skips the count requirement; even an explicit repeat cannot erase an uncertain reservation. */
|
||||
public synchronized boolean reserveForced(DateVoteEvent event, UUID player, int threshold,
|
||||
boolean ignoreLimits) throws IOException {
|
||||
if (!event.thresholds().contains(threshold)) throw new IllegalArgumentException("Unknown milestone threshold");
|
||||
return withLedgerLock(() -> {
|
||||
seal(event);
|
||||
Path file = playerFile(event, player);
|
||||
Properties state = read(file);
|
||||
validateFingerprint(event, state);
|
||||
String previous = state.getProperty("award." + threshold);
|
||||
if ("RESERVED".equals(previous) || !ignoreLimits && "SUBMITTED".equals(previous)) return false;
|
||||
state.setProperty("fingerprint", event.fingerprint());
|
||||
state.putIfAbsent("seen", "");
|
||||
state.putIfAbsent("adjustment", "0");
|
||||
state.setProperty("award." + threshold, "RESERVED");
|
||||
write(file, state);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
/** Atomically admit one known unattempted threshold immediately before its external submission. */
|
||||
public synchronized boolean reserve(DateVoteEvent event, UUID player, int threshold) throws IOException {
|
||||
return withLedgerLock(() -> reserveLocked(event, player, threshold));
|
||||
}
|
||||
private boolean reserveLocked(DateVoteEvent event, UUID player, int threshold) throws IOException {
|
||||
Path file = playerFile(event, player);
|
||||
Properties state = read(file);
|
||||
validateFingerprint(event, state);
|
||||
if (!event.thresholds().contains(threshold) || votes(state) < threshold
|
||||
|| !"DEFERRED".equals(state.getProperty("award." + threshold))) return false;
|
||||
state.setProperty("award." + threshold, "RESERVED");
|
||||
write(file, state);
|
||||
return true;
|
||||
}
|
||||
public synchronized void submitted(DateVoteEvent event, UUID player, int threshold) throws IOException {
|
||||
withLedgerLock(() -> { submittedLocked(event, player, threshold); return null; });
|
||||
}
|
||||
private void submittedLocked(DateVoteEvent event, UUID player, int threshold) throws IOException {
|
||||
Path file = playerFile(event, player);
|
||||
Properties state = read(file);
|
||||
validateFingerprint(event, state);
|
||||
if (!"RESERVED".equals(state.getProperty("award." + threshold))) return;
|
||||
state.setProperty("award." + threshold, "SUBMITTED");
|
||||
write(file, state);
|
||||
}
|
||||
public synchronized Progress progress(DateVoteEvent event, UUID player) throws IOException {
|
||||
// An absent ledger is a valid empty snapshot. A read must not initialize
|
||||
// accounting storage, including for legacy votes with no event progress.
|
||||
// Unknown/unreadable paths still enter the checked ownership path.
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS))
|
||||
return new Progress(0, Set.of(), Set.of(), Set.of());
|
||||
return withLedgerLock(() -> progressLocked(event, player));
|
||||
}
|
||||
private Progress progressLocked(DateVoteEvent event, UUID player) throws IOException {
|
||||
Properties definitions = readSealedDefinitions(event);
|
||||
String sealed = definitions.getProperty(event.id());
|
||||
if (sealed != null && !sealed.equals(event.fingerprint())) throw new IOException("Event accounting changed; use a new ID");
|
||||
Properties state = read(playerFile(event, player));
|
||||
validateFingerprint(event, state);
|
||||
Set<Integer> reserved = new HashSet<>(), submitted = new HashSet<>(), deferred = new HashSet<>();
|
||||
for (String key : state.stringPropertyNames()) {
|
||||
if (key.startsWith("award.")) {
|
||||
int threshold;
|
||||
try { threshold = Integer.parseInt(key.substring(6)); }
|
||||
catch (NumberFormatException invalid) { throw new IOException("Invalid award state", invalid); }
|
||||
if ("RESERVED".equals(state.getProperty(key))) reserved.add(threshold);
|
||||
else if ("SUBMITTED".equals(state.getProperty(key))) submitted.add(threshold);
|
||||
else if ("DEFERRED".equals(state.getProperty(key))) deferred.add(threshold);
|
||||
else throw new IOException("Unknown award state");
|
||||
}
|
||||
}
|
||||
return new Progress(votes(state), Set.copyOf(reserved), Set.copyOf(submitted), Set.copyOf(deferred));
|
||||
}
|
||||
@FunctionalInterface
|
||||
private interface LedgerAction<T> { T run() throws IOException; }
|
||||
private <T> T withLedgerLock(LedgerAction<T> action) throws IOException {
|
||||
if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory");
|
||||
Files.createDirectories(directory);
|
||||
if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory");
|
||||
// Interning shares this monitor across plugin classloaders without retaining a static registry.
|
||||
// On Unix, closing any descriptor for a file can release another channel's process-owned lock.
|
||||
String monitor = ("com.bencodez.votingplugin.DateVoteLedger:" + directory.toRealPath()).intern();
|
||||
if (Thread.holdsLock(monitor)) throw new IOException("Date milestone ledger is busy");
|
||||
synchronized (monitor) {
|
||||
Path lockPath = directory.resolve(".ledger.lock");
|
||||
if (Files.isSymbolicLink(lockPath) || (Files.exists(lockPath, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockPath, LinkOption.NOFOLLOW_LINKS)))
|
||||
throw new IOException("Unsafe milestone lock path");
|
||||
try (FileChannel channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS)) {
|
||||
FileLock lock;
|
||||
try { lock = channel.tryLock(); }
|
||||
catch (OverlappingFileLockException busy) { throw new IOException("Date milestone ledger is busy", busy); }
|
||||
if (lock == null) throw new IOException("Date milestone ledger is busy");
|
||||
try (lock) { return action.run(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
private void validateFingerprint(DateVoteEvent event, Properties state) throws IOException {
|
||||
if (state.isEmpty()) return;
|
||||
if (!event.fingerprint().equals(state.getProperty("fingerprint")))
|
||||
throw new IOException("Event accounting changed or state invalid; use a new ID");
|
||||
int votes = votes(state);
|
||||
for (int threshold : event.thresholds()) if (votes >= threshold && !state.containsKey("award." + threshold))
|
||||
throw new IOException("Missing reached milestone award state; progress retained for review");
|
||||
for (String key : state.stringPropertyNames()) if (key.startsWith("award.")
|
||||
&& !event.thresholds().contains(Integer.parseInt(key.substring(6))))
|
||||
throw new IOException("Award is outside configured milestone contract");
|
||||
}
|
||||
private void seal(DateVoteEvent event) throws IOException {
|
||||
Properties definitions = readSealedDefinitions(event);
|
||||
String old = definitions.getProperty(event.id());
|
||||
if (old != null && !old.equals(event.fingerprint())) throw new IOException("DateVoteMilestones event " + event.id() + " changed accounting; use a new ID");
|
||||
if (old == null) {
|
||||
if (definitions.size() >= 1024) throw new IOException("Event definition capacity reached");
|
||||
definitions.setProperty(event.id(), event.fingerprint());
|
||||
write(directory.resolve("definitions.properties"), definitions);
|
||||
}
|
||||
}
|
||||
/** Missing metadata cannot turn surviving player history into a fresh event. */
|
||||
private Properties readSealedDefinitions(DateVoteEvent event) throws IOException {
|
||||
if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory");
|
||||
Properties definitions = read(directory.resolve("definitions.properties"));
|
||||
if (!definitions.containsKey(event.id()) && Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
// Bounded accounting directory; this lookup is exclusively on the I/O owner.
|
||||
try (var history = Files.newDirectoryStream(directory, event.fileId() + "-*.properties")) {
|
||||
if (history.iterator().hasNext())
|
||||
throw new IOException("Missing historical event seal; preserve state and repair metadata before accounting");
|
||||
}
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
private Path playerFile(DateVoteEvent event, UUID player) {
|
||||
return directory.resolve(event.fileId() + "-" + player + ".properties");
|
||||
}
|
||||
private Set<UUID> occurrences(Properties state) throws IOException {
|
||||
Set<UUID> ids = new HashSet<>();
|
||||
String raw = state.getProperty("seen", "");
|
||||
if (!raw.isEmpty()) for (String id : raw.split(",", -1)) {
|
||||
try { if (!ids.add(UUID.fromString(id))) throw new IOException("Duplicate stored occurrence"); }
|
||||
catch (IllegalArgumentException invalid) { throw new IOException("Invalid stored occurrence", invalid); }
|
||||
if (ids.size() > 4096) throw new IOException("Oversized event state");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
private int votes(Properties state) throws IOException {
|
||||
String adjustment = state.getProperty("adjustment", "0");
|
||||
if (!adjustment.matches("-?(0|[1-9][0-9]{0,3})")) throw new IOException("Invalid progress adjustment");
|
||||
int offset = Integer.parseInt(adjustment);
|
||||
int result = occurrences(state).size() + offset;
|
||||
if (offset < -4096 || offset > 4096 || result < 0 || result > 4096)
|
||||
throw new IOException("Invalid adjusted progress");
|
||||
return result;
|
||||
}
|
||||
private Properties read(Path file) throws IOException {
|
||||
Properties state = new Properties() {
|
||||
@Override public synchronized Object put(Object key, Object value) {
|
||||
if (containsKey(key)) throw new IllegalArgumentException("Duplicate stored field");
|
||||
return super.put(key, value);
|
||||
}
|
||||
};
|
||||
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return new Properties();
|
||||
if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory");
|
||||
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > 256 * 1024)
|
||||
throw new IOException("Unsafe/oversized date milestone state");
|
||||
try { state.load(new StringReader(Files.readString(file))); }
|
||||
catch (IllegalArgumentException invalid) { throw new IOException("Malformed milestone state", invalid); }
|
||||
if (file.getFileName().toString().equals("definitions.properties")) {
|
||||
if (state.isEmpty() || state.size() > 1024) throw new IOException("Empty/oversized definitions; existing seals must not be reset");
|
||||
for (String key : state.stringPropertyNames()) if (!key.matches("[A-Za-z0-9_-]{1,64}")
|
||||
|| !state.getProperty(key).matches("[a-f0-9]{64}")) throw new IOException("Invalid definition state");
|
||||
} else {
|
||||
if (!state.containsKey("fingerprint") || !state.getProperty("fingerprint").matches("[a-f0-9]{64}")
|
||||
|| !state.containsKey("seen") || state.size() > 67) throw new IOException("Incomplete milestone state");
|
||||
Set<UUID> seen = occurrences(state);
|
||||
votes(state);
|
||||
for (String key : state.stringPropertyNames()) if (!key.equals("fingerprint") && !key.equals("seen")
|
||||
&& !key.equals("adjustment")) {
|
||||
if (!key.matches("award\\.[1-9][0-9]{0,3}")) throw new IOException("Unknown milestone state field");
|
||||
int threshold = Integer.parseInt(key.substring(6));
|
||||
if (threshold > 4096 || threshold > seen.size() && !state.containsKey("adjustment")
|
||||
|| !(state.getProperty(key).equals("RESERVED") || state.getProperty(key).equals("SUBMITTED") || state.getProperty(key).equals("DEFERRED")))
|
||||
throw new IOException("Invalid award state");
|
||||
}
|
||||
}
|
||||
Properties mutable = new Properties(); mutable.putAll(state);
|
||||
return mutable;
|
||||
}
|
||||
protected void write(Path file, Properties state) throws IOException {
|
||||
if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory");
|
||||
Files.createDirectories(directory);
|
||||
if (Files.isSymbolicLink(directory) || Files.isSymbolicLink(file)) throw new IOException("Unsafe milestone state path");
|
||||
boolean newRecord = !file.getFileName().toString().equals("definitions.properties") && Files.notExists(file, LinkOption.NOFOLLOW_LINKS);
|
||||
if (newRecord) {
|
||||
int recordCount = 0;
|
||||
try (var files = Files.newDirectoryStream(directory, "*.properties")) {
|
||||
for (Path existing : files) if (!existing.getFileName().toString().equals("definitions.properties")) {
|
||||
if (++recordCount >= recordCapacity) throw new IOException("Milestone record capacity reached; existing progress retained");
|
||||
}
|
||||
}
|
||||
}
|
||||
StringWriter writer = new StringWriter(); state.store(writer, "DateVoteMilestones v1");
|
||||
String text = writer.toString();
|
||||
if (text.length() > 256 * 1024) throw new IOException("Oversized milestone state");
|
||||
Path staged = Files.createTempFile(directory, "milestone-", ".tmp");
|
||||
try { Files.writeString(staged, text); DurableFiles.publishStagedFile(staged, file); }
|
||||
finally { Files.deleteIfExists(staged); }
|
||||
}
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package com.bencodez.votingplugin.specialrewards.datemilestones;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
|
||||
/** Bounded owner configuration; persistent award identities remain the numeric thresholds. */
|
||||
final class DateVoteMilestoneConfig {
|
||||
record Reward(String key, boolean enabled) { }
|
||||
private static final Pattern RANGE = Pattern.compile("(\\d+)\\s*\\.\\.\\s*(\\d+)(?:\\s+step\\s+(\\d+))?");
|
||||
private DateVoteMilestoneConfig() { }
|
||||
|
||||
static Map<Integer, Reward> read(ConfigurationSection event) {
|
||||
var section = event.getConfigurationSection("Milestones");
|
||||
if (section == null || section.getKeys(false).isEmpty() || section.getKeys(false).size() > 64)
|
||||
throw new IllegalArgumentException("Milestones requires 1–64 entries");
|
||||
Map<Integer, Reward> result = new LinkedHashMap<>();
|
||||
for (String key : section.getKeys(false)) {
|
||||
var milestone = section.getConfigurationSection(key);
|
||||
for (var entry : entry(key, milestone).entrySet()) {
|
||||
if (result.putIfAbsent(entry.getKey(), entry.getValue()) != null)
|
||||
throw new IllegalArgumentException("Milestone thresholds must not overlap between entries");
|
||||
if (result.size() > 64) throw new IllegalArgumentException("At most 64 thresholds per event");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Map<Integer, Reward> entry(String key, ConfigurationSection section) {
|
||||
if (!key.matches("[A-Za-z0-9_-]{1,64}") || section == null
|
||||
|| section.getConfigurationSection("Rewards") == null)
|
||||
throw new IllegalArgumentException("Each milestone requires an ID and Rewards section");
|
||||
if (section.contains("Enabled") && !(section.get("Enabled") instanceof Boolean))
|
||||
throw new IllegalArgumentException("Milestone Enabled must be a boolean");
|
||||
for (String unsupported : List.of("Total", "Group", "Limit")) {
|
||||
if (section.contains(unsupported))
|
||||
throw new IllegalArgumentException(unsupported + " is not supported for date milestones");
|
||||
}
|
||||
TreeSet<Integer> thresholds = new TreeSet<>();
|
||||
if (!section.contains("At") && !section.contains("Every")) {
|
||||
int value = integer(key, false);
|
||||
if (!key.equals(Integer.toString(value))) throw new IllegalArgumentException("Legacy threshold must be canonical");
|
||||
thresholds.add(value);
|
||||
} else {
|
||||
if (section.contains("At")) {
|
||||
Object raw = section.get("At");
|
||||
List<?> values = raw instanceof List<?> list ? list : java.util.Collections.singletonList(raw);
|
||||
if (values.isEmpty() || values.size() > 64) throw new IllegalArgumentException("At requires 1–64 values");
|
||||
for (Object value : values) addAt(thresholds, value);
|
||||
}
|
||||
if (section.contains("Every")) {
|
||||
int every = integer(section.get("Every"), false);
|
||||
for (int value = every; value <= 4096; value += every) add(thresholds, value);
|
||||
}
|
||||
}
|
||||
Map<Integer, Reward> result = new LinkedHashMap<>();
|
||||
for (int value : thresholds) result.put(value, new Reward(key, section.getBoolean("Enabled", true)));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void addAt(TreeSet<Integer> values, Object raw) {
|
||||
if (raw instanceof String text) {
|
||||
var range = RANGE.matcher(text.trim());
|
||||
if (range.matches()) {
|
||||
int start = integer(range.group(1), true), end = integer(range.group(2), true);
|
||||
int step = range.group(3) == null ? 1 : integer(range.group(3), false);
|
||||
int first = Math.min(start, end), last = Math.max(start, end);
|
||||
for (int value = first; value <= last; value += step) add(values, value == 0 ? 1 : value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
int value = integer(raw, true);
|
||||
add(values, value == 0 ? 1 : value);
|
||||
}
|
||||
|
||||
private static void add(TreeSet<Integer> values, int value) {
|
||||
values.add(value);
|
||||
if (values.size() > 64) throw new IllegalArgumentException("At most 64 thresholds per event");
|
||||
}
|
||||
|
||||
private static int integer(Object raw, boolean zero) {
|
||||
// Avoid Number.intValue truncation and YAML's boolean/string-list coercions.
|
||||
if (!(raw instanceof Byte || raw instanceof Short || raw instanceof Integer || raw instanceof Long || raw instanceof String))
|
||||
throw new IllegalArgumentException("Threshold must be an integer");
|
||||
String text = raw.toString().trim();
|
||||
if (!text.matches("\\d{1,4}")) throw new IllegalArgumentException("Threshold must be an integer from 1 to 4096");
|
||||
int value = Integer.parseInt(text);
|
||||
if (value < (zero ? 0 : 1) || value > 4096) throw new IllegalArgumentException("Threshold out of range");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
-548
@@ -1,548 +0,0 @@
|
||||
package com.bencodez.votingplugin.specialrewards.datemilestones;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardOptions;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot;
|
||||
import com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
|
||||
/** Owner-defined windows, independently accounted on one backend using original occurrence IDs. */
|
||||
public final class DateVoteMilestones {
|
||||
private record Definition(DateVoteEvent event, YamlConfiguration rewards, java.util.Set<Integer> enabledThresholds,
|
||||
java.util.Map<Integer, DateVoteMilestoneConfig.Reward> milestones, boolean countFakeVotes) { }
|
||||
private final VotingPluginMain plugin;
|
||||
private volatile List<Definition> definitions = List.of();
|
||||
private final java.util.Set<UUID> pendingProgress = new java.util.HashSet<>();
|
||||
private final java.util.concurrent.atomic.AtomicInteger deferredAccounting = new java.util.concurrent.atomic.AtomicInteger();
|
||||
private final java.util.concurrent.atomic.AtomicInteger pendingAdministration = new java.util.concurrent.atomic.AtomicInteger();
|
||||
private record PlaceholderIdentity(UUID player, boolean resolveName) { }
|
||||
private final java.util.Map<PlaceholderIdentity, CachedProgress> placeholderCache = new java.util.LinkedHashMap<>(256, 0.75f, true);
|
||||
private static final class ProgressRequest {
|
||||
boolean invalidated;
|
||||
UUID resolvedStorageId;
|
||||
}
|
||||
private final java.util.Map<PlaceholderIdentity, ProgressRequest> placeholderRequests = new java.util.HashMap<>();
|
||||
private record CachedProgress(Object generation, long expires, UUID storageId, java.util.Map<String, DateVoteLedger.Progress> events) { }
|
||||
private volatile Object generation = new Object();
|
||||
private DateVoteLedger ledger;
|
||||
public DateVoteMilestones(VotingPluginMain plugin) { this.plugin = plugin; }
|
||||
private synchronized DateVoteLedger ledger() {
|
||||
if (ledger == null) ledger = new DateVoteLedger(plugin.getDataFolder().toPath().resolve("date-vote-milestones"));
|
||||
return ledger;
|
||||
}
|
||||
/** Configuration is already loaded; this method performs no disk access. */
|
||||
public void reload() {
|
||||
generation = null;
|
||||
synchronized (placeholderCache) {
|
||||
placeholderCache.clear();
|
||||
// Queued/running refreshes still own their admission until their existing finally/rejection path releases it.
|
||||
placeholderRequests.values().forEach(request -> request.invalidated = true);
|
||||
}
|
||||
List<Definition> loaded = new ArrayList<>();
|
||||
YamlConfiguration rewards = new YamlConfiguration();
|
||||
var root = plugin.getSpecialRewardsConfig().getData().getConfigurationSection("DateVoteMilestones");
|
||||
if (root != null && !root.getKeys(false).isEmpty()) {
|
||||
try { rewards.loadFromString(plugin.getSpecialRewardsConfig().getData().saveToString()); }
|
||||
catch (org.bukkit.configuration.InvalidConfigurationException invalid) {
|
||||
definitions = List.of(); generation = new Object();
|
||||
plugin.getLogger().warning("DateVoteMilestones configuration snapshot invalid"); return;
|
||||
}
|
||||
}
|
||||
int considered = 0;
|
||||
if (root != null) for (String id : root.getKeys(false)) {
|
||||
if (++considered > 64) { plugin.getLogger().warning("DateVoteMilestones supports at most 64 configured events"); break; }
|
||||
try {
|
||||
ConfigurationSection section = root.getConfigurationSection(id);
|
||||
if (section == null) throw new IllegalArgumentException("Expected an event section");
|
||||
registerRewardHandles(id, section, rewards);
|
||||
DateVoteEvent event = parse(id, section);
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord() && event.accountingServer().isBlank())
|
||||
throw new IllegalArgumentException("Proxy events require one explicit AccountingServer and all-server identified delivery");
|
||||
var configured = DateVoteMilestoneConfig.read(section);
|
||||
var enabledThresholds = configured.entrySet().stream().filter(entry -> entry.getValue().enabled())
|
||||
.map(java.util.Map.Entry::getKey).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
loaded.add(new Definition(event, rewards, enabledThresholds, java.util.Map.copyOf(configured),
|
||||
section.getBoolean("CountFakeVotes", true)));
|
||||
} catch (Exception invalid) {
|
||||
plugin.getLogger().warning("DateVoteMilestones " + id + " disabled: " + invalid.getMessage());
|
||||
}
|
||||
}
|
||||
definitions = List.copyOf(loaded);
|
||||
generation = new Object();
|
||||
}
|
||||
/** Queued rewards must resolve even when an accounting-only setting is invalid. */
|
||||
private void registerRewardHandles(String id, ConfigurationSection section, YamlConfiguration rewards) {
|
||||
String fileId = DateVoteEvent.fileId(id);
|
||||
var milestones = section.getConfigurationSection("Milestones");
|
||||
if (milestones == null) return;
|
||||
java.util.Map<Integer, DateVoteMilestoneConfig.Reward> configuredRewards = new java.util.LinkedHashMap<>();
|
||||
java.util.Set<Integer> ambiguous = new java.util.HashSet<>();
|
||||
int count = 0;
|
||||
for (String key : milestones.getKeys(false)) {
|
||||
if (++count > 64) break;
|
||||
try {
|
||||
for (var entry : DateVoteMilestoneConfig.entry(key, milestones.getConfigurationSection(key)).entrySet()) {
|
||||
if (configuredRewards.size() >= 64 && !configuredRewards.containsKey(entry.getKey())) continue;
|
||||
if (configuredRewards.putIfAbsent(entry.getKey(), entry.getValue()) != null) ambiguous.add(entry.getKey());
|
||||
}
|
||||
} catch (IllegalArgumentException invalid) { /* Keep independent valid queued reward handles. */ }
|
||||
}
|
||||
for (var entry : configuredRewards.entrySet()) {
|
||||
int threshold = entry.getKey();
|
||||
if (ambiguous.contains(threshold)) continue;
|
||||
String source = "DateVoteMilestones." + id + ".Milestones." + entry.getValue().key() + ".Rewards";
|
||||
var configured = rewards.getConfigurationSection(source);
|
||||
if (configured == null) continue;
|
||||
String alias = "DateVoteMilestonesRuntime." + fileId + ".Milestones." + threshold + ".Rewards";
|
||||
rewards.createSection(alias, configured.getValues(true));
|
||||
plugin.addDirectlyDefinedRewards(new DirectlyDefinedReward(alias) {
|
||||
private String sourcePath(String requested) {
|
||||
if (!requested.equals(alias) && !requested.startsWith(alias + "."))
|
||||
throw new IllegalArgumentException("Unexpected date milestone reward path");
|
||||
String current = currentRewardSource(plugin.getSpecialRewardsConfig().getData(), id, threshold);
|
||||
if (current == null) throw new IllegalStateException("Date milestone reward definition is unavailable");
|
||||
return current + requested.substring(alias.length());
|
||||
}
|
||||
@Override public ConfigurationSection getFileData() {
|
||||
YamlConfiguration data = new YamlConfiguration();
|
||||
var live = plugin.getSpecialRewardsConfig().getData();
|
||||
String currentSource = currentRewardSource(live, id, threshold);
|
||||
var current = currentSource == null ? null
|
||||
: live.getConfigurationSection(currentSource);
|
||||
if (current != null) data.createSection(alias, current.getValues(true));
|
||||
return data;
|
||||
}
|
||||
@Override public void createSection(String path) { plugin.getSpecialRewardsConfig().createSection(sourcePath(path)); }
|
||||
@Override public void setData(String path, Object value) { plugin.getSpecialRewardsConfig().setValue(sourcePath(path), value); }
|
||||
@Override public void save() { plugin.getSpecialRewardsConfig().saveData(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
/** Old registry handles can outlive YAML replacement during reload; resolve by stable threshold. */
|
||||
private static String currentRewardSource(ConfigurationSection live, String id, int threshold) {
|
||||
var milestones = live.getConfigurationSection("DateVoteMilestones." + id + ".Milestones");
|
||||
if (milestones == null) return null;
|
||||
String found = null;
|
||||
int count = 0;
|
||||
for (String key : milestones.getKeys(false)) {
|
||||
if (++count > 64) break;
|
||||
try {
|
||||
if (!DateVoteMilestoneConfig.entry(key, milestones.getConfigurationSection(key)).containsKey(threshold)) continue;
|
||||
if (found != null) return null;
|
||||
found = "DateVoteMilestones." + id + ".Milestones." + key + ".Rewards";
|
||||
} catch (IllegalArgumentException invalid) { /* An unrelated invalid entry does not hide queued rewards. */ }
|
||||
}
|
||||
return found;
|
||||
}
|
||||
public static DateVoteEvent parse(String id, ConfigurationSection section) {
|
||||
if (section.contains("CountFakeVotes") && !section.isBoolean("CountFakeVotes"))
|
||||
throw new IllegalArgumentException("CountFakeVotes must be a boolean");
|
||||
String zone = section.getString("Timezone");
|
||||
String start = section.getString("Start"), end = section.getString("End");
|
||||
var milestones = section.getConfigurationSection("Milestones");
|
||||
if (zone == null || start == null || end == null || milestones == null)
|
||||
throw new IllegalArgumentException("Start, End, Timezone and Milestones are required");
|
||||
List<Integer> thresholds = new ArrayList<>(DateVoteMilestoneConfig.read(section).keySet());
|
||||
Object rawSites = section.get("VoteSites");
|
||||
if (rawSites != null && (!(rawSites instanceof List<?> sites)
|
||||
|| sites.stream().anyMatch(site -> !(site instanceof String))))
|
||||
throw new IllegalArgumentException("VoteSites must be a list of configured site keys");
|
||||
return new DateVoteEvent(id, section.getString("DisplayName", id), section.getBoolean("Enabled", false),
|
||||
DateVoteEvent.timestamp(start, zone), DateVoteEvent.timestamp(end, zone), zone, thresholds,
|
||||
new java.util.HashSet<>(section.getStringList("VoteSites")), section.getString("AccountingServer", ""));
|
||||
}
|
||||
static String path(DateVoteEvent event, int threshold) {
|
||||
return "DateVoteMilestonesRuntime." + event.fileId() + ".Milestones." + threshold + ".Rewards";
|
||||
}
|
||||
/** Owner-thread extension events use the existing ordered storage worker without waiting on gameplay. */
|
||||
public boolean deferAccepted(VotingPluginUser user, String site, UUID occurrence, long occurredAt,
|
||||
boolean real, boolean proxy, boolean canonicalProxy, boolean targeted, boolean forceProxyRouting) {
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord() && !proxy) return true;
|
||||
if (occurrence == null || occurredAt <= 0 || proxy && (!canonicalProxy || targeted)) return true;
|
||||
List<Definition> snapshot = definitions;
|
||||
if (snapshot.stream().noneMatch(definition -> definition.event().matches(occurredAt, site,
|
||||
real || definition.countFakeVotes(), proxy,
|
||||
plugin.getBungeeSettings().getServer()))) return true;
|
||||
int pending;
|
||||
do {
|
||||
pending = deferredAccounting.get();
|
||||
if (pending >= 64) {
|
||||
plugin.getLogger().warning("DateVoteMilestones deferred accounting is busy; occurrence remains unconfirmed.");
|
||||
return false;
|
||||
}
|
||||
} while (!deferredAccounting.compareAndSet(pending, pending + 1));
|
||||
try {
|
||||
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
|
||||
try {
|
||||
if (!plugin.isEnabled() || snapshot != definitions) return;
|
||||
accepted(user, site, occurrence, occurredAt, real, proxy, canonicalProxy, targeted, forceProxyRouting);
|
||||
} finally { deferredAccounting.decrementAndGet(); }
|
||||
});
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
deferredAccounting.decrementAndGet();
|
||||
plugin.getLogger().warning("DateVoteMilestones deferred accounting was rejected; occurrence remains unconfirmed.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Called only from the asynchronous accepted-vote pipeline; never from navigation. */
|
||||
public boolean accepted(VotingPluginUser user, String site, UUID occurrence, long occurredAt,
|
||||
boolean real, boolean proxy, boolean canonicalProxy, boolean targeted, boolean forceProxyRouting) {
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord() && !proxy) return true;
|
||||
if (occurrence == null || occurredAt <= 0 || proxy && (!canonicalProxy || targeted)) return true;
|
||||
boolean recorded = true;
|
||||
for (Definition definition : definitions) {
|
||||
DateVoteEvent event = definition.event();
|
||||
if (!event.matches(occurredAt, site, real || definition.countFakeVotes(), proxy,
|
||||
plugin.getBungeeSettings().getServer())) continue;
|
||||
List<Integer> reached;
|
||||
try {
|
||||
reached = ledger().record(event, user.getJavaUUID(), occurrence, plugin.getOptions().isProcessRewards());
|
||||
invalidateProgress(user.getJavaUUID());
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
recorded = false;
|
||||
plugin.getLogger().warning("DateVoteMilestones " + event.id() + " occurrence was not confirmed; proxy delivery must remain quarantined for accounting reconciliation: " + failure.getMessage());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for (int threshold : reached) {
|
||||
if (!plugin.getOptions().isProcessRewards()) break;
|
||||
if (!definition.enabledThresholds().contains(threshold)) continue;
|
||||
var placeholders = new HashMap<String, String>();
|
||||
placeholders.put("DateVoteEvent", event.displayName());
|
||||
placeholders.put("DateVoteThreshold", Integer.toString(threshold));
|
||||
if (!ledger().reserve(event, user.getJavaUUID(), threshold)) continue;
|
||||
plugin.getRewardHandler().giveReward(user, definition.rewards(), path(event, threshold),
|
||||
new RewardOptions().setPrefix("DateVoteMilestones-" + event.id() + "-" + threshold)
|
||||
.setServer(forceProxyRouting).setPlaceholders(placeholders));
|
||||
// Submission is not a transactional guarantee of arbitrary command execution.
|
||||
ledger().submitted(event, user.getJavaUUID(), threshold);
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
plugin.getLogger().warning("DateVoteMilestones " + event.id() + " accounting/reward submission failed; progress retained for review: " + failure.getMessage());
|
||||
}
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
public enum AdminAction { Status, Preview, SetVotes, Force, ForceNoLimits }
|
||||
|
||||
/** Admission is not success: all identity/storage/reward work runs on the ordered worker. */
|
||||
public void administer(CommandSender sender, AdminAction action, String player, String eventId, String argument) {
|
||||
Object epoch = generation;
|
||||
dispatchAdmin(sender, action, () -> {
|
||||
if (!current(epoch) || !adminAllowed(sender, action)) return;
|
||||
if (!validPlayer(player) || eventId == null || !eventId.matches("[A-Za-z0-9_-]{1,64}")) {
|
||||
sender.sendMessage("Invalid exact player or event ID."); return;
|
||||
}
|
||||
int count;
|
||||
do { count = pendingAdministration.get();
|
||||
if (count >= 64) { sender.sendMessage("Date milestone administration is busy. Try again shortly."); return; }
|
||||
} while (!pendingAdministration.compareAndSet(count, count + 1));
|
||||
try {
|
||||
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
|
||||
try {
|
||||
if (!current(epoch)) return;
|
||||
List<String> result = administerOnWorker(action, player, eventId, argument, epoch);
|
||||
dispatchAdmin(sender, action, () -> {
|
||||
if (current(epoch) && adminAllowed(sender, action)) result.forEach(sender::sendMessage);
|
||||
});
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
dispatchAdmin(sender, action, () -> {
|
||||
if (current(epoch) && adminAllowed(sender, action))
|
||||
sender.sendMessage("Date milestone operation failed; inspect retained progress before retrying rewards.");
|
||||
});
|
||||
} finally { pendingAdministration.decrementAndGet(); }
|
||||
});
|
||||
} catch (RejectedExecutionException unavailable) {
|
||||
pendingAdministration.decrementAndGet();
|
||||
sender.sendMessage("Date milestone storage is unavailable; nothing was submitted.");
|
||||
}
|
||||
});
|
||||
}
|
||||
private boolean current(Object epoch) {
|
||||
return epoch != null && epoch == generation && plugin.isEnabled() && plugin.getDateVoteMilestones() == this;
|
||||
}
|
||||
private static boolean validPlayer(String player) {
|
||||
return player != null && (player.matches("[A-Za-z0-9_]{1,16}")
|
||||
|| player.matches("[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"));
|
||||
}
|
||||
private boolean adminAllowed(CommandSender sender, AdminAction action) {
|
||||
return (!(sender instanceof Player p) || p.isOnline()) && (sender.hasPermission("VotingPlugin.Admin")
|
||||
|| sender.hasPermission("VotingPlugin.Commands.AdminVote.DateVoteMilestone." + action.name()));
|
||||
}
|
||||
private void dispatchAdmin(CommandSender sender, AdminAction action, Runnable effect) {
|
||||
Player player = sender instanceof Player p ? p : null;
|
||||
Runnable checked = () -> { if (plugin.isEnabled() && adminAllowed(sender, action)) effect.run(); };
|
||||
BukkitCompletionScheduler.run(plugin, player, checked, player == null ? checked : () -> { }, () -> { });
|
||||
}
|
||||
private List<String> administerOnWorker(AdminAction action, String player, String eventId, String argument,
|
||||
Object epoch) throws IOException {
|
||||
Definition definition = definitions.stream().filter(d -> d.event().id().equals(eventId)).findFirst().orElse(null);
|
||||
if (definition == null) return List.of("Unknown or invalid date event ID.");
|
||||
DateVoteEvent event = definition.event();
|
||||
boolean network = plugin.getBungeeSettings().isUseBungeecoord();
|
||||
if (network && !event.accountingServer().equals(plugin.getBungeeSettings().getServer()))
|
||||
return List.of("Progress is remote; run this command on the configured AccountingServer.");
|
||||
Integer value = null;
|
||||
List<Integer> selected = List.of();
|
||||
if (action == AdminAction.SetVotes) {
|
||||
if (argument == null || !argument.matches("0|[1-9][0-9]{0,3}") || Integer.parseInt(argument) > 4096)
|
||||
return List.of("Votes must be an integer from 0 to 4096.");
|
||||
value = Integer.parseInt(argument);
|
||||
} else if (action == AdminAction.Force || action == AdminAction.ForceNoLimits) {
|
||||
if (!event.enabled() || !plugin.getOptions().isProcessRewards())
|
||||
return List.of("Event or reward processing is disabled; no reward was submitted.");
|
||||
if (argument == null || !argument.matches("[A-Za-z0-9_-]{1,64}")) return List.of("Invalid milestone ID or threshold.");
|
||||
String selector = argument;
|
||||
// A named At/Every entry can expand into several independently fenced thresholds.
|
||||
selected = event.thresholds().stream().filter(n -> definition.milestones().get(n).key().equals(selector)).toList();
|
||||
if (selected.isEmpty()) selected = event.thresholds().stream().filter(n -> Integer.toString(n).equals(selector)).toList();
|
||||
if (selected.isEmpty()) return List.of("Unknown milestone ID or threshold.");
|
||||
}
|
||||
VotingPluginUser user = player.length() == 36
|
||||
? plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(player), false)
|
||||
: plugin.getVotingPluginUserManager().getVotingPluginUser(player);
|
||||
UUID storageId = user.getJavaUUID();
|
||||
if (!current(epoch)) return List.of();
|
||||
if (action == AdminAction.SetVotes) {
|
||||
var result = ledger().setVotes(event, storageId, value);
|
||||
invalidateProgress(storageId);
|
||||
return List.of("Date event " + eventId + " votes set to " + result.votes()
|
||||
+ "; receipt IDs and reward fences retained. No rewards were submitted.");
|
||||
}
|
||||
if (action == AdminAction.Force || action == AdminAction.ForceNoLimits) {
|
||||
int submitted = 0;
|
||||
for (int threshold : selected) {
|
||||
if (!current(epoch) || !plugin.getOptions().isProcessRewards()) break;
|
||||
if (!definition.enabledThresholds().contains(threshold)) continue;
|
||||
if (!ledger().reserveForced(event, storageId, threshold, action == AdminAction.ForceNoLimits)) continue;
|
||||
invalidateProgress(storageId);
|
||||
var placeholders = new HashMap<String, String>();
|
||||
placeholders.put("DateVoteEvent", event.displayName());
|
||||
placeholders.put("DateVoteThreshold", Integer.toString(threshold));
|
||||
plugin.getRewardHandler().giveReward(user, definition.rewards(), path(event, threshold),
|
||||
new RewardOptions().setPrefix("DateVoteMilestones-" + event.id() + "-" + threshold)
|
||||
.setServer(network).setPlaceholders(placeholders));
|
||||
ledger().submitted(event, storageId, threshold);
|
||||
submitted++;
|
||||
}
|
||||
return List.of("Date event " + eventId + ": submitted " + submitted + " of " + selected.size()
|
||||
+ " selected thresholds; disabled or fenced thresholds skipped. Vote count unchanged.");
|
||||
}
|
||||
var progress = ledger().progress(event, storageId);
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("Date event " + eventId + ": votes=" + progress.votes() + ", CountFakeVotes=" + definition.countFakeVotes()
|
||||
+ ", status=" + (!event.enabled() ? "disabled" : System.currentTimeMillis() < event.start() ? "upcoming"
|
||||
: System.currentTimeMillis() >= event.end() ? "ended" : "active")
|
||||
+ ". Stored in date-vote-milestones/<event-file-id>-<player-uuid>.properties.");
|
||||
for (int threshold : event.thresholds()) lines.add("Milestone " + definition.milestones().get(threshold).key()
|
||||
+ " at " + threshold + ": " + (progress.reservedAwards().contains(threshold) ? "pending review"
|
||||
: progress.submittedAwards().contains(threshold) ? "submitted"
|
||||
: progress.deferredAwards().contains(threshold) ? "deferred" : "not reached")
|
||||
+ (definition.enabledThresholds().contains(threshold) ? "" : " (disabled)"));
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
private void invalidateProgress(UUID player) {
|
||||
synchronized (placeholderCache) {
|
||||
placeholderCache.entrySet().removeIf(entry -> player.equals(entry.getValue().storageId()));
|
||||
// Unresolved names have not read the ledger yet; their later read
|
||||
// will observe this mutation. Once resolved, fence matching reads.
|
||||
placeholderRequests.values().forEach(request -> {
|
||||
if (player.equals(request.resolvedStorageId)) request.invalidated = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Nonblocking projection using a previously captured storage identity. */
|
||||
public String placeholder(UUID player, String request) {
|
||||
return placeholder(player, null, request);
|
||||
}
|
||||
|
||||
/** Named offline identity is resolved only on the persistence worker. */
|
||||
public String placeholder(UUID player, java.util.function.Supplier<String> offlineName, String request) {
|
||||
if (player == null || request == null || request.length() > 100 || !plugin.isEnabled()) return "unavailable";
|
||||
PlaceholderIdentity identity = new PlaceholderIdentity(player, offlineName != null);
|
||||
int split = request.indexOf('_');
|
||||
if (split < 1) return "invalid";
|
||||
String metric = request.substring(0, split).toLowerCase(java.util.Locale.ROOT);
|
||||
String id = request.substring(split + 1);
|
||||
int threshold = -1;
|
||||
if (metric.equals("award")) {
|
||||
split = id.indexOf('_');
|
||||
if (split < 1 || !id.substring(0, split).matches("[1-9][0-9]{0,3}")) return "invalid";
|
||||
threshold = Integer.parseInt(id.substring(0, split)); id = id.substring(split + 1);
|
||||
} else if (!List.of("votes", "next", "status").contains(metric)) return "invalid";
|
||||
String eventId = id;
|
||||
Definition definition = definitions.stream().filter(d -> d.event().id().equals(eventId)).findFirst().orElse(null);
|
||||
if (definition == null) return "invalid";
|
||||
DateVoteEvent event = definition.event();
|
||||
if (metric.equals("status")) return !event.enabled() ? "disabled" : System.currentTimeMillis() < event.start()
|
||||
? "upcoming" : System.currentTimeMillis() >= event.end() ? "ended" : "active";
|
||||
if (metric.equals("award") && !event.thresholds().contains(threshold)) return "invalid";
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord() && !event.accountingServer().equals(plugin.getBungeeSettings().getServer()))
|
||||
return "remote";
|
||||
Object epoch = generation;
|
||||
if (epoch == null) return "unavailable";
|
||||
CachedProgress cached;
|
||||
ProgressRequest launch = null;
|
||||
synchronized (placeholderCache) {
|
||||
cached = placeholderCache.get(identity);
|
||||
if (cached == null || cached.generation() != epoch || System.nanoTime() >= cached.expires()) {
|
||||
cached = null;
|
||||
if (!placeholderRequests.containsKey(identity)) {
|
||||
if (placeholderRequests.size() >= 64) return "unavailable";
|
||||
launch = new ProgressRequest();
|
||||
if (!identity.resolveName()) launch.resolvedStorageId = player;
|
||||
placeholderRequests.put(identity, launch);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (launch != null && !refreshPlaceholder(identity, offlineName, epoch, definitions, launch)) return "unavailable";
|
||||
if (cached == null) return "loading";
|
||||
var progress = cached.events().get(eventId);
|
||||
if (progress == null) return "unavailable";
|
||||
if (metric.equals("votes")) return Integer.toString(progress.votes());
|
||||
if (metric.equals("next")) return event.thresholds().stream().filter(n -> n > progress.votes()
|
||||
&& definition.enabledThresholds().contains(n)).findFirst().map(Object::toString).orElse("0");
|
||||
return progress.reservedAwards().contains(threshold) ? "pending_review" : progress.submittedAwards().contains(threshold)
|
||||
? "submitted" : progress.deferredAwards().contains(threshold) ? "deferred" : "not_reached";
|
||||
}
|
||||
private boolean refreshPlaceholder(PlaceholderIdentity identity, java.util.function.Supplier<String> offlineName, Object epoch, List<Definition> snapshot, ProgressRequest request) {
|
||||
try {
|
||||
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
|
||||
try {
|
||||
if (!current(epoch)) return;
|
||||
UUID storageId;
|
||||
try {
|
||||
String name = offlineName == null ? null : offlineName.get();
|
||||
storageId = name == null || name.isBlank() ? identity.player()
|
||||
: plugin.getVotingPluginUserManager().getVotingPluginUser(identity.player(), name).getJavaUUID();
|
||||
if (storageId == null) throw new IllegalStateException("Missing storage identity");
|
||||
synchronized (placeholderCache) {
|
||||
if (!current(epoch) || request.invalidated || placeholderRequests.get(identity) != request) return;
|
||||
request.resolvedStorageId = storageId;
|
||||
}
|
||||
} catch (RuntimeException unavailable) {
|
||||
synchronized (placeholderCache) {
|
||||
if (current(epoch) && !request.invalidated && placeholderRequests.get(identity) == request) {
|
||||
if (placeholderCache.size() >= 256) placeholderCache.remove(placeholderCache.keySet().iterator().next());
|
||||
placeholderCache.put(identity, new CachedProgress(epoch, System.nanoTime() + 1_000_000_000L, null, java.util.Map.of()));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
java.util.Map<String, DateVoteLedger.Progress> results = new HashMap<>();
|
||||
for (Definition definition : snapshot) {
|
||||
var event = definition.event();
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord()
|
||||
&& !event.accountingServer().equals(plugin.getBungeeSettings().getServer())) continue;
|
||||
try { results.put(event.id(), ledger().progress(event, storageId)); }
|
||||
catch (IOException | RuntimeException unavailable) { /* Missing entry means unavailable, never zero. */ }
|
||||
}
|
||||
synchronized (placeholderCache) {
|
||||
if (!current(epoch) || request.invalidated || placeholderRequests.get(identity) != request) return;
|
||||
if (placeholderCache.size() >= 256) placeholderCache.remove(placeholderCache.keySet().iterator().next());
|
||||
placeholderCache.put(identity, new CachedProgress(epoch, System.nanoTime() + 5_000_000_000L, storageId, java.util.Map.copyOf(results)));
|
||||
}
|
||||
} finally { synchronized (placeholderCache) { placeholderRequests.remove(identity, request); } }
|
||||
});
|
||||
return true;
|
||||
} catch (RuntimeException unavailable) {
|
||||
synchronized (placeholderCache) {
|
||||
if (current(epoch) && placeholderRequests.get(identity) == request) {
|
||||
if (placeholderCache.size() >= 256) placeholderCache.remove(placeholderCache.keySet().iterator().next());
|
||||
placeholderCache.put(identity, new CachedProgress(epoch, System.nanoTime() + 1_000_000_000L, null, java.util.Map.of()));
|
||||
}
|
||||
placeholderRequests.remove(identity, request);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public void progress(Player player) {
|
||||
progress(player, (view, current) -> com.bencodez.votingplugin.commands.gui.player.DateVoteEventsMenu.open(plugin, player, view, current));
|
||||
}
|
||||
/** Display is an entity-owned effect; identity resolution and ledger reads stay on the storage worker. */
|
||||
void progress(Player player, java.util.function.BiConsumer<DateVoteProgressSnapshot, java.util.function.BooleanSupplier> display) {
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
UUID uuid = player.getUniqueId();
|
||||
String playerName = player.getName();
|
||||
if (!canView(player)) return;
|
||||
synchronized (pendingProgress) {
|
||||
if (pendingProgress.contains(uuid) || pendingProgress.size() >= 64) {
|
||||
player.sendMessage("Date voting progress is already being checked or busy. Try again shortly."); return;
|
||||
}
|
||||
pendingProgress.add(uuid);
|
||||
}
|
||||
List<Definition> snapshot = definitions;
|
||||
try {
|
||||
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
|
||||
try {
|
||||
if (snapshot != definitions || !plugin.isEnabled()) return;
|
||||
boolean proxyBackend = plugin.getBungeeSettings().isUseBungeecoord();
|
||||
String server = plugin.getBungeeSettings().getServer();
|
||||
boolean localProgress = snapshot.stream().anyMatch(d -> !proxyBackend || d.event().accountingServer().equals(server));
|
||||
UUID storageId = !localProgress ? uuid
|
||||
: plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, playerName).getJavaUUID();
|
||||
List<DateVoteProgressSnapshot.Event> events = new ArrayList<>();
|
||||
long now = System.currentTimeMillis();
|
||||
for (Definition definition : snapshot) {
|
||||
var event = definition.event();
|
||||
var milestones = event.thresholds().stream().map(threshold -> {
|
||||
var configured = definition.milestones().get(threshold);
|
||||
var rewards = definition.rewards().getConfigurationSection(path(event, threshold));
|
||||
var types = rewards == null ? List.<String>of() : rewards.getKeys(false).stream()
|
||||
.filter(key -> key.length() <= 64).limit(16).toList();
|
||||
return new DateVoteProgressSnapshot.Milestone(threshold, configured.key(), configured.enabled(), types);
|
||||
}).toList();
|
||||
if (proxyBackend && !event.accountingServer().equals(server)) {
|
||||
events.add(view(event, DateVoteProgressSnapshot.Availability.REMOTE, null, milestones)); continue;
|
||||
}
|
||||
try {
|
||||
var progress = ledger().progress(event, storageId);
|
||||
events.add(view(event, DateVoteProgressSnapshot.Availability.OWNED, progress, milestones));
|
||||
} catch (IOException failure) {
|
||||
events.add(view(event, DateVoteProgressSnapshot.Availability.UNAVAILABLE, null, milestones));
|
||||
}
|
||||
}
|
||||
var result = new DateVoteProgressSnapshot(now, events);
|
||||
java.util.function.BooleanSupplier current = () -> canView(player) && snapshot == definitions
|
||||
&& plugin.getDateVoteMilestones() == this;
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
if (current.getAsBoolean()) display.accept(result, current);
|
||||
}, () -> { }, () -> { });
|
||||
} catch (RuntimeException unavailable) {
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
if (canView(player) && snapshot == definitions)
|
||||
player.sendMessage("Date voting progress is temporarily unavailable.");
|
||||
}, () -> { }, () -> { });
|
||||
} finally { synchronized (pendingProgress) { pendingProgress.remove(uuid); } }
|
||||
});
|
||||
} catch (RejectedExecutionException unavailable) {
|
||||
synchronized (pendingProgress) { pendingProgress.remove(uuid); }
|
||||
player.sendMessage("Date voting progress is temporarily unavailable.");
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
}
|
||||
private boolean canView(Player player) {
|
||||
return plugin.isEnabled() && player.isOnline()
|
||||
&& (player.hasPermission("VotingPlugin.Commands.Vote.DateEvents") || player.hasPermission("VotingPlugin.Player"));
|
||||
}
|
||||
private static DateVoteProgressSnapshot.Event view(DateVoteEvent event, DateVoteProgressSnapshot.Availability availability,
|
||||
DateVoteLedger.Progress progress, List<DateVoteProgressSnapshot.Milestone> milestones) {
|
||||
return new DateVoteProgressSnapshot.Event(event, availability, progress == null ? 0 : progress.votes(),
|
||||
progress == null ? java.util.Set.of() : progress.submittedAwards(),
|
||||
progress == null ? java.util.Set.of() : progress.deferredAwards(),
|
||||
progress == null ? java.util.Set.of() : progress.reservedAwards(), milestones);
|
||||
}
|
||||
}
|
||||
+3
-13
@@ -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);
|
||||
@@ -244,16 +243,7 @@ public class VoteParty implements Listener {
|
||||
*/
|
||||
public void giveRewards(VotingPluginUser orgUser, boolean forceBungee) {
|
||||
rewardHandler.giveRewards(orgUser, forceBungee);
|
||||
// Clear this party's recipient list immediately, as before. Delaying it
|
||||
// would risk wiping voters recorded for the next party.
|
||||
setVotedUsers(new ArrayList<>());
|
||||
// Shared-MySQL recipients capture eligibility on AdvancedCore's ordered
|
||||
// storage worker. Only the persisted per-user counter wipe must run
|
||||
// behind those reads; it must not overtake deferred eligibility checks.
|
||||
com.bencodez.advancedcore.api.user.usercache.UserDataManager manager =
|
||||
plugin.getUserManager().getDataManager();
|
||||
if (manager != null && manager.deferSharedStorageWork(this::resetVotePartyCount)) return;
|
||||
resetVotePartyCount();
|
||||
reset(false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-104
@@ -3,8 +3,6 @@ package com.bencodez.votingplugin.specialrewards.voteparty;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
@@ -13,7 +11,6 @@ import com.bencodez.advancedcore.api.messages.PlaceholderUtils;
|
||||
import com.bencodez.advancedcore.api.misc.MiscUtils;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardBuilder;
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
import com.bencodez.advancedcore.api.user.usercache.UserDataManager;
|
||||
import com.bencodez.simpleapi.array.ArrayUtils;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
@@ -40,15 +37,10 @@ public final class VotePartyRewardHandler {
|
||||
}
|
||||
|
||||
public void giveReward(VotingPluginUser user, boolean online, boolean useBungee) {
|
||||
givePreparedReward(user, online, useBungee, !plugin.getServerData().isLastVotePartySameDay());
|
||||
}
|
||||
|
||||
private void givePreparedReward(VotingPluginUser user, boolean online, boolean useBungee,
|
||||
boolean firstVotePartyToday) {
|
||||
new RewardBuilder(plugin.getSpecialRewardsConfig().getData(),
|
||||
plugin.getSpecialRewardsConfig().getVotePartyRewardsPath()).setOnline(online)
|
||||
.withPlaceHolder("VotesRequired", "" + plugin.getSpecialRewardsConfig().getVotePartyVotesRequired())
|
||||
.withPlaceHolder("FirstVotePartyToday", "" + firstVotePartyToday)
|
||||
.withPlaceHolder("FirstVotePartyToday", "" + !plugin.getServerData().isLastVotePartySameDay())
|
||||
.setServer(useBungee).send(user);
|
||||
}
|
||||
|
||||
@@ -90,14 +82,13 @@ public final class VotePartyRewardHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch to every eligible user, preserving each online player's scheduler owner. */
|
||||
private void giveAllPlayers(VotingPluginUser triggeringUser, boolean forceBungee) {
|
||||
plugin.debug("Trying to give all players vote party");
|
||||
ArrayList<String> alreadyGiven = new ArrayList<>();
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
VotingPluginUser user = resolveUser(triggeringUser, player.getUniqueId(), player);
|
||||
if (!plugin.getSpecialRewardsConfig().isVotePartyGiveOnlinePlayersOnly() || user.isOnline()) {
|
||||
giveRewardWhenLoaded(user, forceBungee, player);
|
||||
voteParty.giveReward(user, forceBungee);
|
||||
}
|
||||
alreadyGiven.add(player.getUniqueId().toString());
|
||||
}
|
||||
@@ -108,115 +99,30 @@ public final class VotePartyRewardHandler {
|
||||
}
|
||||
VotingPluginUser user = resolveUser(triggeringUser, UUID.fromString(uuid), null);
|
||||
if (!plugin.getSpecialRewardsConfig().isVotePartyGiveOnlinePlayersOnly() || user.isOnline()) {
|
||||
giveRewardWhenLoaded(user, forceBungee, null);
|
||||
voteParty.giveReward(user, forceBungee);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch to users recorded for this party before its voter list is reset. */
|
||||
private void giveVotedPlayers(VotingPluginUser triggeringUser, boolean forceBungee) {
|
||||
plugin.debug("Trying to give all voted players vote party");
|
||||
plugin.debug(ArrayUtils.makeStringList(voteParty.getVotedUsers()));
|
||||
for (String uuid : voteParty.getVotedUsers()) {
|
||||
VotingPluginUser user = resolveUser(triggeringUser, UUID.fromString(uuid), null);
|
||||
if (!plugin.getSpecialRewardsConfig().isVotePartyGiveOnlinePlayersOnly() || user.isOnline()) {
|
||||
// Capture a live player only on a platform-owned thread. The vote path
|
||||
// can also originate from a storage worker, where Bukkit lookups are unsafe.
|
||||
UserDataManager manager = sharedDataManager();
|
||||
Player owner = manager != null && manager.isPlatformOwnedThread()
|
||||
? Bukkit.getPlayer(user.getJavaUUID()) : null;
|
||||
giveRewardWhenLoaded(user, forceBungee, owner);
|
||||
voteParty.giveReward(user, forceBungee);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a recipient without forcing an uncached SQL read for shared storage. */
|
||||
private VotingPluginUser resolveUser(VotingPluginUser triggeringUser, UUID uuid, Player onlinePlayer) {
|
||||
VotingPluginUser user = triggeringUser != null && triggeringUser.getJavaUUID().equals(uuid)
|
||||
? triggeringUser
|
||||
: onlinePlayer == null
|
||||
? plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true)
|
||||
: plugin.getVotingPluginUserManager().getVotingPluginUser(onlinePlayer);
|
||||
if (sharedDataManager() != null) {
|
||||
// Reward placeholders need the published shared cache, including for the triggering user.
|
||||
user.userDataFetechMode(UserDataFetchMode.DEFAULT);
|
||||
} else if (user != triggeringUser) {
|
||||
// Preserve direct reads for file/SQLite storage.
|
||||
user.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
if (triggeringUser != null && triggeringUser.getJavaUUID().equals(uuid)) {
|
||||
return triggeringUser;
|
||||
}
|
||||
VotingPluginUser user = onlinePlayer == null
|
||||
? plugin.getVotingPluginUserManager().getVotingPluginUser(uuid)
|
||||
: plugin.getVotingPluginUserManager().getVotingPluginUser(onlinePlayer);
|
||||
user.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Returns the active shared SQL manager or null for nonshared storage. */
|
||||
private UserDataManager sharedDataManager() {
|
||||
UserDataManager manager = plugin.getUserManager().getDataManager();
|
||||
return manager != null && manager.hasSharedSqlBackend() ? manager : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture shared SQL reward eligibility on AdvancedCore's storage worker.
|
||||
* This must finish before the VoteParty counter wipe is enqueued on that worker.
|
||||
* A caller running on a platform thread must not check even a published cache
|
||||
* directly because checking publication takes the cache's synchronized monitor.
|
||||
*/
|
||||
private void giveRewardWhenLoaded(VotingPluginUser user, boolean forceBungee, Player callbackOwner) {
|
||||
UserDataManager manager = sharedDataManager();
|
||||
if (manager == null) {
|
||||
voteParty.giveReward(user, forceBungee);
|
||||
return;
|
||||
}
|
||||
UUID uuid = user.getJavaUUID();
|
||||
|
||||
// VoteParty clears per-user counters and updates the last-party timestamp
|
||||
// after dispatch. Capture these decisions before that reset, not when the
|
||||
// deferred reward finally runs on the player scheduler.
|
||||
final boolean firstPartyToday = !plugin.getServerData().isLastVotePartySameDay();
|
||||
final int userVotesRequired = plugin.getSpecialRewardsConfig().getVotePartyUserVotesRequired();
|
||||
|
||||
// A mapped cache entry can still be an *unpublished* placeholder, so
|
||||
// cacheUserIfNeeded() alone would not make reward placeholders safe to read.
|
||||
Supplier<Integer> populate = () -> {
|
||||
if (manager.getPublishedCache(uuid) == null) manager.cacheUser(uuid, null);
|
||||
if (manager.getPublishedCache(uuid) == null) return null;
|
||||
// Read eligibility on the storage worker, before VoteParty resets counters.
|
||||
return userVotesRequired > 0 ? user.getVotePartyVotes() : 0;
|
||||
};
|
||||
Consumer<Integer> success = votes -> {
|
||||
if (votes == null) {
|
||||
plugin.getLogger().warning("VoteParty reward not sent: shared user data is unavailable for " + uuid);
|
||||
return;
|
||||
}
|
||||
if (votes < userVotesRequired) return;
|
||||
|
||||
// Eligibility was captured on the storage worker before reset. Do not
|
||||
// re-read the cache on a platform thread or re-check reset counters.
|
||||
Runnable deliver = () -> givePreparedReward(user, user.isOnline(), forceBungee, firstPartyToday);
|
||||
if (callbackOwner != null) {
|
||||
deliver.run();
|
||||
} else {
|
||||
// A worker-originated vote has no captured Player. Its completion is
|
||||
// on the global platform scheduler; discover a live owner there.
|
||||
Player online = Bukkit.getPlayer(uuid);
|
||||
if (online == null) deliver.run();
|
||||
else plugin.getBukkitScheduler().runTask(plugin, deliver, online);
|
||||
}
|
||||
};
|
||||
Consumer<Throwable> failure = error -> plugin.getLogger()
|
||||
.warning("Failed to load shared user data for VoteParty reward " + uuid + ": " + error);
|
||||
|
||||
try {
|
||||
if (manager.deferSharedStorageResult(populate, success, failure, callbackOwner)) return;
|
||||
|
||||
// Called from a non-platform worker: storage reads are safe here, but reward
|
||||
// effects still need the Bukkit/Folia scheduler.
|
||||
Integer ready = populate.get();
|
||||
if (callbackOwner != null) {
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> success.accept(ready), callbackOwner);
|
||||
} else {
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> success.accept(ready));
|
||||
}
|
||||
} catch (RuntimeException | Error error) {
|
||||
failure.accept(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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() {
|
||||
|
||||
+35
-289
@@ -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();
|
||||
|
||||
@@ -66,23 +48,10 @@ public class TimeQueueHandler implements Listener {
|
||||
* @param voteUsername the voter username
|
||||
* @param voteSiteName the vote site name
|
||||
*/
|
||||
public void addVote(String voteUsername, String voteSiteName) { addVote(voteUsername, voteSiteName, 0L); }
|
||||
|
||||
public void addVote(String voteUsername, String voteSiteName, long occurredAt) {
|
||||
addVote(voteUsername, voteSiteName, occurredAt, UUID.randomUUID());
|
||||
}
|
||||
|
||||
public void addVote(String voteUsername, String voteSiteName, long occurredAt, UUID localOccurrenceId) {
|
||||
java.util.Objects.requireNonNull(localOccurrenceId, "localOccurrenceId");
|
||||
VoteTimeQueue queued = new VoteTimeQueue(voteUsername, voteSiteName,
|
||||
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
|
||||
queued.setLocalOccurrenceId(localOccurrenceId);
|
||||
if (occurredAt > 0) queued.setCanonicalOccurrenceTime(occurredAt);
|
||||
synchronized (persistenceLock) {
|
||||
timeChangeQueue.add(queued);
|
||||
if (stopping) persistPendingVotes();
|
||||
}
|
||||
}
|
||||
public void addVote(String voteUsername, String voteSiteName) {
|
||||
timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName,
|
||||
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads cached votes from server data and schedules queue processing.
|
||||
@@ -90,29 +59,8 @@ public class TimeQueueHandler implements Listener {
|
||||
public void load() {
|
||||
for (String str : plugin.getServerData().getTimedVoteCacheKeys()) {
|
||||
ConfigurationSection data = plugin.getServerData().getTimedVoteCacheSection(str);
|
||||
VoteTimeQueue queued = new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time"));
|
||||
UUID localOccurrenceId;
|
||||
if (!data.contains("LocalOccurrenceId")) {
|
||||
// The existing ServerData saver can defer publication. Derive only
|
||||
// legacy migration IDs from their persisted row identity so a crash
|
||||
// before that save completes cannot reroll the same legacy row.
|
||||
String identity = "legacy-local-timed-vote\u0000" + str + "\u0000" + queued.getName()
|
||||
+ "\u0000" + queued.getService() + "\u0000" + queued.getTime();
|
||||
localOccurrenceId = UUID.nameUUIDFromBytes(identity.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
plugin.getServerData().setTimedVoteLocalOccurrenceId(str, localOccurrenceId);
|
||||
} else {
|
||||
String rawId = data.getString("LocalOccurrenceId");
|
||||
try {
|
||||
localOccurrenceId = UUID.fromString(rawId);
|
||||
if (!localOccurrenceId.toString().equalsIgnoreCase(rawId)) throw new IllegalArgumentException();
|
||||
} catch (IllegalArgumentException | NullPointerException malformed) {
|
||||
plugin.getLogger().warning("Invalid local occurrence ID in timed vote cache; row withheld");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
queued.setLocalOccurrenceId(localOccurrenceId);
|
||||
if (data.contains("CanonicalOccurrenceTime")) queued.setCanonicalOccurrenceTime(data.getLong("CanonicalOccurrenceTime", -1L));
|
||||
timeChangeQueue.add(queued);
|
||||
timeChangeQueue
|
||||
.add(new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time")));
|
||||
}
|
||||
scheduleQueueProcessing(120, TimeUnit.SECONDS);
|
||||
}
|
||||
@@ -128,11 +76,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,14 +94,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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,241 +110,32 @@ 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());
|
||||
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());
|
||||
voteEvent.setLocalOccurrenceId(vote.getLocalOccurrenceId());
|
||||
if (vote.getCanonicalOccurrenceTime() != 0L) voteEvent.setCanonicalOccurrenceTime(vote.getCanonicalOccurrenceTime());
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,6 @@ public class VoteTimeQueue {
|
||||
@Getter
|
||||
@Setter
|
||||
private UUID voteId;
|
||||
/** Local Bukkit receipt identity; never used as a proxy transport vote ID. */
|
||||
@Getter
|
||||
@Setter
|
||||
private UUID localOccurrenceId;
|
||||
/** SQL primary key for a legacy timed-cache row, or {@code -1} when not SQL-backed. */
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -51,19 +47,6 @@ public class VoteTimeQueue {
|
||||
@Getter
|
||||
@Setter
|
||||
private String totals;
|
||||
|
||||
public long getCanonicalOccurrenceTime() {
|
||||
Long original = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.read(totals);
|
||||
return original == null ? 0L : original;
|
||||
}
|
||||
|
||||
public void setCanonicalOccurrenceTime(long time) {
|
||||
totals = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.store(totals, time);
|
||||
}
|
||||
|
||||
public void setTotals(String updated) {
|
||||
totals = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.preserve(totals, updated);
|
||||
}
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean processed;
|
||||
|
||||
-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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+159
-326
@@ -21,7 +21,6 @@ import com.bencodez.simpleapi.sql.DataType;
|
||||
import com.bencodez.simpleapi.sql.data.DataValue;
|
||||
import com.bencodez.simpleapi.sql.data.DataValueInt;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceHolders;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
|
||||
/** Performs point writes that must remain atomic across shared MySQL servers. */
|
||||
@@ -125,7 +124,7 @@ final class SharedMysqlPointMutator {
|
||||
int previousTotal = cachedPoints(user, pointsColumn);
|
||||
int predictedTotal = previousTotal + amount;
|
||||
cachePredictedPoints(user, predictedTotal, pointsColumn);
|
||||
if (!runPointMutation(user, () -> update(user, amount, false, pointsColumn), true)) {
|
||||
if (!run(() -> update(user, amount, false, pointsColumn), true)) {
|
||||
discardOptimisticPoints(user, pointsColumn);
|
||||
if (failOnRejectedAdmission) throw new IllegalStateException("Point mutation storage worker is unavailable");
|
||||
return previousTotal;
|
||||
@@ -186,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,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. */
|
||||
@@ -358,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) {
|
||||
@@ -384,7 +339,7 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
void cap(VotingPluginUser user, int maximum, boolean async) {
|
||||
String pointsColumn = user.getPointsPath();
|
||||
runPointMutation(user, () -> capAt(user, maximum, pointsColumn), async);
|
||||
run(() -> capAt(user, maximum, pointsColumn), async);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,7 +357,7 @@ final class SharedMysqlPointMutator {
|
||||
int predictedTotal = (int) Math.max(Integer.MIN_VALUE,
|
||||
Math.min((long) previousTotal + amount, maximum));
|
||||
cachePredictedPoints(user, predictedTotal, pointsColumn);
|
||||
if (!runPointMutation(user, () -> addAndCapAt(user, amount, maximum, pointsColumn), true)) {
|
||||
if (!run(() -> addAndCapAt(user, amount, maximum, pointsColumn), true)) {
|
||||
discardOptimisticPoints(user, pointsColumn);
|
||||
}
|
||||
}
|
||||
@@ -415,7 +370,7 @@ final class SharedMysqlPointMutator {
|
||||
if (!async) return remove(user, amount);
|
||||
String pointsColumn = user.getPointsPath();
|
||||
boolean predictedSuccess = cachedPoints(user, pointsColumn) >= amount;
|
||||
boolean submitted = runPointMutation(user, () -> update(user, -amount, true, pointsColumn), true);
|
||||
boolean submitted = run(() -> update(user, -amount, true, pointsColumn), true);
|
||||
// Preserve the historical asynchronous API contract: the caller receives
|
||||
// the cached prediction while the conditional database debit runs later.
|
||||
return submitted && predictedSuccess;
|
||||
@@ -543,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
|
||||
@@ -606,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);
|
||||
}
|
||||
}
|
||||
@@ -619,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);
|
||||
}
|
||||
@@ -757,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);
|
||||
}
|
||||
@@ -777,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
|
||||
@@ -808,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);
|
||||
}
|
||||
@@ -846,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);
|
||||
@@ -877,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);
|
||||
@@ -965,23 +890,6 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean runPointMutation(VotingPluginUser user, Runnable operation, boolean async) {
|
||||
PlaceHolders placeholders = plugin.getPlaceholders();
|
||||
Consumer<Boolean> admission = placeholders == null ? null
|
||||
: placeholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID()));
|
||||
boolean accepted = run(() -> {
|
||||
boolean settled = false;
|
||||
try {
|
||||
operation.run();
|
||||
settled = true;
|
||||
} finally {
|
||||
if (admission != null) admission.accept(settled);
|
||||
}
|
||||
}, async);
|
||||
if (!accepted && admission != null) admission.accept(false);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
private boolean run(Runnable operation, boolean async) {
|
||||
if (async) {
|
||||
try {
|
||||
@@ -1002,34 +910,25 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private boolean update(VotingPluginUser user, int delta, boolean requireNonnegative, String points) {
|
||||
PlaceHolders publication = beginPointPublication(user);
|
||||
// Classify this arithmetic statement; an earlier uncertain operation stays fenced.
|
||||
boolean known = true;
|
||||
try {
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
StringBuilder sql = new StringBuilder("UPDATE ").append(table.qi(table.getTableName())).append(" SET ")
|
||||
.append(table.qi(points)).append(" = COALESCE(").append(table.qi(points)).append(", 0) + ? WHERE ")
|
||||
.append(table.qi("uuid")).append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
if (requireNonnegative) {
|
||||
sql.append(" AND COALESCE(").append(table.qi(points)).append(", 0) >= ?");
|
||||
}
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql.toString())) {
|
||||
statement.setInt(1, delta);
|
||||
statement.setString(2, user.getUUID());
|
||||
if (requireNonnegative) statement.setInt(3, -delta);
|
||||
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();
|
||||
StringBuilder sql = new StringBuilder("UPDATE ").append(table.qi(table.getTableName())).append(" SET ")
|
||||
.append(table.qi(points)).append(" = COALESCE(").append(table.qi(points)).append(", 0) + ? WHERE ")
|
||||
.append(table.qi("uuid")).append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
if (requireNonnegative) {
|
||||
sql.append(" AND COALESCE(").append(table.qi(points)).append(", 0) >= ?");
|
||||
}
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql.toString())) {
|
||||
statement.setInt(1, delta);
|
||||
statement.setString(2, user.getUUID());
|
||||
if (requireNonnegative) statement.setInt(3, -delta);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,74 +946,53 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount, String points) {
|
||||
PlaceHolders publication = beginPointPublication(user);
|
||||
// Classify this arithmetic statement; an earlier uncertain operation stays fenced.
|
||||
boolean known = true;
|
||||
boolean refreshed = false;
|
||||
try {
|
||||
try {
|
||||
drainCache(user, points);
|
||||
} catch (RuntimeException preparationFailure) {
|
||||
// No arithmetic statement was submitted. Cache retirement may itself
|
||||
// perform I/O; its failure is not proof this addition ran.
|
||||
plugin.debug(preparationFailure);
|
||||
return new AddResult(MutationOutcome.REJECTED, 0);
|
||||
}
|
||||
MySQL table = plugin.getMysql();
|
||||
String uuidMatch = table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
String update = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = COALESCE("
|
||||
+ table.qi(points) + ", 0) + ? WHERE " + uuidMatch;
|
||||
String read = "SELECT " + table.qi(points) + " FROM " + table.qi(table.getTableName()) + " WHERE " + uuidMatch;
|
||||
boolean updateCommitted = false;
|
||||
Integer committedTotal = null;
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement updateStatement = connection.prepareStatement(update);
|
||||
PreparedStatement readStatement = connection.prepareStatement(read)) {
|
||||
updateStatement.setInt(1, amount);
|
||||
updateStatement.setString(2, user.getUUID());
|
||||
known = false;
|
||||
int affected = updateStatement.executeUpdate();
|
||||
known = true;
|
||||
if (affected == 1) {
|
||||
// With JDBC auto-commit, executeUpdate returning one means the mutation
|
||||
// completed. A later read may still fail after the points have been
|
||||
// committed, so never turn that outcome into a retryable failure.
|
||||
updateCommitted = true;
|
||||
readStatement.setString(1, user.getUUID());
|
||||
try (java.sql.ResultSet result = readStatement.executeQuery()) {
|
||||
if (result.next()) committedTotal = result.getInt(1);
|
||||
}
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
String uuidMatch = table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
String update = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = COALESCE("
|
||||
+ table.qi(points) + ", 0) + ? WHERE " + uuidMatch;
|
||||
String read = "SELECT " + table.qi(points) + " FROM " + table.qi(table.getTableName()) + " WHERE " + uuidMatch;
|
||||
boolean updateCommitted = false;
|
||||
Integer committedTotal = null;
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement updateStatement = connection.prepareStatement(update);
|
||||
PreparedStatement readStatement = connection.prepareStatement(read)) {
|
||||
updateStatement.setInt(1, amount);
|
||||
updateStatement.setString(2, user.getUUID());
|
||||
if (updateStatement.executeUpdate() == 1) {
|
||||
// With JDBC auto-commit, executeUpdate returning one means the mutation
|
||||
// completed. A later read may still fail after the points have been
|
||||
// committed, so never turn that outcome into a retryable failure.
|
||||
updateCommitted = true;
|
||||
readStatement.setString(1, user.getUUID());
|
||||
try (java.sql.ResultSet result = readStatement.executeQuery()) {
|
||||
if (result.next()) committedTotal = result.getInt(1);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
} finally {
|
||||
refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
|
||||
}
|
||||
// Do not evaluate the fallback while the JDBC handle is still held. With a
|
||||
// one-connection pool, getPoints() may need that same handle after a missing
|
||||
// row or a failed follow-up read.
|
||||
if (committedTotal != null) return new AddResult(true, committedTotal);
|
||||
// A failed acquisition cannot support the fallback read either. Report the
|
||||
// mutation failure without checking out a second connection and let callers
|
||||
// complete their callback deterministically.
|
||||
if (!updateCommitted) return new AddResult(known ? MutationOutcome.REJECTED : MutationOutcome.INDETERMINATE, 0);
|
||||
// A live mode reload can change getPointsPath() while this queued write is
|
||||
// running. Keep the fallback on the column that received the credit.
|
||||
if (!points.equals(user.getPointsPath())) return new AddResult(true, user.getUserData().getInt(points));
|
||||
return new AddResult(true, user.getPoints());
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
} finally {
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
discardPointsCache(user, points);
|
||||
}
|
||||
// Do not evaluate the fallback while the JDBC handle is still held. With a
|
||||
// one-connection pool, getPoints() may need that same handle after a missing
|
||||
// row or a failed follow-up read.
|
||||
if (committedTotal != null) return new AddResult(true, committedTotal);
|
||||
// A failed acquisition cannot support the fallback read either. Report the
|
||||
// mutation failure without checking out a second connection and let callers
|
||||
// complete their callback deterministically.
|
||||
if (!updateCommitted) return new AddResult(false, 0);
|
||||
// A live mode reload can change getPointsPath() while this queued write is
|
||||
// running. Keep the fallback on the column that received the credit.
|
||||
if (!points.equals(user.getPointsPath())) return new AddResult(true, user.getUserData().getInt(points));
|
||||
return new AddResult(true, user.getPoints());
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -1124,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1155,28 +1025,20 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private void capAt(VotingPluginUser user, int maximum, String points) {
|
||||
PlaceHolders publication = beginPointPublication(user);
|
||||
// Classify this arithmetic statement; an earlier uncertain operation stays fenced.
|
||||
boolean known = true;
|
||||
try {
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(COALESCE("
|
||||
+ table.qi(points) + ", 0), ?) WHERE " + table.qi("uuid")
|
||||
+ (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, maximum);
|
||||
statement.setString(2, user.getUUID());
|
||||
known = false;
|
||||
statement.executeUpdate();
|
||||
known = true;
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
}
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(COALESCE("
|
||||
+ table.qi(points) + ", 0), ?) WHERE " + table.qi("uuid")
|
||||
+ (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, maximum);
|
||||
statement.setString(2, user.getUUID());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
} finally {
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
discardPointsCache(user, points);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1185,29 +1047,21 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private void addAndCapAt(VotingPluginUser user, int amount, int maximum, String points) {
|
||||
PlaceHolders publication = beginPointPublication(user);
|
||||
// Classify this arithmetic statement; an earlier uncertain operation stays fenced.
|
||||
boolean known = true;
|
||||
try {
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(COALESCE("
|
||||
+ table.qi(points) + ", 0) + ?, ?) WHERE " + table.qi("uuid")
|
||||
+ (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, amount);
|
||||
statement.setInt(2, maximum);
|
||||
statement.setString(3, user.getUUID());
|
||||
known = false;
|
||||
statement.executeUpdate();
|
||||
known = true;
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
}
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(COALESCE("
|
||||
+ table.qi(points) + ", 0) + ?, ?) WHERE " + table.qi("uuid")
|
||||
+ (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, amount);
|
||||
statement.setInt(2, maximum);
|
||||
statement.setString(3, user.getUUID());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
} finally {
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
discardPointsCache(user, points);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1247,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@ package com.bencodez.votingplugin.user;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence.LiveIdentity;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
@@ -26,8 +22,7 @@ import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
* Manages VotingPlugin user operations and data.
|
||||
*/
|
||||
public class UserManager {
|
||||
private VotingPluginMain plugin;
|
||||
private final Set<UUID> warnedOfflineUuidPlayers = ConcurrentHashMap.newKeySet();
|
||||
private VotingPluginMain plugin;
|
||||
private boolean sharedPointTransferRecoveryScheduled;
|
||||
|
||||
/**
|
||||
@@ -262,50 +257,18 @@ public class UserManager {
|
||||
* Gets a VotingPluginUser by UUID.
|
||||
* @param uuid the player UUID
|
||||
* @return the VotingPluginUser
|
||||
* @deprecated UUID-only lookups can be ambiguous in offline mode because Minecraft
|
||||
* preserves name capitalization, while VotingPlugin derives its
|
||||
* offline UUID from the player name lowercased with Locale.ROOT.
|
||||
* Prefer {@link #getVotingPluginUser(UUID, String)} when both UUID
|
||||
* and player name are available; supplying the name allows the
|
||||
* existing offline UUID normalization. Use
|
||||
* {@link #getVotingPluginUser(Player)} for an online player,
|
||||
* {@link #getVotingPluginUser(OfflinePlayer)} when an offline
|
||||
* player object with a known name is available, or
|
||||
* {@link #getVotingPluginUser(String)} when only the name is known.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public VotingPluginUser getVotingPluginUser(UUID uuid) {
|
||||
LiveIdentity identity = offlineLiveIdentity(uuid);
|
||||
if (identity == null) return new VotingPluginUser(plugin, uuid);
|
||||
VotingPluginUser user = new VotingPluginUser(plugin, identity.storageUuid(), false);
|
||||
user.setPlayerName(identity.playerName());
|
||||
warnOfflineUuidCorrection(uuid, identity);
|
||||
return user;
|
||||
return new VotingPluginUser(plugin, uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a VotingPluginUser by exact storage UUID with optional name loading.
|
||||
* This overload preserves the supplied key for storage iteration, recovery
|
||||
* and administrative operations, even when loadName is true.
|
||||
* Gets a VotingPluginUser by UUID with optional name loading.
|
||||
* @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);
|
||||
@@ -322,24 +285,6 @@ public class UserManager {
|
||||
return new VotingPluginUser(plugin, uuid, playerName);
|
||||
}
|
||||
|
||||
/** Resolve only immutable live identity captured by the presence lifecycle. */
|
||||
private LiveIdentity offlineLiveIdentity(UUID uuid) {
|
||||
if (plugin.getOptions().isOnlineMode()) return null;
|
||||
var presence = plugin.getPlaceholderPlayerPresence();
|
||||
LiveIdentity identity = presence == null ? null : presence.liveIdentity(uuid);
|
||||
if (identity == null || identity.serverUuid() == null || identity.storageUuid() == null
|
||||
|| identity.playerName() == null || identity.playerName().isBlank()) return null;
|
||||
return identity;
|
||||
}
|
||||
|
||||
private void warnOfflineUuidCorrection(UUID uuid, LiveIdentity identity) {
|
||||
if (!uuid.equals(identity.storageUuid()) && warnedOfflineUuidPlayers.add(identity.storageUuid())) {
|
||||
plugin.getLogger().warning("Offline UUID compatibility workaround used for " + identity.playerName()
|
||||
+ ". Server UUID: " + identity.serverUuid() + " -> VotingPlugin UUID: " + identity.storageUuid()
|
||||
+ ". Applied for third-party compatibility: a plugin may be using the server UUID instead of VotingPlugin's UUID.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purges old players with no voting data.
|
||||
*/
|
||||
|
||||
+1971
-2178
File diff suppressed because it is too large.
Load diff
-13
@@ -18,19 +18,6 @@ public final class BukkitCompletionScheduler {
|
||||
public static boolean isSuccessfulEntityTaskResult(Object status) {
|
||||
return status instanceof Enum<?> result && "SUCCESS".equals(result.name());
|
||||
}
|
||||
|
||||
/** Event flags are not a reliable execution-context check, including Folia region threads. */
|
||||
public static boolean isPlatformOwnedThread() {
|
||||
try { if (org.bukkit.Bukkit.isPrimaryThread()) return true; } catch (RuntimeException ignored) { }
|
||||
Object server = org.bukkit.Bukkit.getServer();
|
||||
if (server != null) try {
|
||||
if (Boolean.TRUE.equals(server.getClass().getMethod("isGlobalTickThread").invoke(server))) return true;
|
||||
} catch (ReflectiveOperationException | RuntimeException ignored) { }
|
||||
for (String name : new String[] { "ca.spottedleaf.moonrise.common.util.TickThread", "io.papermc.paper.util.TickThread" }) try {
|
||||
if (Boolean.TRUE.equals(Class.forName(name).getMethod("isTickThread").invoke(null))) return true;
|
||||
} catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { }
|
||||
return false;
|
||||
}
|
||||
private BukkitCompletionScheduler() {
|
||||
}
|
||||
|
||||
|
||||
-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:
|
||||
|
||||
@@ -6,7 +6,7 @@ license="CC BY 3.0"
|
||||
modId="votingplugin"
|
||||
version="${project.version}"
|
||||
displayName="VotingPlugin"
|
||||
description="VotingPlugin NeoForge bootstrap (vote processing is not available yet)."
|
||||
description="Experimental VotingPlugin NeoForge support for authenticated proxy SOCKETS votes and limited reward replay."
|
||||
|
||||
[[dependencies.votingplugin]]
|
||||
modId="neoforge"
|
||||
|
||||
@@ -397,45 +397,3 @@ DailyAwards:
|
||||
Rewards:
|
||||
Messages:
|
||||
Player: '&aYou came in second place in %TopVoter%!'
|
||||
|
||||
# Owner-defined dated events. This example is DISABLED by default.
|
||||
# Set your own dates/rewards, then enable the event. No automatic recurring reset.
|
||||
# Keep the event ID stable to retain progress; never delete its ledger and reuse it.
|
||||
# After accounting starts, use a NEW ID for changes to dates, timezone, thresholds,
|
||||
# VoteSites or AccountingServer. Unsafe edits are rejected, preserving progress.
|
||||
# DisplayName and Enabled may change without resetting counts.
|
||||
# Full configuration and durability limits: docs/date-vote-milestones.md
|
||||
DateVoteMilestones:
|
||||
example_event:
|
||||
Enabled: false
|
||||
# Count admin/test votes for this event? Existing normal vote policies are unchanged.
|
||||
CountFakeVotes: true
|
||||
DisplayName: 'Example voting event'
|
||||
# Local ISO date-times interpreted in the explicit IANA timezone below.
|
||||
Start: '2026-10-01T00:00:00' # Inclusive: occurrences at this instant count.
|
||||
End: '2026-11-01T00:00:00' # Exclusive: occurrences at this instant do not count.
|
||||
Timezone: 'UTC'
|
||||
# Ledger owner: empty/omitted means local accounting on standalone servers.
|
||||
# With UseBungeecord enabled, empty/omitted disables new date accounting.
|
||||
# Proxy: set the exact, case-sensitive BungeeSettings.yml Server name of ONE
|
||||
# backend, identically on every backend, with identified all-server delivery.
|
||||
# Reward delivery follows the existing reward settings. Keep matching event
|
||||
# IDs and milestone reward sections on backends that may resolve rewards.
|
||||
AccountingServer: ''
|
||||
# Configured VoteSites.yml keys (not service names); empty means all sites.
|
||||
VoteSites: []
|
||||
# Named milestones use At (one count, list, or bounded range) / Every.
|
||||
# Existing numeric keys with Rewards remain supported; see the guide.
|
||||
Milestones:
|
||||
TenVotes:
|
||||
Enabled: true
|
||||
At: 10
|
||||
Rewards:
|
||||
Messages:
|
||||
Player: '&aYou reached 10 votes in %DateVoteEvent%!'
|
||||
TwentyFiveVotes:
|
||||
Enabled: true
|
||||
At: 25
|
||||
Rewards:
|
||||
Messages:
|
||||
Player: '&aYou reached 25 votes in %DateVoteEvent%!'
|
||||
@@ -196,9 +196,6 @@ permissions:
|
||||
description: Remind if player can vote on all votesites, rather than any
|
||||
default: false
|
||||
|
||||
VotingPlugin.Commands.Vote.DateEvents:
|
||||
description: View owner-configured date event progress
|
||||
default: false
|
||||
VotingPlugin.Commands.Vote.Streaks:
|
||||
description: View your read-only vote streak progression
|
||||
default: op
|
||||
|
||||
-58
@@ -1,58 +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.votingplugin.placeholders.PlaceholderPlayerPresence;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.streaks.VoteStreaksMenu;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
import java.util.HashSet;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DateEventUnloadIntegrationTest {
|
||||
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 unloadClosesDateIngressAndRejectsLaterStreakMenuOpens() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
field(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
var listener = mock(com.bencodez.votingplugin.listeners.VotiferEvent.class);
|
||||
field(plugin, "localVotifierListener", listener);
|
||||
field(plugin, "localVotifierIngressReady", true);
|
||||
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);
|
||||
when(signs.storeSignsForShutdown()).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null));
|
||||
field(plugin, "signs", signs);
|
||||
var menu = new VoteStreaksMenu(plugin); field(plugin, "voteStreaksMenu", menu);
|
||||
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(listener).stop();
|
||||
var proxy = mock(Runnable.class);
|
||||
assertThrows(IllegalStateException.class, () -> DateVoteIngressAccess.initializeDateVoteIngress(plugin, proxy));
|
||||
verifyNoInteractions(proxy);
|
||||
menu.open(player);
|
||||
verifyNoInteractions(player);
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
/** Narrow test-only access to the real startup ingress lifecycle. */
|
||||
public final class DateVoteIngressAccess {
|
||||
private DateVoteIngressAccess() { }
|
||||
public static void initializeDateVoteIngress(VotingPluginMain target, Runnable opening) {
|
||||
ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", target,
|
||||
"initializeDateVoteIngress", new String[]{"Runnable"}, new Object[]{opening});
|
||||
}
|
||||
public static void registerEarlyVotifierIngress(VotingPluginMain target) {
|
||||
ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", target,
|
||||
"registerEarlyVotifierIngress", new String[]{}, new Object[]{});
|
||||
}
|
||||
public static void callRealInitialization(VotingPluginMain target) {
|
||||
initializeDateVoteIngress(Mockito.doCallRealMethod().when(target), Mockito.any());
|
||||
}
|
||||
public static void callRealRegistration(VotingPluginMain target) {
|
||||
registerEarlyVotifierIngress(Mockito.doCallRealMethod().when(target));
|
||||
}
|
||||
}
|
||||
-173
@@ -1,173 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
class DateVoteIngressOrderingTest {
|
||||
@TempDir Path root;
|
||||
@Test void firstTransportDeliveryFindsDefinitionsAndRewardHandlesAlreadyPublished() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
YamlConfiguration config = new YamlConfiguration();
|
||||
String prefix = "DateVoteMilestones.startup.";
|
||||
config.set(prefix + "Enabled", true); config.set(prefix + "Timezone", "UTC");
|
||||
config.set(prefix + "Start", "2020-01-01T00:00:00"); config.set(prefix + "End", "2099-01-01T00:00:00");
|
||||
config.set(prefix + "AccountingServer", "backend");
|
||||
config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks");
|
||||
when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config);
|
||||
when(plugin.getDataFolder()).thenReturn(root.toFile());
|
||||
when(plugin.getOptions().isProcessRewards()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings().getServer()).thenReturn("backend");
|
||||
DateVoteMilestones events = new DateVoteMilestones(plugin);
|
||||
when(plugin.getDateVoteMilestones()).thenReturn(events);
|
||||
VotingPluginUser user = mock(VotingPluginUser.class); when(user.getJavaUUID()).thenReturn(UUID.randomUUID());
|
||||
Runnable ingress = () -> {
|
||||
verify(plugin).addDirectlyDefinedRewards(any());
|
||||
events.accepted(user, "a", UUID.randomUUID(), System.currentTimeMillis(), true, true, true, false, false);
|
||||
};
|
||||
ready(plugin);
|
||||
DateVoteIngressAccess.callRealInitialization(plugin);
|
||||
DateVoteIngressAccess.initializeDateVoteIngress(plugin, ingress);
|
||||
verify(plugin.getRewardHandler()).giveReward(eq(user), any(), startsWith("DateVoteMilestonesRuntime."), any());
|
||||
assertTrue(java.nio.file.Files.exists(root.resolve("date-vote-milestones")));
|
||||
}
|
||||
private static final String[] HANDLERS = {"voteMilestonesManager", "voteStreakHandler", "voteParty", "specialRewards", "topVoterHandler", "voteShopManager", "placeholders"};
|
||||
private static void ready(VotingPluginMain plugin) throws Exception {
|
||||
for (String name : HANDLERS) {
|
||||
var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, mock(field.getType()));
|
||||
}
|
||||
}
|
||||
@Test void eachMissingDownstreamHandlerKeepsIngressClosed() throws Exception {
|
||||
for (String name : HANDLERS) {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); ready(plugin);
|
||||
var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, null);
|
||||
DateVoteIngressAccess.callRealInitialization(plugin); Runnable ingress = mock(Runnable.class);
|
||||
assertThrows(IllegalStateException.class, () -> DateVoteIngressAccess.initializeDateVoteIngress(plugin, ingress)); verify(ingress, never()).run();
|
||||
verify(plugin.getDateVoteMilestones(), never()).reload();
|
||||
verify(plugin.getServer().getPluginManager(), never()).registerEvents(any(), eq(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void earlyCaptureIsRegisteredOnceAndConsumersOpenOnlyAfterDefinitionsAndHandlers() throws Exception {
|
||||
for (boolean proxyEnabled : new boolean[] {false, true}) {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
when(plugin.isVotifierLoaded()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(proxyEnabled);
|
||||
var timer = mock(java.util.concurrent.ScheduledExecutorService.class);
|
||||
when(plugin.getVoteTimer()).thenReturn(timer);
|
||||
var registered = new java.util.ArrayList<org.bukkit.event.Listener>();
|
||||
var manager = plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> { registered.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin));
|
||||
DateVoteIngressAccess.callRealRegistration(plugin);
|
||||
DateVoteIngressAccess.callRealInitialization(plugin);
|
||||
try (var queues = mockConstruction(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(plugin); DateVoteIngressAccess.registerEarlyVotifierIngress(plugin);
|
||||
assertEquals(1, registered.size());
|
||||
assertInstanceOf(com.bencodez.votingplugin.listeners.VotiferEvent.class, registered.getFirst());
|
||||
verify(plugin.getDateVoteMilestones(), never()).reload();
|
||||
assertEquals(1, queues.constructed().size(), "capture publishes its independent queue owner immediately");
|
||||
var queueField = VotingPluginMain.class.getDeclaredField("votifierVoteOverflowQueue");
|
||||
queueField.setAccessible(true);
|
||||
assertSame(queues.constructed().getFirst(), queueField.get(plugin));
|
||||
verifyNoInteractions(timer); // Loading cannot be lost with vote-executor cancellation.
|
||||
verify(queues.constructed().getFirst(), never()).start();
|
||||
ready(plugin);
|
||||
Runnable proxy = () -> {
|
||||
verify(plugin.getDateVoteMilestones()).reload();
|
||||
assertEquals(3, registered.size());
|
||||
assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, registered.get(1));
|
||||
assertInstanceOf(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class, registered.get(2));
|
||||
};
|
||||
DateVoteIngressAccess.initializeDateVoteIngress(plugin, proxy); DateVoteIngressAccess.initializeDateVoteIngress(plugin, proxy);
|
||||
assertEquals(3, registered.size());
|
||||
verify(plugin.getDateVoteMilestones()).reload(); verify(queues.constructed().getFirst()).start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void persistedReplayProducerIsConstructedOnlyAfterItsAcceptedVoteConsumer() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); ready(plugin);
|
||||
DateVoteIngressAccess.callRealInitialization(plugin);
|
||||
var manager = plugin.getServer().getPluginManager();
|
||||
var registered = new java.util.ArrayList<org.bukkit.event.Listener>();
|
||||
doAnswer(call -> { registered.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin));
|
||||
try (var producers = mockConstruction(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class, (handler, context) -> {
|
||||
// The real constructor schedules replay immediately; this hook runs at that boundary.
|
||||
assertEquals(1, registered.size()); assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, registered.getFirst());
|
||||
verify(plugin.getDateVoteMilestones()).reload();
|
||||
})) {
|
||||
DateVoteIngressAccess.initializeDateVoteIngress(plugin, () -> fail("Local startup does not open proxy ingress"));
|
||||
assertEquals(1, producers.constructed().size()); assertSame(producers.constructed().getFirst(), registered.get(1));
|
||||
var field = VotingPluginMain.class.getDeclaredField("timeQueueHandler"); field.setAccessible(true);
|
||||
assertSame(producers.constructed().getFirst(), field.get(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedProxyOpeningRetriesWithoutDuplicatingAcceptedConsumers() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
ready(plugin); when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true);
|
||||
DateVoteIngressAccess.callRealInitialization(plugin);
|
||||
var registered = new java.util.ArrayList<org.bukkit.event.Listener>();
|
||||
var eventManager = plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> { registered.add(call.getArgument(0)); return null; })
|
||||
.when(eventManager).registerEvents(any(), eq(plugin));
|
||||
assertThrows(IllegalStateException.class, () -> DateVoteIngressAccess.initializeDateVoteIngress(plugin, () -> { throw new IllegalStateException("transport unavailable"); }));
|
||||
assertEquals(2, registered.size());
|
||||
DateVoteIngressAccess.initializeDateVoteIngress(plugin, () -> { }); DateVoteIngressAccess.initializeDateVoteIngress(plugin, () -> fail("Already ready"));
|
||||
assertEquals(2, registered.size()); verify(plugin.getDateVoteMilestones(), times(2)).reload();
|
||||
}
|
||||
|
||||
@Test void reenableCreatesNewIngressAndFencesOldReadiness() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
when(plugin.isVotifierLoaded()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings().isGloblalDataEnabled()).thenReturn(true);
|
||||
DateVoteIngressAccess.callRealRegistration(plugin);
|
||||
DateVoteIngressAccess.callRealInitialization(plugin);
|
||||
var listenerField = VotingPluginMain.class.getDeclaredField("localVotifierListener");
|
||||
listenerField.setAccessible(true);
|
||||
var readyField = com.bencodez.votingplugin.listeners.VotiferEvent.class.getDeclaredField("ready");
|
||||
readyField.setAccessible(true);
|
||||
var closedField = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed");
|
||||
closedField.setAccessible(true);
|
||||
var steps = VotingPluginMain.class.getDeclaredField("completedUnloadSteps"); steps.setAccessible(true);
|
||||
var reset = VotingPluginMain.class.getDeclaredMethod("resetDateVoteIngressForEnable"); reset.setAccessible(true);
|
||||
try (var queues = mockConstruction(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(plugin);
|
||||
Object first = listenerField.get(plugin);
|
||||
var oldReady = (java.util.function.BooleanSupplier) readyField.get(first);
|
||||
closedField.setBoolean(plugin, true);
|
||||
steps.set(plugin, new java.util.HashSet<>(java.util.Set.of("local Votifier ingress", "event handlers")));
|
||||
reset.invoke(plugin);
|
||||
assertFalse(oldReady.getAsBoolean());
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(plugin);
|
||||
Object second = listenerField.get(plugin);
|
||||
assertNotSame(first, second);
|
||||
ready(plugin);
|
||||
DateVoteIngressAccess.initializeDateVoteIngress(plugin, () -> { });
|
||||
assertTrue(((java.util.function.BooleanSupplier) readyField.get(second)).getAsBoolean());
|
||||
assertFalse(oldReady.getAsBoolean(), "a retired generation must not become ready again");
|
||||
verify(plugin.getServer().getPluginManager(), times(3)).registerEvents(any(), eq(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reenableRejectsIncompletePriorIngressRetirement() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
var listener = VotingPluginMain.class.getDeclaredField("localVotifierListener"); listener.setAccessible(true);
|
||||
listener.set(plugin, mock(com.bencodez.votingplugin.listeners.VotiferEvent.class));
|
||||
var steps = VotingPluginMain.class.getDeclaredField("completedUnloadSteps"); steps.setAccessible(true);
|
||||
steps.set(plugin, new java.util.HashSet<>(java.util.Set.of("event handlers")));
|
||||
var reset = VotingPluginMain.class.getDeclaredMethod("resetDateVoteIngressForEnable"); reset.setAccessible(true);
|
||||
var failure = assertThrows(java.lang.reflect.InvocationTargetException.class, () -> reset.invoke(plugin));
|
||||
assertInstanceOf(IllegalStateException.class, failure.getCause());
|
||||
assertNotNull(listener.get(plugin));
|
||||
}
|
||||
|
||||
}
|
||||
-477
@@ -1,477 +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.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import com.bencodez.advancedcore.api.user.validation.*;
|
||||
import com.bencodez.votingplugin.events.*;
|
||||
import com.bencodez.votingplugin.listeners.*;
|
||||
import com.bencodez.votingplugin.specialrewards.datemilestones.*;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.util.BoundedScheduledExecutor;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import com.vexsoftware.votifier.model.Vote;
|
||||
|
||||
/** Genuine NuVotifier input traverses actual startup capture, bounded workers and the accepted consumer. */
|
||||
class DateVotifierStartupCaptureTest {
|
||||
@TempDir Path root;
|
||||
private static final String SERVICE = "startup.example";
|
||||
private static final String[] HANDLERS = { "voteMilestonesManager", "voteStreakHandler", "voteParty", "specialRewards", "topVoterHandler", "voteShopManager", "placeholders" };
|
||||
static void ready(VotingPluginMain plugin) throws Exception {
|
||||
for (String name : HANDLERS) {
|
||||
var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, mock(field.getType()));
|
||||
}
|
||||
}
|
||||
private final class Fixture implements AutoCloseable {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final BoundedScheduledExecutor timer = new BoundedScheduledExecutor(1, 256);
|
||||
final VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
final VoteSite site = mock(VoteSite.class);
|
||||
final YamlConfiguration config = new YamlConfiguration();
|
||||
final List<Listener> registered = new CopyOnWriteArrayList<>();
|
||||
final List<PlayerVoteEvent> accepted = new CopyOnWriteArrayList<>();
|
||||
final List<PlayerPostVoteEvent> posted = new CopyOnWriteArrayList<>();
|
||||
final CountDownLatch delivered;
|
||||
final DateVoteMilestones dates;
|
||||
volatile Runnable afterAccepted = () -> { };
|
||||
Fixture(Path directory, int expectedVotes) throws Exception { this(directory, expectedVotes, UUID.randomUUID()); }
|
||||
Fixture(Path directory, int expectedVotes, UUID playerId) throws Exception {
|
||||
delivered = new CountDownLatch(expectedVotes);
|
||||
timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
timer.setThreadFactory(work -> new Thread(work, "date-startup-vote-owner"));
|
||||
when(plugin.isEnabled()).thenReturn(true); when(plugin.isVotifierLoaded()).thenReturn(true);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile()); when(plugin.getVoteTimer()).thenReturn(timer);
|
||||
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
doCallRealMethod().when(plugin).getVotifierVoteOverflowQueue();
|
||||
DateVoteIngressAccess.callRealRegistration(plugin);
|
||||
DateVoteIngressAccess.callRealInitialization(plugin);
|
||||
when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn(".");
|
||||
when(plugin.getOptions().isProcessRewards()).thenReturn(true);
|
||||
when(plugin.getConfigFile().isAddTotals()).thenReturn(true);
|
||||
// Create worker-used deep stubs before concurrent overflow admission
|
||||
// and ordinary processing; Mockito cannot safely stub on both workers.
|
||||
plugin.getServerData(); plugin.getBungeeSettings(); plugin.getTimeChecker();
|
||||
plugin.getBroadcastHandler(); plugin.getVoteParty(); plugin.getCoolDownCheck();
|
||||
plugin.getVoteMilestonesManager(); plugin.getVoteStreakHandler();
|
||||
plugin.getPlaceholders(); plugin.getConfigFile().getPlaceholderCacheLevel();
|
||||
plugin.getRewardHandler();
|
||||
when(plugin.getUserManager().getProperName("Steve")).thenReturn("Steve");
|
||||
when(plugin.getUserManager().getValidationService().validate("Steve", false)).thenReturn(
|
||||
new UserValidationResult(ValidationStatus.VALID, "Steve", ValidationSource.STORAGE, "fixture", false));
|
||||
when(user.getPlayerName()).thenReturn("Steve"); when(user.getJavaUUID()).thenReturn(playerId);
|
||||
when(user.getUUID()).thenReturn(playerId.toString()); when(user.isOnline()).thenReturn(true);
|
||||
when(site.isEnabled()).thenReturn(true); when(site.getKey()).thenReturn("a"); when(site.getServiceSite()).thenReturn(SERVICE);
|
||||
when(plugin.getVoteSiteManager().getVoteSiteName(false, SERVICE, "")).thenReturn("a");
|
||||
when(plugin.getVoteSiteManager().hasVoteSite("a")).thenReturn(true);
|
||||
when(plugin.getVoteSiteManager().getVoteSiteName(true, SERVICE, "")).thenReturn("a");
|
||||
when(plugin.getVoteSiteManager().getVoteSite("a", true)).thenReturn(site);
|
||||
String prefix = "DateVoteMilestones.startup.";
|
||||
config.set(prefix + "Enabled", true); config.set(prefix + "Start", "2020-01-01T00:00:00");
|
||||
config.set(prefix + "End", "2099-01-01T00:00:00"); config.set(prefix + "Timezone", "UTC");
|
||||
config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks");
|
||||
when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config);
|
||||
dates = new DateVoteMilestones(plugin); when(plugin.getDateVoteMilestones()).thenReturn(dates);
|
||||
var manager = plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> { registered.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin));
|
||||
doAnswer(call -> {
|
||||
Object event = call.getArgument(0);
|
||||
if (event instanceof PlayerVoteEvent vote) {
|
||||
assertEquals("date-startup-vote-owner", Thread.currentThread().getName());
|
||||
vote.setVotingPluginUser(user); accepted.add(vote);
|
||||
var consumer = registered.stream().filter(PlayerVoteListener.class::isInstance).map(PlayerVoteListener.class::cast).findFirst().orElseThrow();
|
||||
consumer.onplayerVote(vote);
|
||||
afterAccepted.run();
|
||||
} else if (event instanceof PlayerPostVoteEvent post) { posted.add(post); delivered.countDown(); }
|
||||
return null;
|
||||
}).when(manager).callEvent(any());
|
||||
}
|
||||
VotiferEvent capture() { return registered.stream().filter(VotiferEvent.class::isInstance).map(VotiferEvent.class::cast).findFirst().orElseThrow(); }
|
||||
void vote() { capture().onVotiferEvent(new com.vexsoftware.votifier.model.VotifierEvent(new Vote(SERVICE, "Steve", "127.0.0.1", "1"))); }
|
||||
void drain() throws Exception {
|
||||
timer.submit(() -> { }).get(3, TimeUnit.SECONDS);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
while ((!plugin.getVotifierVoteOverflowQueue().isInitializationComplete() || capture().getPendingCaptureCount() != 0)
|
||||
&& System.nanoTime() < deadline) Thread.sleep(5);
|
||||
assertTrue(plugin.getVotifierVoteOverflowQueue().isInitializationComplete(), "queue initialization did not finish");
|
||||
assertEquals(0, capture().getPendingCaptureCount(), "capture transfers did not finish");
|
||||
timer.submit(() -> { }).get(3, TimeUnit.SECONDS);
|
||||
}
|
||||
void open() throws Exception { ready(plugin); DateVoteIngressAccess.initializeDateVoteIngress(plugin, () -> fail("Standalone does not open proxy ingress")); }
|
||||
void received() throws Exception { assertTrue(delivered.await(3, TimeUnit.SECONDS)); drain(); }
|
||||
DateVoteLedger.Progress progress() throws Exception {
|
||||
var event = DateVoteMilestones.parse("startup", config.getConfigurationSection("DateVoteMilestones.startup"));
|
||||
return new DateVoteLedger(plugin.getDataFolder().toPath().resolve("date-vote-milestones")).progress(event, user.getJavaUUID());
|
||||
}
|
||||
public void close() {
|
||||
capture().stop(); timer.shutdownNow();
|
||||
try { assertTrue(timer.awaitTermination(3, TimeUnit.SECONDS)); } catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
var queue = plugin.getVotifierVoteOverflowQueue(); if (queue != null) queue.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualExternalEventsAreCapturedBeforeConsumersAndDeliveredOnceAfterReadiness() throws Exception {
|
||||
try (var fixture = new Fixture(root, 2)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain();
|
||||
assertEquals(1, fixture.registered.size());
|
||||
long before = System.currentTimeMillis(); fixture.vote(); fixture.vote(); long after = System.currentTimeMillis();
|
||||
fixture.drain();
|
||||
assertEquals(2, fixture.plugin.getVotifierVoteOverflowQueue().size()); assertTrue(fixture.accepted.isEmpty());
|
||||
verify(fixture.plugin.getServerData(), never()).addServiceSite(anyString());
|
||||
verify(fixture.user, never()).cache(); verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean());
|
||||
verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any());
|
||||
long openedAt = System.currentTimeMillis(); fixture.open(); DateVoteIngressAccess.initializeDateVoteIngress(fixture.plugin, () -> fail("Repeated startup cannot reopen transports"));
|
||||
fixture.received(); fixture.vote(); fixture.drain();
|
||||
assertEquals(3, fixture.registered.size()); assertEquals(3, fixture.accepted.size()); assertEquals(3, fixture.posted.size());
|
||||
assertEquals(0, fixture.plugin.getVotifierVoteOverflowQueue().size());
|
||||
for (int index = 0; index < 2; index++) {
|
||||
long occurrence = fixture.accepted.get(index).getCanonicalOccurrenceTime();
|
||||
assertTrue(occurrence >= before && occurrence <= after);
|
||||
assertEquals(0L, fixture.accepted.get(index).getTime());
|
||||
assertEquals(occurrence, fixture.posted.get(index).getCanonicalOccurrenceTime());
|
||||
assertTrue(fixture.posted.get(index).getVoteTime() >= openedAt);
|
||||
}
|
||||
assertEquals(3, fixture.accepted.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count());
|
||||
assertTrue(fixture.accepted.stream().allMatch(vote -> vote.getLocalOccurrenceId() != null && vote.getProxyVoteId() == null));
|
||||
for (int index = 0; index < 3; index++) assertEquals(fixture.accepted.get(index).getLocalOccurrenceId(), fixture.posted.get(index).getVoteUUID());
|
||||
assertEquals(3, fixture.progress().votes());
|
||||
verify(fixture.user, times(3)).setTime(fixture.site);
|
||||
verify(fixture.user, never()).setTime(eq(fixture.site), anyLong());
|
||||
verify(fixture.user, times(3)).addTotal(); verify(fixture.user, times(3)).playerVote(fixture.site, true, false);
|
||||
verify(fixture.plugin.getRewardHandler(), times(1)).giveReward(eq(fixture.user), any(), startsWith("DateVoteMilestonesRuntime."), any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReadinessRetainsCaptureAndUsesCurrentDefinitionsWhenStartupRetries() throws Exception {
|
||||
try (var fixture = new Fixture(root, 1)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain(); fixture.vote(); fixture.drain();
|
||||
assertThrows(IllegalStateException.class, () -> DateVoteIngressAccess.initializeDateVoteIngress(fixture.plugin, () -> fail("Not ready")));
|
||||
assertEquals(1, fixture.registered.size()); assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size());
|
||||
// Reload/edit before release: a stale enabled snapshot must not award a buffered vote.
|
||||
fixture.config.set("DateVoteMilestones.startup.Enabled", false);
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.open(); fixture.received();
|
||||
assertEquals(1, fixture.accepted.size()); assertEquals(0, fixture.progress().votes());
|
||||
verify(fixture.user).addTotal(); verify(fixture.user).playerVote(fixture.site, true, false);
|
||||
verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void preReadyCaptureSurvivesRestartWithItsSeparateOccurrence() throws Exception {
|
||||
long original;
|
||||
try (var fixture = new Fixture(root, 0)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain(); fixture.vote(); fixture.drain();
|
||||
assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size()); assertTrue(fixture.accepted.isEmpty());
|
||||
// Closing the real overflow writes the captured receipt; no consumer was opened.
|
||||
fixture.plugin.getVotifierVoteOverflowQueue().close();
|
||||
var yaml = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile());
|
||||
original = ((Number) yaml.getMapList("Votes").getFirst().get("Time")).longValue();
|
||||
}
|
||||
try (var restarted = new Fixture(root, 1)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(restarted.plugin); restarted.drain();
|
||||
assertEquals(1, restarted.plugin.getVotifierVoteOverflowQueue().size()); assertTrue(restarted.accepted.isEmpty());
|
||||
restarted.open(); restarted.received();
|
||||
assertEquals(1, restarted.accepted.size()); assertEquals(original, restarted.accepted.getFirst().getCanonicalOccurrenceTime());
|
||||
assertEquals(0L, restarted.accepted.getFirst().getTime()); assertEquals(original, restarted.posted.getFirst().getCanonicalOccurrenceTime());
|
||||
assertEquals(1, restarted.progress().votes()); verify(restarted.user).addTotal();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void crashAfterLedgerBeforeOverflowAckReplaysTheSameOccurrenceWithoutAnotherAward() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
UUID original;
|
||||
long originalTime;
|
||||
var committed = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
try (var fixture = new Fixture(root, 1, playerId)) {
|
||||
// Recounting a replay would unlock a distinct second reward.
|
||||
fixture.config.set("DateVoteMilestones.startup.Milestones.2.Rewards.Messages.Player", "Second");
|
||||
fixture.afterAccepted = () -> {
|
||||
committed.countDown();
|
||||
try { assertTrue(release.await(3, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
};
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain(); fixture.vote(); fixture.drain();
|
||||
fixture.open();
|
||||
assertTrue(committed.await(3, TimeUnit.SECONDS));
|
||||
try {
|
||||
assertEquals(1, fixture.progress().votes());
|
||||
original = fixture.accepted.getFirst().getLocalOccurrenceId();
|
||||
originalTime = fixture.accepted.getFirst().getCanonicalOccurrenceTime();
|
||||
// The native callback has not returned, so existing shutdown retains
|
||||
// precisely the same durable receipt a process crash would leave.
|
||||
fixture.plugin.getVotifierVoteOverflowQueue().close();
|
||||
var disk = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile());
|
||||
assertEquals(original.toString(), disk.getMapList("Votes").getFirst().get("LocalOccurrenceId"));
|
||||
verify(fixture.plugin.getRewardHandler(), times(1)).giveReward(eq(fixture.user), any(), startsWith("DateVoteMilestonesRuntime."), any());
|
||||
} finally { release.countDown(); }
|
||||
fixture.drain();
|
||||
}
|
||||
try (var restarted = new Fixture(root, 1, playerId)) {
|
||||
restarted.config.set("DateVoteMilestones.startup.Milestones.2.Rewards.Messages.Player", "Second");
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(restarted.plugin); restarted.drain(); restarted.open(); restarted.received();
|
||||
assertEquals(original, restarted.accepted.getFirst().getLocalOccurrenceId());
|
||||
assertEquals(original, restarted.posted.getFirst().getVoteUUID());
|
||||
assertNull(restarted.accepted.getFirst().getProxyVoteId());
|
||||
assertFalse(restarted.accepted.getFirst().isBungee());
|
||||
assertEquals(originalTime, restarted.accepted.getFirst().getCanonicalOccurrenceTime());
|
||||
assertEquals(0L, restarted.accepted.getFirst().getTime());
|
||||
assertEquals(1, restarted.progress().votes());
|
||||
verify(restarted.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any());
|
||||
// Ordinary effects retain their existing at-least-once replay semantics.
|
||||
verify(restarted.user).setTime(restarted.site);
|
||||
verify(restarted.user).addTotal(); verify(restarted.user).playerVote(restarted.site, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void blockedExecutorCannotLoseEarlyCaptureOrQueueSetupOnCancellation() throws Exception {
|
||||
capturedAdmissionSurvivesCancellation(false);
|
||||
}
|
||||
|
||||
@Test void blockedExecutorCannotLoseReadyCaptureOnCancellation() throws Exception {
|
||||
capturedAdmissionSurvivesCancellation(true);
|
||||
}
|
||||
|
||||
@Test void fullRecoveredStartupQueueRetriesNewCaptureAndCountsItsOriginalReceiptOnce() throws Exception {
|
||||
var rows = new ArrayList<Map<String, Object>>();
|
||||
var recoveredIds = new HashSet<UUID>();
|
||||
long recoveredTime = System.currentTimeMillis() - 1000;
|
||||
for (int index = 0; index < 256; index++) {
|
||||
UUID id = UUID.randomUUID(); recoveredIds.add(id);
|
||||
rows.add(Map.of("Username", "Steve", "ServiceSite", SERVICE, "Time", recoveredTime,
|
||||
"LocalOccurrenceId", id.toString()));
|
||||
}
|
||||
var yaml = new YamlConfiguration(); yaml.set("Votes", rows);
|
||||
yaml.save(root.resolve("VotifierVoteQueue.yml").toFile());
|
||||
try (var fixture = new Fixture(root, 257)) {
|
||||
when(fixture.plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain();
|
||||
var overflow = fixture.plugin.getVotifierVoteOverflowQueue(); assertEquals(256, overflow.size());
|
||||
fixture.vote(); fixture.timer.submit(() -> { }).get(3, TimeUnit.SECONDS);
|
||||
var workerField = VotifierVoteOverflowQueue.class.getDeclaredField("worker"); workerField.setAccessible(true);
|
||||
var worker = (ScheduledExecutorService) workerField.get(overflow);
|
||||
worker.submit(() -> { }).get(3, TimeUnit.SECONDS);
|
||||
assertEquals(1, fixture.capture().getPendingCaptureCount()); assertTrue(fixture.accepted.isEmpty());
|
||||
var capturesField = VotiferEvent.class.getDeclaredField("captured"); capturesField.setAccessible(true);
|
||||
var captures = (Map<?, ?>) capturesField.get(fixture.capture());
|
||||
UUID original = (UUID) captures.keySet().iterator().next();
|
||||
var capture = captures.values().iterator().next();
|
||||
var timeField = capture.getClass().getDeclaredField("occurredAt"); timeField.setAccessible(true);
|
||||
long originalTime = timeField.getLong(capture);
|
||||
fixture.open();
|
||||
assertTrue(fixture.delivered.await(10, TimeUnit.SECONDS), "Startup must retry capture after recovered overflow drains");
|
||||
fixture.drain(); assertEquals(257, fixture.accepted.size()); assertEquals(257, fixture.posted.size());
|
||||
assertEquals(257, fixture.accepted.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count());
|
||||
assertTrue(fixture.accepted.stream().map(PlayerVoteEvent::getLocalOccurrenceId).toList().containsAll(recoveredIds));
|
||||
var retried = fixture.accepted.stream().filter(event -> original.equals(event.getLocalOccurrenceId())).toList();
|
||||
assertEquals(1, retried.size()); assertEquals(originalTime, retried.getFirst().getCanonicalOccurrenceTime());
|
||||
assertEquals(0L, retried.getFirst().getTime()); assertNull(retried.getFirst().getProxyVoteId());
|
||||
var post = fixture.posted.stream().filter(event -> original.equals(event.getVoteUUID())).toList();
|
||||
assertEquals(1, post.size()); assertEquals(originalTime, post.getFirst().getCanonicalOccurrenceTime());
|
||||
assertEquals(257, fixture.progress().votes());
|
||||
verify(fixture.user, times(257)).setTime(fixture.site);
|
||||
verify(fixture.user, times(257)).addTotal(); verify(fixture.user, times(257)).playerVote(fixture.site, true, false);
|
||||
verify(fixture.plugin.getRewardHandler(), times(1)).giveReward(eq(fixture.user), any(), startsWith("DateVoteMilestonesRuntime."), any());
|
||||
assertEquals(0, overflow.size());
|
||||
}
|
||||
}
|
||||
|
||||
private void capturedAdmissionSurvivesCancellation(boolean readyFirst) throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
UUID original;
|
||||
long originalTime;
|
||||
try (var fixture = new Fixture(root, 0, playerId)) {
|
||||
if (readyFirst) { DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain(); fixture.open(); }
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
fixture.timer.submit(() -> {
|
||||
entered.countDown();
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
while (release.getCount() != 0) {
|
||||
try { assertTrue(release.await(3, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException expected) { interrupted = true; }
|
||||
}
|
||||
} finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
});
|
||||
assertTrue(entered.await(3, TimeUnit.SECONDS));
|
||||
// In the early case, even registration/setup happens behind the held
|
||||
// vote worker. Queue construction and disk load must be independent.
|
||||
if (!readyFirst) DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin);
|
||||
assertNotNull(fixture.plugin.getVotifierVoteOverflowQueue());
|
||||
long before = System.currentTimeMillis(); fixture.vote(); long after = System.currentTimeMillis();
|
||||
assertEquals(1, fixture.capture().getPendingCaptureCount());
|
||||
assertTrue(fixture.accepted.isEmpty());
|
||||
var registryField = VotiferEvent.class.getDeclaredField("captured"); registryField.setAccessible(true);
|
||||
var registry = (Map<?, ?>) registryField.get(fixture.capture());
|
||||
original = (UUID) registry.keySet().iterator().next();
|
||||
var captureTime = registry.values().iterator().next().getClass().getDeclaredField("occurredAt"); captureTime.setAccessible(true);
|
||||
originalTime = captureTime.getLong(registry.values().iterator().next());
|
||||
try {
|
||||
fixture.capture().stop();
|
||||
var cancelled = fixture.timer.shutdownNow();
|
||||
if (readyFirst) assertFalse(cancelled.isEmpty(), "the admitted capture callback never executed");
|
||||
// Before queue initialization, the receipt is provisionally owned
|
||||
// by ingress; retirement must save it without executor admission.
|
||||
} finally { release.countDown(); }
|
||||
assertTrue(fixture.timer.awaitTermination(3, TimeUnit.SECONDS));
|
||||
fixture.plugin.getVotifierVoteOverflowQueue().close();
|
||||
assertEquals(0, fixture.capture().getPendingCaptureCount());
|
||||
var disk = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes");
|
||||
assertEquals(1, disk.size());
|
||||
assertEquals(original, UUID.fromString((String) disk.getFirst().get("LocalOccurrenceId")));
|
||||
assertEquals(originalTime, ((Number) disk.getFirst().get("Time")).longValue());
|
||||
assertTrue(originalTime >= before && originalTime <= after);
|
||||
verify(fixture.user, never()).addTotal();
|
||||
verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any());
|
||||
}
|
||||
try (var restarted = new Fixture(root, 1, playerId)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(restarted.plugin); restarted.drain(); restarted.open(); restarted.received();
|
||||
assertEquals(original, restarted.accepted.getFirst().getLocalOccurrenceId());
|
||||
assertEquals(original, restarted.posted.getFirst().getVoteUUID());
|
||||
assertEquals(originalTime, restarted.accepted.getFirst().getCanonicalOccurrenceTime());
|
||||
assertEquals(0L, restarted.accepted.getFirst().getTime());
|
||||
assertEquals(1, restarted.progress().votes());
|
||||
verify(restarted.user).addTotal(); verify(restarted.user).setTime(restarted.site);
|
||||
verify(restarted.plugin.getRewardHandler(), times(1)).giveReward(eq(restarted.user), any(), startsWith("DateVoteMilestonesRuntime."), any());
|
||||
}
|
||||
try (var secondRestart = new Fixture(root, 0, playerId)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(secondRestart.plugin); secondRestart.drain(); secondRestart.open(); secondRestart.drain();
|
||||
assertTrue(secondRestart.accepted.isEmpty()); assertEquals(1, secondRestart.progress().votes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retiredCaptureCannotReopenAnOldStartupGeneration() throws Exception {
|
||||
try (var fixture = new Fixture(root, 0)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain(); fixture.vote(); fixture.drain();
|
||||
fixture.capture().stop();
|
||||
var field = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed"); field.setAccessible(true); field.setBoolean(fixture.plugin, true);
|
||||
fixture.vote(); fixture.drain();
|
||||
assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size());
|
||||
assertThrows(IllegalStateException.class, () -> DateVoteIngressAccess.initializeDateVoteIngress(fixture.plugin, () -> fail("Retired")));
|
||||
assertTrue(fixture.accepted.isEmpty()); assertEquals(1, fixture.registered.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void startedNativeCallbackBlockedBeforeConsumerSurvivesShutdownAndRewardsOnceAfterRestart() throws Exception {
|
||||
UUID playerId = UUID.randomUUID(), original; long originalTime;
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
try (var fixture = new Fixture(root, 0, playerId)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain(); fixture.open();
|
||||
var serverData = fixture.plugin.getServerData();
|
||||
doAnswer(call -> {
|
||||
entered.countDown();
|
||||
try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test storage timeout"); }
|
||||
catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); throw new IllegalStateException("native receipt storage interrupted before consumer", interrupted);
|
||||
}
|
||||
return null;
|
||||
}).when(serverData).addServiceSite(SERVICE);
|
||||
fixture.vote(); assertTrue(entered.await(3, TimeUnit.SECONDS));
|
||||
assertEquals(1, fixture.capture().getPendingCaptureCount()); assertTrue(fixture.accepted.isEmpty());
|
||||
var registryField = VotiferEvent.class.getDeclaredField("captured"); registryField.setAccessible(true);
|
||||
var registry = (Map<?, ?>) registryField.get(fixture.capture()); original = (UUID) registry.keySet().iterator().next();
|
||||
var captureTime = registry.values().iterator().next().getClass().getDeclaredField("occurredAt"); captureTime.setAccessible(true);
|
||||
originalTime = captureTime.getLong(registry.values().iterator().next());
|
||||
// Use the native teardown fence, then the real listener/worker stop order.
|
||||
var closed = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed"); closed.setAccessible(true); closed.setBoolean(fixture.plugin, true);
|
||||
var readiness = VotingPluginMain.class.getDeclaredField("localVotifierIngressReady"); readiness.setAccessible(true); readiness.setBoolean(fixture.plugin, false);
|
||||
fixture.capture().stop(); fixture.timer.shutdownNow(); assertTrue(fixture.timer.awaitTermination(3, TimeUnit.SECONDS));
|
||||
fixture.plugin.getVotifierVoteOverflowQueue().close(); assertEquals(0, fixture.capture().getPendingCaptureCount());
|
||||
var disk = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes");
|
||||
assertEquals(1, disk.size()); assertEquals(original.toString(), disk.getFirst().get("LocalOccurrenceId"));
|
||||
assertEquals(originalTime, ((Number) disk.getFirst().get("Time")).longValue());
|
||||
verify(fixture.user, never()).cache(); verify(fixture.user, never()).addTotal();
|
||||
verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean());
|
||||
verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any());
|
||||
} finally { release.countDown(); }
|
||||
try (var restarted = new Fixture(root, 1, playerId)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(restarted.plugin); restarted.drain(); restarted.open(); restarted.received();
|
||||
assertEquals(original, restarted.accepted.getFirst().getLocalOccurrenceId());
|
||||
assertEquals(originalTime, restarted.accepted.getFirst().getCanonicalOccurrenceTime()); assertEquals(1, restarted.progress().votes());
|
||||
verify(restarted.user).addTotal(); verify(restarted.user).playerVote(restarted.site, true, false);
|
||||
verify(restarted.plugin.getRewardHandler(), times(1)).giveReward(eq(restarted.user), any(), startsWith("DateVoteMilestonesRuntime."), any());
|
||||
}
|
||||
try (var again = new Fixture(root, 0, playerId)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(again.plugin); again.drain(); again.open(); again.drain();
|
||||
assertTrue(again.accepted.isEmpty()); assertEquals(1, again.progress().votes()); verify(again.user, never()).addTotal();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void productionOverflowRetainsStorageRejectionThenRetriesOriginalOccurrence() throws Exception {
|
||||
try (var fixture = new Fixture(root, 1)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain(); fixture.open();
|
||||
var rejected = new CountDownLatch(1);
|
||||
var permitStorage = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var serverData = fixture.plugin.getServerData();
|
||||
doAnswer(call -> {
|
||||
if (!permitStorage.get()) {
|
||||
rejected.countDown();
|
||||
throw new RejectedExecutionException("synthetic storage admission rejection");
|
||||
}
|
||||
return null;
|
||||
}).when(serverData).addServiceSite(SERVICE);
|
||||
UUID original = UUID.fromString("00000000-0000-0000-0000-000000000019");
|
||||
long originalTime = java.time.Instant.parse("2026-10-01T00:00:00Z").toEpochMilli();
|
||||
var overflow = fixture.plugin.getVotifierVoteOverflowQueue();
|
||||
assertTrue(overflow.enqueue("Steve", SERVICE, originalTime, original));
|
||||
assertTrue(rejected.await(3, TimeUnit.SECONDS));
|
||||
fixture.timer.submit(() -> { }).get(3, TimeUnit.SECONDS);
|
||||
assertEquals(1, overflow.size(), "known pre-dispatch failure must retain durable receipt ownership");
|
||||
assertTrue(fixture.accepted.isEmpty());
|
||||
verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean());
|
||||
verify(fixture.user, never()).addPoints();
|
||||
var persisted = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes");
|
||||
assertEquals(1, persisted.size());
|
||||
assertEquals(original.toString(), persisted.getFirst().get("LocalOccurrenceId"));
|
||||
assertEquals(originalTime, ((Number) persisted.getFirst().get("Time")).longValue());
|
||||
|
||||
permitStorage.set(true);
|
||||
fixture.received();
|
||||
assertEquals(0, overflow.size());
|
||||
assertEquals(1, fixture.accepted.size()); assertEquals(1, fixture.posted.size());
|
||||
assertEquals(original, fixture.accepted.getFirst().getLocalOccurrenceId());
|
||||
assertEquals(originalTime, fixture.accepted.getFirst().getCanonicalOccurrenceTime());
|
||||
assertEquals(original, fixture.posted.getFirst().getVoteUUID());
|
||||
assertEquals(originalTime, fixture.posted.getFirst().getCanonicalOccurrenceTime());
|
||||
assertEquals(1, fixture.progress().votes());
|
||||
verify(fixture.user).playerVote(fixture.site, true, false);
|
||||
verify(fixture.user).addPoints();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void admittedOverflowCallbackCannotRewardAfterStartupGenerationRetires() throws Exception {
|
||||
try (var fixture = new Fixture(root, 0)) {
|
||||
DateVoteIngressAccess.registerEarlyVotifierIngress(fixture.plugin); fixture.drain(); fixture.vote(); fixture.drain();
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
fixture.timer.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(3, TimeUnit.SECONDS)); } catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
assertTrue(entered.await(3, TimeUnit.SECONDS));
|
||||
try {
|
||||
fixture.open();
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||
// The constructor's delayed replay occupies one slot; overflow is another.
|
||||
while (fixture.timer.getQueue().size() < 2 && System.nanoTime() < deadline) Thread.sleep(5);
|
||||
assertTrue(fixture.timer.getQueue().size() >= 2);
|
||||
fixture.capture().stop();
|
||||
var field = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed");
|
||||
field.setAccessible(true); field.setBoolean(fixture.plugin, true);
|
||||
} finally { release.countDown(); }
|
||||
fixture.drain();
|
||||
assertTrue(fixture.accepted.isEmpty()); assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size());
|
||||
verify(fixture.user, never()).addTotal(); verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
-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));
|
||||
}
|
||||
}
|
||||
}
|
||||
-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());
|
||||
}
|
||||
}
|
||||
-64
@@ -47,7 +47,6 @@ import com.bencodez.votingplugin.proxy.cache.ProxyOnlineVoteCacheTable;
|
||||
import com.bencodez.votingplugin.proxy.cache.ProxyTimedVoteCacheTable;
|
||||
import com.bencodez.votingplugin.proxy.cache.ProxyVoteCacheTable;
|
||||
import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler;
|
||||
import com.bencodez.votingplugin.proxy.velocity.VelocityJsonVoteCache;
|
||||
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
|
||||
|
||||
/**
|
||||
@@ -1399,69 +1398,6 @@ public class VoteCacheHandlerVoteIdTest {
|
||||
return new GsonDataNode(data);
|
||||
}
|
||||
|
||||
@Test
|
||||
void occurrenceMetadataSurvivesBothJsonAdaptersAndCacheRestart() throws Exception {
|
||||
for (boolean bungee : new boolean[] { true, false }) {
|
||||
Path folder = tempDir.resolve(bungee ? "bungee" : "velocity");
|
||||
Files.createDirectories(folder);
|
||||
VotingPluginBungee plugin = mock(VotingPluginBungee.class);
|
||||
when(plugin.getDataFolder()).thenReturn(folder.toFile());
|
||||
IVoteCache store = bungee ? new BungeeJsonVoteCache(plugin)
|
||||
: new VelocityJsonVoteCache(folder.resolve("votecache.json").toFile());
|
||||
VoteCacheHandler writer = newHandler(store);
|
||||
OfflineBungeeVote server = vote(UUID.randomUUID(), 456L);
|
||||
server.setCanonicalOccurrenceTime(123L);
|
||||
OfflineBungeeVote online = vote(UUID.randomUUID(), 457L);
|
||||
online.setCanonicalOccurrenceTime(123L);
|
||||
VoteTimeQueue timed = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 458L);
|
||||
timed.setUuid("player-uuid"); timed.setCanonicalOccurrenceTime(123L);
|
||||
timed.setMultiProxyOrigin("Primary");
|
||||
assertTrue(writer.addServerVoteDurably("server", server));
|
||||
assertTrue(writer.addOnlineVoteDurably("player-uuid", online));
|
||||
assertTrue(writer.addTimeVoteToCache(timed));
|
||||
IVoteCache recoveredStore = bungee ? new BungeeJsonVoteCache(plugin)
|
||||
: new VelocityJsonVoteCache(folder.resolve("votecache.json").toFile());
|
||||
VoteCacheHandler restarted = newHandler(recoveredStore);
|
||||
restarted.load();
|
||||
assertEquals(456L, restarted.getVotes("server").get(0).getTime());
|
||||
assertEquals(123L, restarted.getVotes("server").get(0).getCanonicalOccurrenceTime());
|
||||
assertEquals(457L, restarted.getOnlineVotes("player-uuid").get(0).getTime());
|
||||
assertEquals(123L, restarted.getOnlineVotes("player-uuid").get(0).getCanonicalOccurrenceTime());
|
||||
assertEquals(458L, restarted.getTimeChangeQueue().element().getTime());
|
||||
assertEquals(123L, restarted.getTimeChangeQueue().element().getCanonicalOccurrenceTime());
|
||||
assertEquals("Primary", restarted.getTimeChangeQueue().element().getMultiProxyOrigin());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlRecoveryDecodesOccurrenceFromAllExistingOpaquePayloadColumns() throws Exception {
|
||||
VoteCacheHandler recovered = newHandler(storage);
|
||||
ProxyVoteCacheTable serverTable = mock(ProxyVoteCacheTable.class);
|
||||
ProxyOnlineVoteCacheTable onlineTable = mock(ProxyOnlineVoteCacheTable.class);
|
||||
ProxyTimedVoteCacheTable timedTable = mock(ProxyTimedVoteCacheTable.class);
|
||||
String payload = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.store("v2//1//2//3//4//5//6//7//8", 123L);
|
||||
UUID serverId = UUID.randomUUID(), onlineId = UUID.randomUUID(), timedId = UUID.randomUUID();
|
||||
when(serverTable.getAllVotesChecked()).thenReturn(List.of(new ProxyVoteCacheTable.VoteRow(1,
|
||||
serverId.toString(), "player-uuid", "Player", "Service", 456L, true, false, false,
|
||||
payload, false, false, "", "", false, "", "", "server")));
|
||||
when(onlineTable.getAllVotesChecked()).thenReturn(List.of(new ProxyOnlineVoteCacheTable.VoteRow(2,
|
||||
onlineId.toString(), "player-uuid", "Player", "Service", 457L, true, false, false,
|
||||
payload, false, false, "", "", false, "", "")));
|
||||
when(timedTable.getAllVotesChecked()).thenReturn(List.of(new ProxyTimedVoteCacheTable.TimedVoteRow(3,
|
||||
"Player", "Service", 458L, timedId, "player-uuid", false, "", "", payload, false)));
|
||||
setPrivateField(recovered, "useMySQL", true);
|
||||
setPrivateField(recovered, "voteCacheTable", serverTable);
|
||||
setPrivateField(recovered, "onlineVoteCacheTable", onlineTable);
|
||||
setPrivateField(recovered, "timedVoteCacheTable", timedTable);
|
||||
recovered.load();
|
||||
assertEquals(456L, recovered.getVotes("server").get(0).getTime());
|
||||
assertEquals(123L, recovered.getVotes("server").get(0).getCanonicalOccurrenceTime());
|
||||
assertEquals(457L, recovered.getOnlineVotes("player-uuid").get(0).getTime());
|
||||
assertEquals(123L, recovered.getOnlineVotes("player-uuid").get(0).getCanonicalOccurrenceTime());
|
||||
assertEquals(458L, recovered.getTimeChangeQueue().element().getTime());
|
||||
assertEquals(123L, recovered.getTimeChangeQueue().element().getCanonicalOccurrenceTime());
|
||||
}
|
||||
|
||||
private static VoteCacheHandler newHandler(IVoteCache storage) {
|
||||
return newHandler(storage, true);
|
||||
}
|
||||
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.proxy.OfflineBungeeVote;
|
||||
import com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata;
|
||||
import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
|
||||
|
||||
class VoteOccurrenceMetadataTest {
|
||||
@SuppressWarnings("deprecation")
|
||||
@Test void metadataPreservesLegacyAndVersionedTotalsAndCorrelation() {
|
||||
UUID correlation = UUID.randomUUID();
|
||||
for (String payload : new String[] {
|
||||
"1//2//3//4//5//999//6//7//8//" + correlation,
|
||||
"v2//1//2//3//4//5//6//7//8//" + correlation }) {
|
||||
String decorated = VoteOccurrenceMetadata.store(payload, 123L);
|
||||
var original = VoteTotalsSnapshot.parseStorage(payload);
|
||||
var restored = VoteTotalsSnapshot.parseStorage(decorated);
|
||||
assertEquals(original.toStorageString(), restored.toStorageString());
|
||||
assertEquals(correlation, restored.getVoteUUID());
|
||||
assertEquals(123L, VoteOccurrenceMetadata.read(decorated));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cachedTotalsUpdatesAndCopiesKeepOccurrenceSeparateFromCooldownTime() {
|
||||
var offline = new OfflineBungeeVote(UUID.randomUUID(), "Player", "uuid", "Service", 456L, true, "");
|
||||
offline.setCanonicalOccurrenceTime(123L);
|
||||
offline.setText("v2//1//2//3//4//5//6//7//8");
|
||||
var copied = new OfflineBungeeVote(offline.getVoteId(), offline.getPlayerName(), offline.getUuid(),
|
||||
offline.getService(), offline.getTime(), offline.isRealVote(), offline.getText());
|
||||
assertEquals(456L, copied.getTime());
|
||||
assertEquals(123L, copied.getCanonicalOccurrenceTime());
|
||||
var timed = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 789L);
|
||||
timed.setCanonicalOccurrenceTime(123L);
|
||||
timed.setTotals("v2//8//7//6//5//4//3//2//1");
|
||||
assertEquals(789L, timed.getTime());
|
||||
assertEquals(123L, timed.getCanonicalOccurrenceTime());
|
||||
assertEquals(8, VoteTotalsSnapshot.parseStorage(timed.getTotals()).getAllTimeTotal());
|
||||
}
|
||||
|
||||
@Test void malformedOversizedAndConflictingMetadataCannotBecomeDateProvenance() {
|
||||
for (String token : new String[] { "", "0", "-1", "bad", "9".repeat(100), "123//vp-occurrence-time:456" }) {
|
||||
String payload = "v2//1//2//3//4//5//6//7//8//vp-occurrence-time:" + token;
|
||||
assertEquals(-1L, VoteOccurrenceMetadata.read(payload));
|
||||
var envelope = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 789L,
|
||||
true, true, payload, UUID.randomUUID(), false, false, 1, 1);
|
||||
assertEquals(-1L, VotingPluginWire.readVote(envelope).canonicalOccurrenceTime);
|
||||
assertEquals(789L, VotingPluginWire.readVote(envelope).time);
|
||||
assertEquals(1, VoteTotalsSnapshot.parseStorage(payload).getAllTimeTotal());
|
||||
}
|
||||
var envelope = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 789L,
|
||||
true, true, VoteOccurrenceMetadata.store("", 123L), UUID.randomUUID(), false, false, 1, 1);
|
||||
var conflicting = envelope.toBuilder().put(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME, 456L).build();
|
||||
assertEquals(-1L, VotingPluginWire.readVote(conflicting).canonicalOccurrenceTime);
|
||||
assertEquals(789L, VotingPluginWire.readVote(conflicting).time);
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,8 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
@@ -29,15 +27,12 @@ import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import com.bencodez.advancedcore.api.user.UserManager;
|
||||
import com.bencodez.advancedcore.api.user.usercache.UserDataManager;
|
||||
import com.bencodez.simpleapi.sql.DataType;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.config.SpecialRewardsConfig;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceHolders;
|
||||
import com.bencodez.votingplugin.specialrewards.voteparty.VoteParty;
|
||||
import com.bencodez.votingplugin.specialrewards.voteparty.VotePartyRewardHandler;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
public class VotePartyTest {
|
||||
@@ -93,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
|
||||
@@ -119,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<>());
|
||||
@@ -195,48 +188,4 @@ public class VotePartyTest {
|
||||
|
||||
assertEquals("No Player", playerName);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedMysqlResetWaitsForQueuedRewardEligibilityReads() throws Exception {
|
||||
UserDataManager dataManager = Mockito.mock(UserDataManager.class);
|
||||
when(plugin.getUserManager().getDataManager()).thenReturn(dataManager);
|
||||
VotePartyRewardHandler handler = Mockito.mock(VotePartyRewardHandler.class);
|
||||
replaceRewardHandler(handler);
|
||||
AtomicReference<Runnable> queuedReset = new AtomicReference<>();
|
||||
when(dataManager.deferSharedStorageWork(any(Runnable.class))).thenAnswer(invocation -> {
|
||||
queuedReset.set(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
|
||||
voteParty.giveRewards(user, false);
|
||||
|
||||
verify(handler).giveRewards(user, false);
|
||||
// Voter-list state clears immediately; only the SQL counter wipe is deferred.
|
||||
verify(plugin.getServerData()).setStringList(eq("VoteParty.Voted"), any());
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(queuedReset.get());
|
||||
verify(plugin.getUserManager(), never()).removeAllKeyValues("VotePartyVotes", DataType.INTEGER);
|
||||
|
||||
queuedReset.get().run();
|
||||
verify(plugin.getUserManager()).removeAllKeyValues("VotePartyVotes", DataType.INTEGER);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonsharedResetRemainsImmediate() throws Exception {
|
||||
UserDataManager dataManager = Mockito.mock(UserDataManager.class);
|
||||
when(plugin.getUserManager().getDataManager()).thenReturn(dataManager);
|
||||
VotePartyRewardHandler handler = Mockito.mock(VotePartyRewardHandler.class);
|
||||
replaceRewardHandler(handler);
|
||||
|
||||
voteParty.giveRewards(user, false);
|
||||
|
||||
verify(handler).giveRewards(user, false);
|
||||
verify(dataManager).deferSharedStorageWork(any(Runnable.class));
|
||||
verify(plugin.getUserManager()).removeAllKeyValues("VotePartyVotes", DataType.INTEGER);
|
||||
}
|
||||
|
||||
private void replaceRewardHandler(VotePartyRewardHandler handler) throws Exception {
|
||||
Field field = VoteParty.class.getDeclaredField("rewardHandler");
|
||||
field.setAccessible(true);
|
||||
field.set(voteParty, handler);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -147,14 +147,14 @@ class VotingPluginMainPlaceholderPresenceTest {
|
||||
verify(plugin, times(2)).captureOnlineTopVoterIgnore(any(), any());
|
||||
}
|
||||
@Test
|
||||
void offlineStorageIdentityWinsDuringColdPresenceRefresh() {
|
||||
void cachedStorageIdentityWinsDuringColdPresenceRefresh() {
|
||||
Player player = mock(Player.class);
|
||||
UuidLookup lookup = mock(UuidLookup.class);
|
||||
UUID bukkitUuid = UUID.randomUUID();
|
||||
UUID storageUuid = UUID.randomUUID();
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getUniqueId()).thenReturn(bukkitUuid);
|
||||
when(lookup.getUUIDWithoutStorage("Player")).thenReturn(storageUuid.toString());
|
||||
when(lookup.getCachedUUID("Player")).thenReturn(storageUuid.toString());
|
||||
|
||||
try (var uuidLookup = mockStatic(UuidLookup.class)) {
|
||||
uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup);
|
||||
@@ -169,7 +169,7 @@ class VotingPluginMainPlaceholderPresenceTest {
|
||||
UUID bukkitUuid = UUID.randomUUID();
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getUniqueId()).thenReturn(bukkitUuid);
|
||||
when(lookup.getUUIDWithoutStorage("Player")).thenReturn("");
|
||||
when(lookup.getCachedUUID("Player")).thenReturn("");
|
||||
|
||||
try (var uuidLookup = mockStatic(UuidLookup.class)) {
|
||||
uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup);
|
||||
|
||||
-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);
|
||||
}
|
||||
}
|
||||
+2
-18
@@ -722,9 +722,6 @@ public class VotingPluginProxyTest {
|
||||
target.setAccessible(true);
|
||||
target.set(retry, field.getValue());
|
||||
}
|
||||
java.lang.reflect.Field occurrence = retryType.getDeclaredField("canonicalOccurrenceTime");
|
||||
occurrence.setAccessible(true);
|
||||
occurrence.setLong(retry, 123L);
|
||||
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
|
||||
retriesField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -741,11 +738,7 @@ public class VotingPluginProxyTest {
|
||||
@SuppressWarnings({ "rawtypes", "unchecked" })
|
||||
org.mockito.ArgumentCaptor<java.util.Collection<String>> recipients =
|
||||
(org.mockito.ArgumentCaptor) org.mockito.ArgumentCaptor.forClass(java.util.Collection.class);
|
||||
org.mockito.ArgumentCaptor<JsonEnvelope> envelopes = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class);
|
||||
verify(multiProxyHandler, Mockito.times(2)).sendMultiProxyEnvelopeAccepted(envelopes.capture(), recipients.capture());
|
||||
assertTrue(envelopes.getAllValues().stream().allMatch(envelope ->
|
||||
VotingPluginWire.readVote(envelope).canonicalOccurrenceTime == 123L
|
||||
&& VotingPluginWire.readVote(envelope).time == 100L));
|
||||
verify(multiProxyHandler, Mockito.times(2)).sendMultiProxyEnvelopeAccepted(Mockito.any(), recipients.capture());
|
||||
assertEquals(java.util.Set.of(java.util.Set.of("proxy2"), java.util.Set.of("ProxyLegacy")),
|
||||
new java.util.HashSet<>(recipients.getAllValues()));
|
||||
java.lang.reflect.Method settle = VotingPluginProxy.class
|
||||
@@ -757,8 +750,6 @@ public class VotingPluginProxyTest {
|
||||
verify(voteCache, Mockito.atLeast(2)).addTimeVoteToCache(outboxes.capture());
|
||||
assertTrue(outboxes.getAllValues().stream().allMatch(outbox -> voteId.equals(outbox.getVoteId())));
|
||||
assertEquals(1, queue.size());
|
||||
assertEquals(100L, queue.element().getTime());
|
||||
assertEquals(123L, queue.element().getCanonicalOccurrenceTime());
|
||||
assertEquals(voteId, queue.element().getVoteId());
|
||||
assertEquals(java.util.Set.of("proxy2"), queue.element().getMultiProxyRecipients());
|
||||
}
|
||||
@@ -847,8 +838,6 @@ public class VotingPluginProxyTest {
|
||||
verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any());
|
||||
assertEquals(1, queue.size());
|
||||
assertTrue(queue.element().isMultiProxyCapabilityDiscoveryPending());
|
||||
assertEquals(100L, queue.element().getTime());
|
||||
assertEquals(100L, queue.element().getCanonicalOccurrenceTime());
|
||||
assertTrue(queue.element().isProcessed());
|
||||
verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L),
|
||||
Mockito.eq(java.util.concurrent.TimeUnit.SECONDS));
|
||||
@@ -1708,8 +1697,7 @@ public class VotingPluginProxyTest {
|
||||
|
||||
assertEquals(1, queue.size());
|
||||
assertEquals(voteId, queue.element().getVoteId());
|
||||
assertEquals(0, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.parseStorage(queue.element().getTotals()).getAllTimeTotal());
|
||||
assertEquals(100L, queue.element().getCanonicalOccurrenceTime());
|
||||
assertEquals("", queue.element().getTotals());
|
||||
assertFalse(queue.element().isRealVote());
|
||||
assertEquals(1024, retryMap.size());
|
||||
}
|
||||
@@ -1737,10 +1725,6 @@ public class VotingPluginProxyTest {
|
||||
assertEquals("Player", retained.getName());
|
||||
assertEquals("Service", retained.getService());
|
||||
assertEquals(1234L, retained.getTime());
|
||||
assertEquals(1234L, retained.getCanonicalOccurrenceTime());
|
||||
assertFalse(retained.isTotalsApplied());
|
||||
assertFalse(retained.isVotePartyApplied());
|
||||
verify(voteCache, Mockito.never()).updateTimeVote(Mockito.any());
|
||||
verify(voteCache, Mockito.times(1)).addTimeVoteToCache(Mockito.any());
|
||||
}
|
||||
|
||||
|
||||
-572
@@ -1,572 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests.backendproxy.messaging;
|
||||
|
||||
import com.bencodez.votingplugin.backendproxy.messaging.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.Event;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import com.bencodez.advancedcore.api.user.validation.UserValidationResult;
|
||||
import com.bencodez.advancedcore.api.user.validation.ValidationSource;
|
||||
import com.bencodez.advancedcore.api.user.validation.ValidationStatus;
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
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.OrderedVoteOutcome;
|
||||
import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager;
|
||||
import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.listeners.PlayerVoteListener;
|
||||
import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteLedger;
|
||||
import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
|
||||
/** Exercises the router, native event adapter, accepted listener and real date ledger together. */
|
||||
class BackendDateVoteAccountingRegressionTest {
|
||||
private static final UUID PLAYER = UUID.fromString("e5baec32-9b2c-4fc8-9aed-0e0285e3c33d");
|
||||
private static final long OCCURRED_AT = Instant.parse("2026-10-15T00:00:00Z").toEpochMilli();
|
||||
private static final String SERVICE = "known.example";
|
||||
private static final int POINTS = 7;
|
||||
|
||||
@TempDir Path root;
|
||||
private final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
private final VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
private final VoteSite site = mock(VoteSite.class);
|
||||
private final YamlConfiguration config = new YamlConfiguration();
|
||||
private final List<PlayerVoteEvent> accepted = new ArrayList<>();
|
||||
private final List<PlayerPostVoteEvent> posted = new ArrayList<>();
|
||||
private final AtomicInteger storedPoints = new AtomicInteger(37);
|
||||
private final ProcessedVoteCache cache = spy(new ProcessedVoteCache());
|
||||
private BackendProxyMessageRouter router;
|
||||
private boolean cancelNext;
|
||||
private MockedStatic<Bukkit> bukkit;
|
||||
private MockedStatic<com.bencodez.advancedcore.AdvancedCorePlugin> advancedCore;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getDataFolder()).thenReturn(root.toFile());
|
||||
when(plugin.getStorageType()).thenReturn(null); // Exercise ordinary synchronous point storage.
|
||||
when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings().getServer()).thenReturn("owner");
|
||||
when(plugin.getOptions().isProcessRewards()).thenReturn(true);
|
||||
when(plugin.getOptions().getServer()).thenReturn("owner");
|
||||
advancedCore=mockStatic(com.bencodez.advancedcore.AdvancedCorePlugin.class);
|
||||
advancedCore.when(com.bencodez.advancedcore.AdvancedCorePlugin::getInstance).thenReturn(plugin);
|
||||
when(plugin.getConfigFile().getPointsOnVote()).thenReturn(POINTS);
|
||||
when(plugin.getConfigFile().isAddTotals()).thenReturn(true);
|
||||
when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config);
|
||||
when(plugin.getUserManager().getProperName("Player")).thenReturn("Player");
|
||||
when(plugin.getUserManager().getValidationService().validate("Player", false)).thenReturn(
|
||||
new UserValidationResult(ValidationStatus.VALID, "Player", ValidationSource.STORAGE, "fixture", false));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER, "Player")).thenReturn(user);
|
||||
when(plugin.getVoteSiteManager().getVoteSite(SERVICE, true)).thenReturn(site);
|
||||
when(site.isEnabled()).thenReturn(true);
|
||||
when(site.getKey()).thenReturn("Example");
|
||||
when(site.getServiceSite()).thenReturn(SERVICE);
|
||||
when(user.getPlayerName()).thenReturn("Player");
|
||||
when(user.getUUID()).thenReturn(PLAYER.toString());
|
||||
when(user.getJavaUUID()).thenReturn(PLAYER);
|
||||
when(user.isOnline()).thenReturn(true);
|
||||
when(user.getPoints()).thenAnswer(ignored -> storedPoints.get());
|
||||
doAnswer(call -> { storedPoints.set(call.getArgument(0)); return null; })
|
||||
.when(user).setPoints(anyInt(), eq(false));
|
||||
var field = VotingPluginUser.class.getDeclaredField("plugin");
|
||||
field.setAccessible(true);
|
||||
field.set(user, plugin);
|
||||
doCallRealMethod().when(user).addPoints(anyInt());
|
||||
doCallRealMethod().when(user).addPoints(anyInt(), anyBoolean());
|
||||
doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong());
|
||||
|
||||
var dispatcher = plugin.getServer().getPluginManager();
|
||||
var listener = new PlayerVoteListener(plugin);
|
||||
doAnswer(call -> {
|
||||
Event event = call.getArgument(0);
|
||||
if (event instanceof PlayerVoteEvent vote) {
|
||||
accepted.add(vote);
|
||||
if (cancelNext) { vote.setCancelled(true); cancelNext = false; }
|
||||
listener.onplayerVote(vote);
|
||||
} else if (event instanceof PlayerPostVoteEvent post) {
|
||||
posted.add(post);
|
||||
}
|
||||
return null;
|
||||
}).when(dispatcher).callEvent(any());
|
||||
bukkit = mockStatic(Bukkit.class);
|
||||
bukkit.when(Bukkit::getPluginManager).thenReturn(dispatcher);
|
||||
router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class),
|
||||
mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache);
|
||||
definition("october");
|
||||
reloadDefinitions();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void closeBukkit() {
|
||||
if (bukkit != null) bukkit.close();
|
||||
if (advancedCore != null) advancedCore.close();
|
||||
}
|
||||
|
||||
private void definition(String id) {
|
||||
String prefix = "DateVoteMilestones." + id + ".";
|
||||
config.set(prefix + "Enabled", true);
|
||||
config.set(prefix + "Start", "2026-10-01T00:00:00");
|
||||
config.set(prefix + "End", "2026-11-01T00:00:00");
|
||||
config.set(prefix + "Timezone", "UTC");
|
||||
config.set(prefix + "AccountingServer", "owner");
|
||||
config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks");
|
||||
}
|
||||
|
||||
private void reloadDefinitions() {
|
||||
var milestones = new DateVoteMilestones(plugin);
|
||||
milestones.reload();
|
||||
when(plugin.getDateVoteMilestones()).thenReturn(milestones);
|
||||
}
|
||||
|
||||
private DateVoteLedger.Progress progress(String id) throws Exception {
|
||||
var event = DateVoteMilestones.parse(id, config.getConfigurationSection("DateVoteMilestones." + id));
|
||||
return new DateVoteLedger(root.resolve("date-vote-milestones")).progress(event, PLAYER);
|
||||
}
|
||||
|
||||
private JsonEnvelope vote(UUID id, boolean queued) {
|
||||
var original = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT,
|
||||
true, true, "", id, false, false, 1, 1, true);
|
||||
var builder = JsonEnvelope.builder(original.getSubChannel()).schema(original.getSchema());
|
||||
original.getFields().forEach(builder::put);
|
||||
return VotingPluginWire.requestVoteDeliveryAcknowledgement(
|
||||
builder.put(VotingPluginWire.K_QUEUED_DELIVERY, queued).build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledLiveDeliveryDoesNotCountOrCreditAndDoesNotBlockTheNextAcceptedOccurrence() throws Exception {
|
||||
assertCancelledDeliveryRetiresWithoutEffects(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledQueuedDeliveryDoesNotCountOrCreditAndDoesNotBlockTheNextAcceptedOccurrence() throws Exception {
|
||||
assertCancelledDeliveryRetiresWithoutEffects(true);
|
||||
}
|
||||
|
||||
private void assertCancelledDeliveryRetiresWithoutEffects(boolean queued) throws Exception {
|
||||
UUID cancelledId = UUID.randomUUID();
|
||||
cancelNext = true;
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
var cancelled = VotingPluginWire.requestVoteDeliveryAcknowledgement(vote(cancelledId, queued));
|
||||
router.handleOrderedVote(cancelled, outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
router.handleOrderedVote(cancelled, outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
assertEquals(1, accepted.size(), "completed cancellation must be deduplicated");
|
||||
assertTrue(accepted.get(0).isCancelled());
|
||||
assertEquals(OCCURRED_AT, accepted.get(0).getCanonicalOccurrenceTime());
|
||||
assertEquals(0, progress("october").votes());
|
||||
assertEquals(37, storedPoints.get());
|
||||
assertTrue(posted.isEmpty());
|
||||
verify(user, never()).playerVote(any(), anyBoolean(), anyBoolean());
|
||||
verify(user, never()).addTotal();
|
||||
verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any());
|
||||
|
||||
UUID acceptedId = UUID.randomUUID();
|
||||
router.handleOrderedVote(VotingPluginWire.requestVoteDeliveryAcknowledgement(vote(acceptedId, queued)), outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
assertEquals(2, accepted.size());
|
||||
assertFalse(accepted.get(1).isCancelled());
|
||||
assertEquals(1, progress("october").votes());
|
||||
assertEquals(37 + POINTS, storedPoints.get());
|
||||
assertEquals(1, posted.size());
|
||||
verify(user, times(1)).playerVote(site, true, true);
|
||||
verify(user, times(1)).addTotal();
|
||||
assertTrue(cache.hasCompletedEffects(cancelledId));
|
||||
assertTrue(cache.hasCompletedEffects(acceptedId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reliableForwardingSeparatesOriginalMembershipFromReceiverCooldownTime() throws Exception {
|
||||
config.set("DateVoteMilestones.october.Start", "2026-10-15T00:00:00");
|
||||
config.set("DateVoteMilestones.october.End", "2026-10-16T00:00:00");
|
||||
reloadDefinitions();
|
||||
UUID id = UUID.randomUUID();
|
||||
JsonEnvelope forwarded = receiverEnvelope(id, OCCURRED_AT, null, true);
|
||||
var wire = VotingPluginWire.readVote(forwarded);
|
||||
assertEquals(OCCURRED_AT, wire.canonicalOccurrenceTime);
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
router.handleOrderedVote(forwarded, outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
assertEquals(1, progress("october").votes());
|
||||
assertEquals(java.util.Set.of(1), progress("october").submittedAwards());
|
||||
assertEquals(id, accepted.get(0).getProxyVoteId());
|
||||
assertEquals(OCCURRED_AT, accepted.get(0).getCanonicalOccurrenceTime());
|
||||
assertEquals(OCCURRED_AT, posted.get(0).getCanonicalOccurrenceTime());
|
||||
assertEquals(wire.time, posted.get(0).getVoteTime());
|
||||
assertNormalEffectsOnce(id, wire.time);
|
||||
verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void newForwarderPreservesIndependentOccurrenceTimeAcrossAnotherHop() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var forwarded = receiverEnvelope(id, 1L, OCCURRED_AT, true);
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
router.handleOrderedVote(forwarded, outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
assertEquals(1, progress("october").votes());
|
||||
assertEquals(OCCURRED_AT, posted.get(0).getCanonicalOccurrenceTime());
|
||||
assertNormalEffectsOnce(id, VotingPluginWire.readVote(forwarded).time);
|
||||
}
|
||||
|
||||
@Test
|
||||
void earlierOccurrenceAndOriginlessForwardingRemainOutsideCanonicalAccounting() throws Exception {
|
||||
for (boolean originKnown : new boolean[] { true, false }) {
|
||||
UUID id = UUID.randomUUID();
|
||||
var forwarded = receiverEnvelope(id, 1L, null, originKnown);
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
router.handleOrderedVote(forwarded, outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
}
|
||||
assertEquals(0, progress("october").votes());
|
||||
assertEquals(2, posted.size());
|
||||
assertEquals(1L, posted.get(0).getCanonicalOccurrenceTime());
|
||||
assertEquals(-1L, posted.get(1).getCanonicalOccurrenceTime());
|
||||
verify(user, times(2)).playerVote(site, true, true);
|
||||
verify(user, times(2)).addTotal();
|
||||
verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedOccurrenceMetadataExcludesDateMembershipWithoutChangingNormalProcessing() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var original = vote(id, false);
|
||||
var forwarded = original.toBuilder().put(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME,
|
||||
"9".repeat(100)).build();
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
router.handleOrderedVote(forwarded, outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
assertEquals(0, progress("october").votes());
|
||||
assertEquals(-1L, accepted.get(0).getCanonicalOccurrenceTime());
|
||||
assertNormalEffectsOnce(id, OCCURRED_AT);
|
||||
verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldBackendEnvelopeWithoutOccurrenceProvenanceKeepsNormalEffectsOnly() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var source = vote(id, false);
|
||||
var oldSender = JsonEnvelope.builder(source.getSubChannel()).schema(source.getSchema());
|
||||
source.getFields().forEach((key, value) -> {
|
||||
if (!key.equals(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME)) oldSender.put(key, value);
|
||||
});
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
router.handleOrderedVote(oldSender.build(), outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
assertEquals(0, progress("october").votes());
|
||||
assertEquals(id, accepted.get(0).getProxyVoteId());
|
||||
assertEquals(-1L, posted.get(0).getCanonicalOccurrenceTime());
|
||||
assertNormalEffectsOnce(id, OCCURRED_AT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cachedReplayPreservesOccurrenceAndEstablishedProcessingTime() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var cached = new com.bencodez.votingplugin.proxy.OfflineBungeeVote(id, "Player", PLAYER.toString(),
|
||||
SERVICE, 1L, true, "");
|
||||
cached.setCanonicalOccurrenceTime(OCCURRED_AT);
|
||||
cached.setWasOnline(true);
|
||||
var envelope = cachedEnvelope(cached);
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
router.handleOrderedVote(envelope, outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
assertEquals(1, progress("october").votes());
|
||||
assertEquals(OCCURRED_AT, posted.get(0).getCanonicalOccurrenceTime());
|
||||
assertEquals(1L, posted.get(0).getVoteTime());
|
||||
assertNormalEffectsOnce(id, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyCacheReplayDoesNotInventOccurrenceFromProcessingTime() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var cached = new com.bencodez.votingplugin.proxy.OfflineBungeeVote(id, "Player", PLAYER.toString(),
|
||||
SERVICE, OCCURRED_AT, true, "");
|
||||
cached.setWasOnline(true);
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
router.handleOrderedVote(cachedEnvelope(cached), outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
assertEquals(0, progress("october").votes());
|
||||
assertEquals(id, accepted.get(0).getProxyVoteId());
|
||||
assertEquals(-1L, posted.get(0).getCanonicalOccurrenceTime());
|
||||
assertNormalEffectsOnce(id, OCCURRED_AT);
|
||||
}
|
||||
|
||||
private JsonEnvelope cachedEnvelope(com.bencodez.votingplugin.proxy.OfflineBungeeVote cached) throws Exception {
|
||||
var proxy = new com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl();
|
||||
var method = com.bencodez.votingplugin.proxy.VotingPluginProxy.class.getDeclaredMethod("cachedVoteEnvelope",
|
||||
com.bencodez.votingplugin.proxy.OfflineBungeeVote.class, boolean.class, boolean.class,
|
||||
int.class, int.class);
|
||||
method.setAccessible(true);
|
||||
return (JsonEnvelope) method.invoke(proxy, cached, false, false, 1, 1);
|
||||
}
|
||||
|
||||
private JsonEnvelope receiverEnvelope(UUID id, long senderTime, Long originalTime, boolean originKnown) throws Exception {
|
||||
var proxy = spy(new com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl());
|
||||
proxy.setDataFolder(root.resolve(id.toString()).toFile());
|
||||
var votes = mock(com.bencodez.votingplugin.proxy.cache.VoteCacheHandler.class);
|
||||
when(votes.markMultiProxyVoteCompletedDurably(any())).thenReturn(true);
|
||||
when(votes.getTimeChangeQueue()).thenReturn(new java.util.concurrent.ConcurrentLinkedQueue<>());
|
||||
when(votes.addOnlineVoteDurably(anyString(), any())).thenReturn(true);
|
||||
when(votes.addServerVoteDurably(anyString(), any())).thenReturn(true);
|
||||
when(votes.updateOnlineVote(anyString(), any())).thenReturn(true);
|
||||
when(votes.updateServerVote(anyString(), any())).thenReturn(true);
|
||||
doReturn(votes).when(proxy).getVoteCacheHandler();
|
||||
doNothing().when(proxy).addVoteParty();
|
||||
proxy.setMethod(com.bencodez.votingplugin.proxy.BungeeMethod.PLUGINMESSAGING);
|
||||
proxy.setGlobalMessageProxyHandlerForTest(new com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler() {
|
||||
@Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { }
|
||||
});
|
||||
proxy.loadMultiProxySupport();
|
||||
var handler = spy(proxy.getMultiProxyHandler());
|
||||
doNothing().when(handler).acknowledgeMultiProxyVote(any(), anyString());
|
||||
proxy.setMultiProxyHandler(handler);
|
||||
when(proxy.getConfig().getMultiProxySupport()).thenReturn(true);
|
||||
when(proxy.getConfig().getOnlineMode()).thenReturn(true);
|
||||
when(proxy.getConfig().getSendVotesToAllServers()).thenReturn(true);
|
||||
var legacyField = com.bencodez.votingplugin.proxy.VotingPluginProxy.class.getDeclaredField("legacyVoteDeliveryServers");
|
||||
legacyField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked") var legacy = (java.util.Set<String>) legacyField.get(proxy);
|
||||
legacy.add("server1"); legacy.add("server2");
|
||||
var input = originKnown
|
||||
? VotingPluginWire.multiProxyVote("Player", PLAYER.toString(), SERVICE, senderTime,
|
||||
true, true, "", id, false, false, 1, 1, "Primary", true)
|
||||
: VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, senderTime,
|
||||
true, true, "", id, false, false, 1, 1);
|
||||
if (originalTime != null) input = input.toBuilder().put(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME, originalTime).build();
|
||||
else {
|
||||
// Exercise the actual pre-metadata sender shape, including reliable origin.
|
||||
var legacyBuilder = JsonEnvelope.builder(input.getSubChannel()).schema(input.getSchema());
|
||||
input.getFields().forEach((key, value) -> {
|
||||
if (!key.equals(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME)) legacyBuilder.put(key, value);
|
||||
});
|
||||
input = legacyBuilder.build();
|
||||
}
|
||||
var handle = com.bencodez.votingplugin.proxy.multiproxy.MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class);
|
||||
handle.setAccessible(true);
|
||||
long before = System.currentTimeMillis();
|
||||
handle.invoke(handler, input);
|
||||
long after = System.currentTimeMillis();
|
||||
var emitted = proxy.getLastVoteEnvelope();
|
||||
assertNotNull(emitted);
|
||||
var wire = VotingPluginWire.readVote(emitted);
|
||||
assertTrue(wire.time >= before && wire.time <= after);
|
||||
assertEquals(PLAYER.toString(), wire.uuid);
|
||||
if (originKnown) {
|
||||
assertEquals(id, wire.voteId);
|
||||
assertTrue(wire.delayValidated); assertTrue(wire.delayValidationKnown);
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
@Test
|
||||
void liveAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception {
|
||||
assertAccountingFailureFinishesNormalEffects(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void queuedAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception {
|
||||
assertAccountingFailureFinishesNormalEffects(true);
|
||||
}
|
||||
|
||||
private void assertAccountingFailureFinishesNormalEffects(boolean queued) throws Exception {
|
||||
definition("sibling");
|
||||
reloadDefinitions();
|
||||
var event = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october"));
|
||||
Path corrupt = root.resolve("date-vote-milestones").resolve(event.fileId() + "-" + PLAYER + ".properties");
|
||||
Files.createDirectories(corrupt.getParent());
|
||||
Files.writeString(corrupt, "corrupt fixture record");
|
||||
// A corrupt player record needs its existing definition seal, just as an installed ledger would have.
|
||||
var definitions = new java.util.Properties();
|
||||
definitions.setProperty("october", event.fingerprint());
|
||||
try (var writer = Files.newBufferedWriter(corrupt.getParent().resolve("definitions.properties"))) {
|
||||
definitions.store(writer, "fixture");
|
||||
}
|
||||
UUID id = UUID.randomUUID();
|
||||
JsonEnvelope envelope = vote(id, queued);
|
||||
var outcomes = new ArrayList<OrderedVoteOutcome>();
|
||||
assertThrows(VotingPluginUser.DateMilestoneAccountingException.class,
|
||||
() -> router.handleOrderedVote(envelope, outcome -> {
|
||||
// The lane may quarantine only after the remaining normal mutation and observation.
|
||||
assertEquals(44, storedPoints.get());
|
||||
verify(plugin.getServerData()).addServiceSite(SERVICE);
|
||||
outcomes.add(outcome);
|
||||
}));
|
||||
assertEquals(List.of(OrderedVoteOutcome.QUARANTINE), outcomes);
|
||||
assertTrue(accepted.get(0).isDateMilestoneAccountingFailed());
|
||||
assertEquals(id, accepted.get(0).getProxyVoteId());
|
||||
assertEquals(id, posted.get(0).getProxyVoteId());
|
||||
assertEquals(OCCURRED_AT, posted.get(0).getVoteTime());
|
||||
assertEquals("corrupt fixture record", Files.readString(corrupt));
|
||||
assertEquals(1, progress("sibling").votes());
|
||||
assertEquals(java.util.Set.of(1), progress("sibling").submittedAwards());
|
||||
|
||||
router.handleOrderedVote(envelope, outcomes::add);
|
||||
assertEquals(List.of(OrderedVoteOutcome.QUARANTINE, OrderedVoteOutcome.QUARANTINE), outcomes);
|
||||
assertEquals(44, storedPoints.get());
|
||||
assertEquals(1, accepted.size());
|
||||
assertEquals(1, posted.size());
|
||||
assertFalse(cache.hasCompletedEffects(id));
|
||||
verify(cache, never()).complete(id);
|
||||
assertNormalEffectsOnce(id, OCCURRED_AT);
|
||||
verify(user).addPoints(POINTS);
|
||||
verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any());
|
||||
}
|
||||
|
||||
private void assertNormalEffectsOnce(UUID id, long time) {
|
||||
verify(user).playerVote(site, true, true);
|
||||
verify(user).addTotal();
|
||||
verify(user).addTotalDaily();
|
||||
verify(user).addTotalWeekly();
|
||||
verify(user).addPoints();
|
||||
verify(user).setTime(site, time);
|
||||
verify(plugin.getVoteParty()).vote(user, true, true, true);
|
||||
verify(plugin.getVoteMilestonesManager()).handleVote(eq(user), any(), eq(true), eq(id), any());
|
||||
verify(plugin.getCoolDownCheck()).vote(user, site);
|
||||
verify(plugin.getVoteStreakHandler()).processVote(user, time, id);
|
||||
verify(plugin).setUpdate(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingWireIdKeepsLegacyTotalsCorrelationAndDedupWithoutDateAccounting() throws Exception {
|
||||
UUID correlation = UUID.randomUUID();
|
||||
// Actual legacy storage layout, including the old ignored milestone-count column.
|
||||
String totals = "1//2//3//4//5//0//6//7//8//" + correlation;
|
||||
JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).schema(VotingPluginWire.SCHEMA_VERSION)
|
||||
.put(VotingPluginWire.K_PLAYER, "Player").put(VotingPluginWire.K_UUID, PLAYER)
|
||||
.put(VotingPluginWire.K_SERVICE, SERVICE).put(VotingPluginWire.K_TIME, OCCURRED_AT)
|
||||
.put(VotingPluginWire.K_WAS_ONLINE, true).put(VotingPluginWire.K_REAL_VOTE, true)
|
||||
.put(VotingPluginWire.K_TOTALS, totals).build();
|
||||
assertFalse(envelope.getFields().containsKey(VotingPluginWire.K_VOTE_ID));
|
||||
assertLegacyCorrelation(envelope, correlation);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyWireIdKeepsVersionedTotalsCorrelationWithoutDateAccounting() throws Exception {
|
||||
UUID correlation = UUID.randomUUID();
|
||||
JsonEnvelope envelope = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT,
|
||||
true, true, "v2//1//2//3//4//5//6//7//8//" + correlation, null, false, false, 1, 1);
|
||||
assertEquals("", envelope.getFields().get(VotingPluginWire.K_VOTE_ID));
|
||||
assertLegacyCorrelation(envelope, correlation);
|
||||
}
|
||||
|
||||
private void assertLegacyCorrelation(JsonEnvelope envelope, UUID correlation) throws Exception {
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
router.handleOrderedVote(envelope, outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
router.handleOrderedVote(envelope, outcome::set);
|
||||
assertEquals(1, accepted.size());
|
||||
assertEquals(1, posted.size());
|
||||
assertNull(accepted.get(0).getProxyVoteId());
|
||||
assertNull(posted.get(0).getProxyVoteId());
|
||||
assertEquals(correlation, posted.get(0).getVoteUUID());
|
||||
assertEquals(OCCURRED_AT, posted.get(0).getVoteTime());
|
||||
assertEquals(0, progress("october").votes());
|
||||
assertFalse(Files.exists(root.resolve("date-vote-milestones")));
|
||||
assertEquals(44, storedPoints.get());
|
||||
assertNormalEffectsOnce(correlation, OCCURRED_AT);
|
||||
verify(cache, times(2)).reserveWithOutcome(correlation);
|
||||
verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetedWireIdentityRemainsExplicitButDoesNotEnterTheOwnerLedger() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
router.handleOrderedVote(VotingPluginWire.voteOnline("Player", PLAYER.toString(), SERVICE, OCCURRED_AT,
|
||||
true, true, "", id, false, false, 1, 1), ignored -> { });
|
||||
assertTrue(accepted.get(0).isTargetedProxyVote());
|
||||
assertEquals(id, posted.get(0).getProxyVoteId());
|
||||
assertEquals(OCCURRED_AT, posted.get(0).getVoteTime());
|
||||
assertEquals(0, progress("october").votes());
|
||||
assertNormalEffectsOnce(id, OCCURRED_AT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalTestFlagCountsByDefaultWithoutChangingNormalVoteTotals() throws Exception {
|
||||
assertCanonicalTestVote(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalTestFlagRemainsExcludedWhenTheEventOptsOut() throws Exception {
|
||||
config.set("DateVoteMilestones.october.CountFakeVotes", false);
|
||||
plugin.getDateVoteMilestones().reload();
|
||||
assertCanonicalTestVote(0);
|
||||
}
|
||||
|
||||
private void assertCanonicalTestVote(int expectedDateVotes) throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT,
|
||||
true, false, "", id, false, false, 1, 1), ignored -> { });
|
||||
assertFalse(accepted.get(0).isRealVote());
|
||||
assertFalse(posted.get(0).isRealVote());
|
||||
assertEquals(id, posted.get(0).getProxyVoteId());
|
||||
assertEquals(OCCURRED_AT, posted.get(0).getVoteTime());
|
||||
assertEquals(expectedDateVotes, progress("october").votes());
|
||||
verify(user).playerVote(site, true, true);
|
||||
verify(user, never()).addTotal();
|
||||
verify(user).addPoints(POINTS); // Preserve the existing router's per-server points policy.
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOriginalTimeIsNotMadeCanonicalByNormalPostVoteTimestampFallback() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
long before = System.currentTimeMillis();
|
||||
router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, 0,
|
||||
true, true, "", id, false, false, 1, 1), ignored -> { });
|
||||
assertEquals(0, accepted.get(0).getTime());
|
||||
assertEquals(id, posted.get(0).getProxyVoteId());
|
||||
assertTrue(posted.get(0).getVoteTime() >= before);
|
||||
assertTrue(posted.get(0).getVoteTime() <= System.currentTimeMillis());
|
||||
assertEquals(0, progress("october").votes());
|
||||
verify(user).setTime(site);
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitWireIdOverridesTotalsCorrelationThroughDateAccountingAndPostVote() throws Exception {
|
||||
UUID wireId = UUID.randomUUID(), correlation = UUID.randomUUID();
|
||||
JsonEnvelope envelope = VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote(
|
||||
"Player", PLAYER.toString(), SERVICE, OCCURRED_AT, true, true,
|
||||
"v2//1//2//3//4//5//6//7//8//" + correlation, wireId, false, false, 1, 1));
|
||||
var outcome = new AtomicReference<OrderedVoteOutcome>();
|
||||
router.handleOrderedVote(envelope, outcome::set);
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
router.handleOrderedVote(envelope, outcome::set);
|
||||
assertEquals(1, accepted.size());
|
||||
assertEquals(1, posted.size());
|
||||
assertEquals(wireId, accepted.get(0).getProxyVoteId());
|
||||
assertEquals(wireId, posted.get(0).getProxyVoteId());
|
||||
assertEquals(wireId, posted.get(0).getVoteUUID());
|
||||
assertEquals(OCCURRED_AT, posted.get(0).getVoteTime());
|
||||
assertEquals(1, progress("october").votes());
|
||||
assertNormalEffectsOnce(wireId, OCCURRED_AT);
|
||||
verify(cache, times(2)).reserveWithOutcome(wireId);
|
||||
verify(cache, never()).reserveWithOutcome(correlation);
|
||||
verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any());
|
||||
}
|
||||
}
|
||||
+8
-57
@@ -840,38 +840,6 @@ class BackendProxyMessageRouterTest {
|
||||
verify(user, never()).setTime(any(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualAccountingDispatchFailureQuarantinesOriginalIdAndNeverReplaysNormalProcessing() throws Exception {
|
||||
UUID voteId = UUID.randomUUID();
|
||||
ProcessedVoteCache cache = mock(ProcessedVoteCache.class);
|
||||
when(cache.reserveWithOutcome(voteId)).thenReturn(Reservation.RESERVED, Reservation.DUPLICATE);
|
||||
var dispatchPlugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(dispatchPlugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true);
|
||||
var dispatchUser = mock(VotingPluginUser.class, org.mockito.Mockito.CALLS_REAL_METHODS);
|
||||
var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(dispatchUser, dispatchPlugin);
|
||||
org.mockito.Mockito.doReturn("Player").when(dispatchUser).getPlayerName();
|
||||
var dispatcher = dispatchPlugin.getServer().getPluginManager();
|
||||
org.mockito.Mockito.doAnswer(call -> { ((com.bencodez.votingplugin.events.PlayerVoteEvent) call.getArgument(0)).setDateMilestoneAccountingFailed(true); return null; })
|
||||
.when(dispatcher).callEvent(any());
|
||||
when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class));
|
||||
when(plugin.getServerData()).thenReturn(mock(ServerData.class));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")).thenReturn(user);
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
return dispatchUser.bungeeVotePluginMessagingEvent(call.getArgument(0), call.getArgument(1), call.getArgument(2),
|
||||
call.getArgument(3), call.getArgument(4), call.getArgument(5), call.getArgument(6), call.getArgument(7),
|
||||
call.getArgument(8), call.getArgument(9), call.getArgument(10), call.getArgument(11), call.getArgument(12), call.getArgument(13));
|
||||
}).when(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(),
|
||||
anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong());
|
||||
var router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache);
|
||||
var envelope = VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example", LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1));
|
||||
AtomicReference<OrderedVoteOutcome> outcome = new AtomicReference<>();
|
||||
assertThrows(IllegalStateException.class, () -> router.handleOrderedVote(envelope, outcome::set));
|
||||
assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); outcome.set(null);
|
||||
router.handleOrderedVote(envelope, outcome::set); assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get());
|
||||
verify(cache, never()).complete(voteId); verify(dispatchPlugin.getServer().getPluginManager(), times(1)).callEvent(any());
|
||||
verify(plugin.getServerData(), times(1)).addServiceSite("known.example");
|
||||
}
|
||||
|
||||
@Test
|
||||
void proxyCancellationSuppressesPerServerPointsAndCompletesWithoutReplayingRejectedVotes() throws Exception {
|
||||
for (boolean cancelled : new boolean[] {false, true}) {
|
||||
@@ -889,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(), anyLong());
|
||||
anyBoolean(), anyBoolean(), any(), 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);
|
||||
@@ -929,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(), anyLong());
|
||||
anyBoolean(), any(), anyBoolean());
|
||||
BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
|
||||
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
|
||||
mock(BackendVotePartySync.class), cache);
|
||||
@@ -975,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(), anyLong());
|
||||
anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -996,24 +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(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalTestVoteFlagIsPassedToTheBackendEventAdapter() {
|
||||
UUID voteId = UUID.randomUUID();
|
||||
ProcessedVoteCache cache = mock(ProcessedVoteCache.class);
|
||||
when(cache.reserveWithOutcome(voteId)).thenReturn(Reservation.RESERVED);
|
||||
when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")).thenReturn(user);
|
||||
when(plugin.getServerData()).thenReturn(mock(ServerData.class));
|
||||
BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
|
||||
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
|
||||
mock(BackendVotePartySync.class), cache);
|
||||
voteRouter.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example",
|
||||
LAST_VOTE_TIME, true, false, "", voteId, false, false, 1, 1), ignored -> { });
|
||||
verify(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(false), eq(false), anyLong());
|
||||
anyBoolean(), anyInt(), eq(false), eq(false), eq(true), eq(voteId), eq(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1033,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(), anyLong());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1055,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(), anyLong());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean());
|
||||
verify(cache).cancelReservation(voteId);
|
||||
}
|
||||
|
||||
@@ -1075,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(), anyLong());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean());
|
||||
verify(cache, never()).complete(voteId);
|
||||
}
|
||||
|
||||
@@ -1298,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(), anyLong());
|
||||
anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-3
@@ -266,9 +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(), anyLong());
|
||||
when(plugin.getDateVoteMilestones()).thenReturn(
|
||||
new com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones(plugin));
|
||||
anyBoolean(), any(), 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);
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests.commands;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.advancedcore.api.command.PlayerCommandHandler;
|
||||
import com.bencodez.votingplugin.commands.CommandDocumentationExporter;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
class CommandDocumentationExporterTest {
|
||||
@TempDir
|
||||
Path temp;
|
||||
|
||||
@Test
|
||||
void capturesNewCommandsWithoutAHandMaintainedList() {
|
||||
CommandHandler shop = handler("/vote", "/vote Shop", "VotingPlugin.Commands.Vote.Shop|VotingPlugin.Player",
|
||||
"Open shop");
|
||||
CommandHandler points = handler("/vote", "/vote Points", "VotingPlugin.Commands.Vote.Points|VotingPlugin.Player",
|
||||
"View points");
|
||||
CommandHandler admin = handler("/av", "/av Reload", "VotingPlugin.Commands.AdminVote.Reload|VotingPlugin.Admin",
|
||||
"Reload plugin");
|
||||
var rows = CommandDocumentationExporter.capture("/vote", List.of(shop, points), List.of(admin));
|
||||
JsonObject json = JsonParser.parseString(CommandDocumentationExporter.toJson(rows)).getAsJsonObject();
|
||||
assertEquals(1, json.get("schemaVersion").getAsInt());
|
||||
JsonArray commands = json.getAsJsonArray("commands");
|
||||
assertEquals(3, commands.size());
|
||||
assertEquals("/vote Points", commands.get(0).getAsJsonObject().get("syntax").getAsString());
|
||||
assertEquals("/vote Shop", commands.get(1).getAsJsonObject().get("syntax").getAsString());
|
||||
assertEquals("/av Reload", commands.get(2).getAsJsonObject().get("syntax").getAsString());
|
||||
assertEquals(2, commands.get(0).getAsJsonObject().getAsJsonArray("permissions").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void capturesBulkPermissionsAndSenderRestrictions() {
|
||||
PlayerCommandHandler bulk = mock(PlayerCommandHandler.class);
|
||||
when(bulk.getHelpLineCommand("/av")).thenReturn("/av User (player) AddPoints (number)");
|
||||
when(bulk.getPerm()).thenReturn("VotingPlugin.Commands.AdminVote.AddPoints|VotingPlugin.Admin");
|
||||
when(bulk.getHelpMessage()).thenReturn("Add points");
|
||||
when(bulk.getAdditionalPermissions()).thenReturn(List.of("VotingPlugin.Commands.AdminVote.AddPoints.All"));
|
||||
when(bulk.isAllowMultiplePermissions()).thenReturn(true);
|
||||
when(bulk.isForceConsole()).thenReturn(true);
|
||||
|
||||
var rows = CommandDocumentationExporter.capture("/vote", List.of(), List.of(bulk));
|
||||
JsonObject entry = JsonParser.parseString(CommandDocumentationExporter.toJson(rows))
|
||||
.getAsJsonObject().getAsJsonArray("commands").get(0).getAsJsonObject();
|
||||
assertEquals("console", entry.get("sender").getAsString());
|
||||
assertEquals("VotingPlugin.Commands.AdminVote.AddPoints.All",
|
||||
entry.getAsJsonArray("allTargetPermissions").get(0).getAsString());
|
||||
assertTrue(entry.get("multiplePermissionChecks").getAsBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersMarkdownSafelyAndRespectsConfiguredPlayerRoot() {
|
||||
CommandHandler handle = handler("/customvote", "/customvote Help/?",
|
||||
"VotingPlugin.Player|VotingPlugin.Commands.Vote.Help", "Help | usage\nwith newline");
|
||||
String markdown = CommandDocumentationExporter.toMarkdown(
|
||||
CommandDocumentationExporter.capture("/customvote", List.of(handle), List.of()));
|
||||
assertTrue(markdown.contains("## /customvote"));
|
||||
assertTrue(markdown.contains("Help \\| usage with newline"));
|
||||
assertTrue(markdown.contains("VotingPlugin.Player\\|VotingPlugin.Commands.Vote.Help"));
|
||||
assertFalse(markdown.contains("usage\nwith newline"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesOnlyWhenOutputChanges() throws Exception {
|
||||
CommandHandler original = handler("/vote", "/vote Shop", "VotingPlugin.Player", "Shop");
|
||||
assertTrue(CommandDocumentationExporter.write(temp, "/vote", List.of(original), List.of()));
|
||||
Path folder = temp.resolve(CommandDocumentationExporter.DIRECTORY);
|
||||
Path json = folder.resolve(CommandDocumentationExporter.JSON_NAME);
|
||||
Path markdown = folder.resolve(CommandDocumentationExporter.MARKDOWN_NAME);
|
||||
assertTrue(Files.readString(json, StandardCharsets.UTF_8).contains("/vote Shop"));
|
||||
assertTrue(Files.readString(markdown, StandardCharsets.UTF_8).contains("/vote Shop"));
|
||||
assertFalse(CommandDocumentationExporter.write(temp, "/vote", List.of(original), List.of()));
|
||||
CommandHandler added = handler("/vote", "/vote Next", "VotingPlugin.Player", "Next vote");
|
||||
assertTrue(CommandDocumentationExporter.write(temp, "/vote", List.of(original, added), List.of()));
|
||||
assertEquals(2, JsonParser.parseString(Files.readString(json))
|
||||
.getAsJsonObject().getAsJsonArray("commands").size());
|
||||
}
|
||||
|
||||
private static CommandHandler handler(String root, String syntax, String permission, String description) {
|
||||
CommandHandler handle = mock(CommandHandler.class);
|
||||
when(handle.getHelpLineCommand(root)).thenReturn(syntax);
|
||||
when(handle.getPerm()).thenReturn(permission);
|
||||
when(handle.getHelpMessage()).thenReturn(description);
|
||||
when(handle.isAllowConsole()).thenReturn(true);
|
||||
when(handle.isAllowMultiplePermissions()).thenReturn(true);
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
+4
-48
@@ -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;
|
||||
@@ -161,7 +161,7 @@ class CommandLoaderSchedulingTest {
|
||||
when(user.getPlayer()).thenReturn(player);
|
||||
when(plugin.getUserManager().getAllUUIDs())
|
||||
.thenReturn(new java.util.ArrayList<>(java.util.List.of(uuid.toString())));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true)).thenReturn(user);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid)).thenReturn(user);
|
||||
AtomicReference<Runnable> platform = new AtomicReference<>();
|
||||
org.mockito.Mockito.doAnswer(call -> { platform.set(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin), any(Runnable.class));
|
||||
@@ -193,7 +193,7 @@ class CommandLoaderSchedulingTest {
|
||||
java.util.List<String> uuids = new java.util.ArrayList<>();
|
||||
for (int index = 0; index < 65; index++) uuids.add(UUID.randomUUID().toString());
|
||||
when(plugin.getUserManager().getAllUUIDs()).thenReturn(new java.util.ArrayList<>(uuids));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(any(UUID.class), eq(true))).thenReturn(user);
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(any(UUID.class))).thenReturn(user);
|
||||
java.util.List<Runnable> platformTasks = new java.util.ArrayList<>();
|
||||
org.mockito.Mockito.doAnswer(call -> { platformTasks.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin), any(Runnable.class));
|
||||
@@ -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)))
|
||||
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests.commands;
|
||||
|
||||
import com.bencodez.votingplugin.commands.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.simpleapi.command.TabCompleteHandler;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.commands.executers.CommandAdminVote;
|
||||
import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones;
|
||||
import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones.AdminAction;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DateVoteMilestoneCommandDispatchTest {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final DateVoteMilestones manager = mock(DateVoteMilestones.class);
|
||||
final CommandSender sender = mock(CommandSender.class);
|
||||
final BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
final ArrayDeque<Runnable> framework = new ArrayDeque<>();
|
||||
Field instance;
|
||||
Object previous;
|
||||
|
||||
@BeforeEach void setup() throws Exception {
|
||||
instance = TabCompleteHandler.class.getDeclaredField("instance");
|
||||
instance.setAccessible(true);
|
||||
previous = instance.get(null);
|
||||
var completions = new TabCompleteHandler();
|
||||
instance.set(null, completions);
|
||||
for (String token : List.of("(player)", "(number)", "(milestone)"))
|
||||
completions.addTabCompleteOption(token, new ArrayList<>());
|
||||
when(plugin.getAdminVoteCommand()).thenReturn(new ArrayList<CommandHandler>());
|
||||
when(plugin.getDateVoteMilestones()).thenReturn(manager);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(plugin.getConfigFile().getFormatInvalidCommandAdminVote()).thenReturn("invalid command");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
when(sender.hasPermission(anyString())).thenReturn(true);
|
||||
doAnswer(call -> { framework.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
DateVoteMilestoneCommandsAccess.register(plugin, "VotingPlugin.Admin");
|
||||
for (int index = 0; index < plugin.getAdminVoteCommand().size(); index++) {
|
||||
var handler = spy(plugin.getAdminVoteCommand().get(index));
|
||||
doReturn("denied").when(handler).formatNoPerms();
|
||||
plugin.getAdminVoteCommand().set(index, handler);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach void restore() throws Exception { instance.set(null, previous); }
|
||||
|
||||
@Test void everyActionAndAliasDispatchesThroughTheActualSdkMatcher() {
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of());
|
||||
for (AdminAction action : AdminAction.values()) {
|
||||
for (String root : List.of("DateVoteMilestone", "dVm")) {
|
||||
clearInvocations(manager, sender);
|
||||
var args = new ArrayList<>(List.of(root, action.name().toLowerCase(Locale.ROOT),
|
||||
"Example", "festival_With_Underscores"));
|
||||
String value = switch (action) {
|
||||
case Status, Preview -> null;
|
||||
case SetVotes -> "3";
|
||||
case Force, ForceNoLimits -> "starter_With_Underscores";
|
||||
};
|
||||
if (value != null) args.add(value);
|
||||
new CommandAdminVote(plugin).onCommand(sender, mock(Command.class), "av", args.toArray(String[]::new));
|
||||
assertEquals(1, framework.size(), root + " " + action + " must reach SDK scheduling");
|
||||
framework.remove().run();
|
||||
verify(manager).administer(sender, action, "Example", "festival_With_Underscores", value);
|
||||
verify(sender, never()).sendMessage("invalid command");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactTargetsReachTheWorkerWithoutSdkFuzzyPlayerLookup() {
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
var nearby = mock(Player.class);
|
||||
when(nearby.getName()).thenReturn("Example");
|
||||
bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(nearby));
|
||||
bukkit.when(() -> Bukkit.getPlayer("Exam")).thenReturn(nearby);
|
||||
for (String target : List.of("Exam", "EXample", UUID.randomUUID().toString())) {
|
||||
clearInvocations(manager);
|
||||
new CommandAdminVote(plugin).onCommand(sender, mock(Command.class), "av",
|
||||
new String[] { "DVM", "Status", target, "festival_With_Underscores" });
|
||||
assertEquals(1, framework.size());
|
||||
framework.remove().run();
|
||||
verify(manager).administer(sender, AdminAction.Status, target, "festival_With_Underscores", null);
|
||||
}
|
||||
bukkit.verifyNoInteractions();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void deniedPlayerCannotDispatchAnyAction() {
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of());
|
||||
var player = mock(Player.class);
|
||||
when(player.getName()).thenReturn("Example");
|
||||
for (AdminAction action : AdminAction.values()) {
|
||||
var args = new ArrayList<>(List.of("DVM", action.name(), "Example", "festival_With_Underscores"));
|
||||
if (action == AdminAction.SetVotes) args.add("3");
|
||||
if (action == AdminAction.Force || action == AdminAction.ForceNoLimits) args.add("starter");
|
||||
new CommandAdminVote(plugin).onCommand(player, mock(Command.class), "av", args.toArray(String[]::new));
|
||||
}
|
||||
assertTrue(framework.isEmpty());
|
||||
verifyNoInteractions(manager);
|
||||
verify(player, times(5)).sendMessage("denied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void eachActionAcceptsEitherItsOwnPermissionOrTheDocumentedAdminFallback() {
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
for (AdminAction action : AdminAction.values()) {
|
||||
for (String permission : List.of("VotingPlugin.Admin",
|
||||
"VotingPlugin.Commands.AdminVote.DateVoteMilestone." + action.name())) {
|
||||
clearInvocations(manager);
|
||||
var player = mock(Player.class);
|
||||
when(player.hasPermission(permission)).thenReturn(true);
|
||||
var args = new ArrayList<>(List.of("DVM", action.name(), "Example", "festival_With_Underscores"));
|
||||
String value = action == AdminAction.SetVotes ? "3"
|
||||
: action == AdminAction.Force || action == AdminAction.ForceNoLimits ? "starter" : null;
|
||||
if (value != null) args.add(value);
|
||||
new CommandAdminVote(plugin).onCommand(player, mock(Command.class), "av", args.toArray(String[]::new));
|
||||
assertEquals(1, framework.size(), action + " must accept " + permission);
|
||||
framework.remove().run();
|
||||
verify(manager).administer(player, action, "Example", "festival_With_Underscores", value);
|
||||
}
|
||||
}
|
||||
bukkit.verifyNoInteractions();
|
||||
}
|
||||
}
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests.commands;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
/** Calls the actual package-private command registrar. */
|
||||
public final class DateVoteMilestoneCommandsAccess {
|
||||
private DateVoteMilestoneCommandsAccess() { }
|
||||
public static void register(VotingPluginMain plugin, String adminPermission) {
|
||||
ReflectiveAccess.call("com.bencodez.votingplugin.commands.DateVoteMilestoneCommands", null,
|
||||
"register", new String[]{"VotingPluginMain", "String"}, new Object[]{plugin, adminPermission});
|
||||
}
|
||||
}
|
||||
-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-311
@@ -1,311 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests.commands.gui.player;
|
||||
|
||||
import com.bencodez.votingplugin.commands.gui.player.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
import com.bencodez.advancedcore.api.inventory.BInventory;
|
||||
import com.bencodez.advancedcore.api.inventory.BInventoryButton;
|
||||
import com.bencodez.advancedcore.api.item.ItemBuilder;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Availability;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Event;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Milestone;
|
||||
|
||||
class DateVoteEventsMenuTest {
|
||||
private static final long NOW = 1_800_000_000_000L;
|
||||
|
||||
@Test
|
||||
void localProgressAndLiteralTextComeFromSnapshotAndNavigationPreservesListPage() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
String literal = "&a<red>%player% {javascript:throw new Error('must not run')}";
|
||||
Event event = event("literal", literal, true, NOW - 1000, NOW + 1000, Availability.OWNED,
|
||||
7, Set.of(1), Set.of(2), Set.of(3), List.of(
|
||||
milestone(1, true), milestone(2, true), milestone(3, true),
|
||||
milestone(5, true), milestone(10, false), milestone(20, true)));
|
||||
List<Event> events = new ArrayList<>(IntStream.range(0, 45)
|
||||
.mapToObj(i -> simpleEvent("event" + i, Availability.OWNED)).toList());
|
||||
events.add(event);
|
||||
boundary.open(events);
|
||||
boundary.click(0, 53);
|
||||
assertEquals(literal, boundary.name(1, 0));
|
||||
assertTrue(boundary.lore(1, 0).contains("Votes: 7"));
|
||||
assertTrue(boundary.lore(1, 0).contains("Next milestone: 20 votes"));
|
||||
assertTrue(boundary.lore(1, 0).contains("Timezone: America/Regina"));
|
||||
assertTrue(boundary.lore(1, 0).contains("Status: active"));
|
||||
boundary.click(1, 0);
|
||||
assertEquals(literal, boundary.name(2, 48));
|
||||
assertTrue(boundary.lore(2, 0).contains("Award state: submitted"));
|
||||
assertTrue(boundary.lore(2, 1).contains("Award state: deferred"));
|
||||
assertTrue(boundary.lore(2, 2).contains("Award state: pending review"));
|
||||
assertTrue(boundary.lore(2, 3).contains("Award state: reached"));
|
||||
assertTrue(boundary.lore(2, 4).contains("Award state: not reached"));
|
||||
assertTrue(boundary.lore(2, 4).contains("Enabled: false"));
|
||||
assertTrue(boundary.lore(2, 5).contains("Reward types: Commands, Items"));
|
||||
boundary.click(2, 49);
|
||||
assertEquals(literal, boundary.name(3, 0));
|
||||
boundary.assertOnlyPresentationAccess();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void all64EventsAndMilestonesAreReachableAcrossTwoBoundedPages() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
List<Milestone> milestones = IntStream.rangeClosed(1, 64).mapToObj(i -> milestone(i, true)).toList();
|
||||
List<Event> events = IntStream.range(0, 64).mapToObj(i -> event("e" + i, "Event " + i, true,
|
||||
NOW - 1000, NOW + 1000, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), milestones)).toList();
|
||||
boundary.open(events);
|
||||
assertEquals(47, boundary.buttons.get(0).size());
|
||||
assertFalse(boundary.buttons.get(0).containsKey(45));
|
||||
boundary.click(0, 53);
|
||||
assertEquals("Event 45", boundary.name(1, 0));
|
||||
assertEquals("Event 63", boundary.name(1, 18));
|
||||
assertFalse(boundary.buttons.get(1).containsKey(19));
|
||||
assertFalse(boundary.buttons.get(1).containsKey(53));
|
||||
boundary.click(1, 18);
|
||||
assertEquals("1 votes", boundary.name(2, 0));
|
||||
assertEquals("45 votes", boundary.name(2, 44));
|
||||
boundary.click(2, 53);
|
||||
assertEquals("46 votes", boundary.name(3, 0));
|
||||
assertEquals("64 votes", boundary.name(3, 18));
|
||||
assertFalse(boundary.buttons.get(3).containsKey(19));
|
||||
assertFalse(boundary.buttons.get(3).containsKey(53));
|
||||
boundary.click(3, 45);
|
||||
assertEquals("1 votes", boundary.name(4, 0));
|
||||
boundary.click(4, 49);
|
||||
assertEquals("Event 45", boundary.name(5, 0));
|
||||
boundary.click(5, 45);
|
||||
assertEquals("Event 0", boundary.name(6, 0));
|
||||
boundary.inventories.constructed().forEach(inventory -> {
|
||||
verify(inventory).runClicksSync();
|
||||
verify(inventory).setMaxInvSize(54);
|
||||
verify(inventory).setPages(false);
|
||||
verify(inventory).openInventory(boundary.player);
|
||||
});
|
||||
boundary.assertOnlyPresentationAccess();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void remoteOwnerAndUnavailableErrorsNeverFabricateZeroProgress() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
boundary.open(List.of(simpleEvent("remote", Availability.REMOTE),
|
||||
simpleEvent("failed", Availability.UNAVAILABLE)));
|
||||
assertTrue(boundary.lore(0, 0).contains("Progress owned by: Exact-Owner"));
|
||||
assertTrue(boundary.lore(0, 1).contains("Progress unavailable: accounting read failed."));
|
||||
for (int slot = 0; slot < 2; slot++) {
|
||||
assertTrue(boundary.lore(0, slot).stream().noneMatch(line -> line.startsWith("Votes:")
|
||||
|| line.startsWith("Next milestone:")));
|
||||
}
|
||||
boundary.click(0, 0);
|
||||
assertTrue(boundary.lore(1, 0).contains("Award state: progress unavailable"));
|
||||
boundary.click(1, 49);
|
||||
boundary.click(2, 1);
|
||||
assertTrue(boundary.lore(3, 0).contains("Award state: progress unavailable"));
|
||||
boundary.assertOnlyPresentationAccess();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusUsesCapturedTimeWithInclusiveStartAndExclusiveEnd() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
boundary.open(List.of(
|
||||
event("future", "Future", true, NOW + 1, NOW + 100, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, true))),
|
||||
event("start", "Start", true, NOW, NOW + 100, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, true))),
|
||||
event("end", "End", true, NOW - 100, NOW, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, true))),
|
||||
event("off", "Off", false, NOW - 100, NOW + 100, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, false)))));
|
||||
String[] statuses = {"upcoming", "active", "ended", "disabled"};
|
||||
for (int i = 0; i < statuses.length; i++)
|
||||
assertTrue(boundary.lore(0, i).contains("Status: " + statuses[i]));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyAndAllDisabledStatesAreExplicit() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
boundary.open(List.of());
|
||||
assertTrue(boundary.lore(0, 49).contains("No date vote events configured."));
|
||||
assertFalse(boundary.buttons.get(0).containsKey(0));
|
||||
Event disabled = event("off", "Disabled", false, NOW - 1, NOW + 1, Availability.OWNED,
|
||||
0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, false)));
|
||||
boundary.open(List.of(disabled));
|
||||
assertTrue(boundary.lore(1, 49).contains("No enabled date vote events."));
|
||||
boundary.click(1, 0);
|
||||
assertTrue(boundary.lore(2, 0).contains("Enabled: false"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewContainsOnlyTypesAndMilestoneClicksHaveNoRewardEffects() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
Event event = event("preview", "Preview", true, NOW - 1, NOW + 1, Availability.OWNED,
|
||||
1, Set.of(), Set.of(), Set.of(), List.of(new Milestone(1, "one", true,
|
||||
List.of("Commands", "say PRIVATE_SECRET", "{javascript:bad}", "Items"))));
|
||||
boundary.open(List.of(event));
|
||||
boundary.click(0, 0);
|
||||
assertTrue(boundary.lore(1, 0).contains("Reward types: Commands, Other, Items"));
|
||||
assertFalse(String.join(" ", boundary.lore(1, 0)).contains("PRIVATE_SECRET"));
|
||||
boundary.click(1, 0);
|
||||
assertEquals(2, boundary.inventories.constructed().size());
|
||||
boundary.assertOnlyPresentationAccess();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleOfflineDisabledAndRevokedPermissionsBlockInitialAndLaterNavigation() {
|
||||
for (int failure = 0; failure < 4; failure++) {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
boundary.open(List.of(simpleEvent("event", Availability.OWNED)));
|
||||
switch (failure) {
|
||||
case 0 -> boundary.current.set(false);
|
||||
case 1 -> when(boundary.player.isOnline()).thenReturn(false);
|
||||
case 2 -> when(boundary.plugin.isEnabled()).thenReturn(false);
|
||||
case 3 -> when(boundary.player.hasPermission(anyString())).thenReturn(false);
|
||||
default -> fail();
|
||||
}
|
||||
boundary.click(0, 0);
|
||||
boundary.open(List.of(simpleEvent("other", Availability.OWNED)));
|
||||
assertEquals(1, boundary.inventories.constructed().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void backAndBothPaginationDirectionsRecheckEligibility() {
|
||||
for (int control : new int[] {45, 49, 53}) {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
List<Milestone> milestones = IntStream.rangeClosed(1, 64).mapToObj(i -> milestone(i, true)).toList();
|
||||
boundary.open(List.of(event("event", "Event", true, NOW - 1, NOW + 1, Availability.OWNED,
|
||||
0, Set.of(), Set.of(), Set.of(), milestones)));
|
||||
boundary.click(0, 0);
|
||||
int screen = 1;
|
||||
if (control == 45) { boundary.click(1, 53); screen = 2; }
|
||||
boundary.current.set(false);
|
||||
boundary.click(screen, control);
|
||||
assertEquals(screen + 1, boundary.inventories.constructed().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void playerPermissionIsAcceptedAndSnapshotIsRecheckedImmediatelyBeforeOpen() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
when(boundary.player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")).thenReturn(false);
|
||||
when(boundary.player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
boundary.open(List.of());
|
||||
verify(boundary.inventories.constructed().get(0)).openInventory(boundary.player);
|
||||
AtomicInteger checks = new AtomicInteger();
|
||||
DateVoteEventsMenu.open(boundary.plugin, boundary.player, new DateVoteProgressSnapshot(NOW, List.of()),
|
||||
() -> checks.incrementAndGet() == 1);
|
||||
verify(boundary.inventories.constructed().get(1), never()).openInventory(any(Player.class));
|
||||
}
|
||||
}
|
||||
|
||||
private static Milestone milestone(int threshold, boolean enabled) {
|
||||
return new Milestone(threshold, "m" + threshold, enabled, List.of("Commands", "Items"));
|
||||
}
|
||||
|
||||
private static Event simpleEvent(String id, Availability availability) {
|
||||
return event(id, id, true, NOW - 1000, NOW + 1000, availability, 0,
|
||||
Set.of(), Set.of(), Set.of(), List.of(milestone(1, true)));
|
||||
}
|
||||
|
||||
private static Event event(String id, String name, boolean enabled, long start, long end,
|
||||
Availability availability, int votes, Set<Integer> submitted, Set<Integer> deferred,
|
||||
Set<Integer> reserved, List<Milestone> milestones) {
|
||||
DateVoteEvent definition = new DateVoteEvent(id, name, enabled, start, end, "America/Regina",
|
||||
milestones.stream().map(Milestone::threshold).toList(), Set.of(), "Exact-Owner");
|
||||
return new Event(definition, availability, votes, submitted, deferred, reserved, milestones);
|
||||
}
|
||||
|
||||
/** Mock only platform/item rendering and inventory opening; retain real buttons and callbacks. */
|
||||
private static final class Boundary implements AutoCloseable {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
final Player player = mock(Player.class);
|
||||
final AtomicBoolean current = new AtomicBoolean(true);
|
||||
final List<Map<Integer, BInventoryButton>> buttons = new ArrayList<>();
|
||||
final MockedConstruction<BInventory> inventories;
|
||||
final MockedConstruction<ItemBuilder> builders;
|
||||
|
||||
Boundary() {
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")).thenReturn(true);
|
||||
inventories = mockConstruction(BInventory.class, (inventory, context) -> {
|
||||
Map<Integer, BInventoryButton> entries = new HashMap<>();
|
||||
buttons.add(entries);
|
||||
doAnswer(call -> { entries.put(call.getArgument(0), call.getArgument(1)); return null; })
|
||||
.when(inventory).addButton(anyInt(), any(BInventoryButton.class));
|
||||
});
|
||||
builders = mockConstruction(ItemBuilder.class, (builder, context) -> {
|
||||
assertEquals(1, context.arguments().size());
|
||||
assertInstanceOf(Material.class, context.arguments().get(0));
|
||||
ItemStack stack = mock(ItemStack.class);
|
||||
ItemMeta meta = mock(ItemMeta.class);
|
||||
Map<String, Object> rendered = new HashMap<>();
|
||||
when(stack.getItemMeta()).thenReturn(meta);
|
||||
when(builder.toItemStack()).thenReturn(stack);
|
||||
doAnswer(call -> { rendered.put("name", call.getArgument(0)); return null; })
|
||||
.when(meta).setDisplayName(anyString());
|
||||
doAnswer(call -> { rendered.put("lore", call.getArgument(0)); return null; })
|
||||
.when(meta).setLore(anyList());
|
||||
when(meta.getDisplayName()).thenAnswer(call -> rendered.get("name"));
|
||||
when(meta.getLore()).thenAnswer(call -> rendered.get("lore"));
|
||||
});
|
||||
}
|
||||
|
||||
void open(List<Event> events) {
|
||||
DateVoteEventsMenu.open(plugin, player, new DateVoteProgressSnapshot(NOW, events), current::get);
|
||||
}
|
||||
|
||||
void click(int screen, int slot) {
|
||||
BInventoryButton button = buttons.get(screen).get(slot);
|
||||
assertNotNull(button, "Expected navigation button in slot " + slot);
|
||||
button.onClick(null);
|
||||
}
|
||||
|
||||
String name(int screen, int slot) {
|
||||
return buttons.get(screen).get(slot).getItem(player, new HashMap<>()).getItemMeta().getDisplayName();
|
||||
}
|
||||
|
||||
List<String> lore(int screen, int slot) {
|
||||
return buttons.get(screen).get(slot).getItem(player).getItemMeta().getLore();
|
||||
}
|
||||
|
||||
void assertOnlyPresentationAccess() {
|
||||
assertTrue(mockingDetails(plugin).getInvocations().stream()
|
||||
.allMatch(call -> call.getMethod().getName().equals("isEnabled")),
|
||||
"Presentation must not access users, storage, rewards, configuration or schedulers");
|
||||
builders.constructed().forEach(builder -> {
|
||||
verify(builder, never()).setName(anyString());
|
||||
verify(builder, never()).setLore(anyList());
|
||||
verify(builder, never()).addPlaceholder(anyString(), anyString());
|
||||
});
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
builders.close();
|
||||
inventories.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests.commands.gui.player;
|
||||
|
||||
import com.bencodez.votingplugin.commands.gui.player.*;
|
||||
|
||||
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.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.InventoryOpenEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedConstruction;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
import com.bencodez.advancedcore.api.inventory.BInventory;
|
||||
import com.bencodez.advancedcore.api.item.ItemBuilder;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot;
|
||||
import com.bencodez.votingplugin.listeners.DateVoteMenuOpenGuard;
|
||||
|
||||
/** Real AdvancedCore inventory construction and deferred open; Bukkit is the external boundary. */
|
||||
class DateVoteMenuOpenBoundaryTest {
|
||||
@Test
|
||||
void permissionRevokedAfterAdvancedCoreQueuesOpenCancelsFinalPlatformOpen() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
boundary.open();
|
||||
assertEquals(1, boundary.scheduled.size());
|
||||
when(boundary.player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")).thenReturn(false);
|
||||
boundary.finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reloadBetweenInventoryConstructionAndFinalOpenCancelsStaleSnapshot() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
boundary.open();
|
||||
boundary.current.set(false);
|
||||
boundary.finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginShutdownAndPlayerDisconnectEachCancelTheFinalOpen() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
boundary.open();
|
||||
when(boundary.plugin.isEnabled()).thenReturn(false);
|
||||
boundary.finish(false);
|
||||
}
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
boundary.open();
|
||||
when(boundary.player.isOnline()).thenReturn(false);
|
||||
boundary.finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentSnapshotOpensAndUnrelatedInventoriesRemainAllowed() {
|
||||
try (Boundary boundary = new Boundary()) {
|
||||
boundary.open();
|
||||
boundary.finish(true);
|
||||
BInventory unrelated = new BInventory("Another menu");
|
||||
assertTrue(DateVoteEventsMenu.permitsOpen(unrelated, boundary.player));
|
||||
InventoryView view = mock(InventoryView.class);
|
||||
Inventory ordinary = mock(Inventory.class);
|
||||
when(view.getTopInventory()).thenReturn(ordinary);
|
||||
when(view.getPlayer()).thenReturn(boundary.player);
|
||||
InventoryOpenEvent event = new InventoryOpenEvent(view);
|
||||
new DateVoteMenuOpenGuard().onOpen(event);
|
||||
assertFalse(event.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Boundary implements AutoCloseable {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
final Player player = mock(Player.class);
|
||||
final AtomicBoolean current = new AtomicBoolean(true);
|
||||
final AtomicInteger visible = new AtomicInteger();
|
||||
final ArrayDeque<Runnable> scheduled = new ArrayDeque<>();
|
||||
final MockedConstruction<ItemBuilder> builders;
|
||||
final MockedStatic<Bukkit> bukkit;
|
||||
final MockedStatic<AdvancedCorePlugin> core;
|
||||
InventoryOpenEvent finalEvent;
|
||||
|
||||
Boundary() {
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getOptions()).thenReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class));
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getUniqueId()).thenReturn(new UUID(1, 1));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")).thenReturn(true);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
doAnswer(call -> { scheduled.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player));
|
||||
core = mockStatic(AdvancedCorePlugin.class);
|
||||
core.when(AdvancedCorePlugin::getInstance).thenReturn(plugin);
|
||||
builders = mockConstruction(ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF),
|
||||
(builder, context) -> {
|
||||
ItemStack stack = mock(ItemStack.class);
|
||||
when(stack.getItemMeta()).thenReturn(mock(ItemMeta.class));
|
||||
when(builder.toItemStack()).thenReturn(stack);
|
||||
});
|
||||
bukkit = mockStatic(Bukkit.class);
|
||||
bukkit.when(() -> Bukkit.createInventory(any(InventoryHolder.class), anyInt(), anyString()))
|
||||
.thenAnswer(call -> {
|
||||
Inventory inventory = mock(Inventory.class);
|
||||
when(inventory.getHolder()).thenReturn(call.getArgument(0));
|
||||
return inventory;
|
||||
});
|
||||
when(player.openInventory(any(Inventory.class))).thenAnswer(call -> {
|
||||
InventoryView view = mock(InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(call.getArgument(0));
|
||||
when(view.getPlayer()).thenReturn(player);
|
||||
finalEvent = new InventoryOpenEvent(view);
|
||||
new DateVoteMenuOpenGuard().onOpen(finalEvent);
|
||||
if (!finalEvent.isCancelled()) visible.incrementAndGet();
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
void open() {
|
||||
DateVoteEventsMenu.open(plugin, player, new DateVoteProgressSnapshot(1800000000000L, List.of()),
|
||||
current::get);
|
||||
assertEquals(0, visible.get());
|
||||
verify(player, never()).openInventory(any(Inventory.class));
|
||||
}
|
||||
|
||||
void finish(boolean allowed) {
|
||||
assertEquals(1, scheduled.size());
|
||||
scheduled.remove().run();
|
||||
assertNotNull(finalEvent);
|
||||
assertEquals(!allowed, finalEvent.isCancelled());
|
||||
assertEquals(allowed ? 1 : 0, visible.get());
|
||||
assertTrue(scheduled.isEmpty());
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
bukkit.close();
|
||||
builders.close();
|
||||
core.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
-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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user