mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-12 20:22:04 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d845237f1f | ||
|
|
e75ce256ec | ||
|
|
d1d5891b7f | ||
|
|
548c974b69 | ||
|
|
293a01f352 | ||
|
|
2abd12466e | ||
|
|
01ac6fa8f0 | ||
|
|
9b850812f7 | ||
|
|
56eac13558 | ||
|
|
8d378a0cb5 | ||
|
|
136c9ec31e | ||
|
|
558f9e1f58 | ||
|
|
4113acdea0 | ||
|
|
10ab18db8d | ||
|
|
8d5b046fe2 | ||
|
|
26ba7af075 | ||
|
|
8e9c5aa6d6 | ||
|
|
0c4914b676 | ||
|
|
67806e473b | ||
|
|
e8fa948ecd | ||
|
|
4b83cc08bd | ||
|
|
6e26c9c6c2 | ||
|
|
dd005146f3 | ||
|
|
b212f5cabb | ||
|
|
e09fa0f039 | ||
|
|
79bbdb1395 | ||
|
|
51f0b5e4a9 | ||
|
|
eb9fc8618b | ||
|
|
86735060bc | ||
|
|
f2eb2152fc | ||
|
|
d2bc2feb8e | ||
|
|
b34229ced0 | ||
|
|
9f56166708 | ||
|
|
7746fc52dc | ||
|
|
ee33a291a6 | ||
|
|
66d1c858c7 | ||
|
|
2e8cb4bd5d | ||
|
|
334a00ed77 | ||
|
|
fdcb9e6501 | ||
|
|
2a1e4e19ce | ||
|
|
73947ee4cc | ||
|
|
dc127ce300 | ||
|
|
1f27359df7 | ||
|
|
c80e460640 | ||
|
|
c3248a0a85 | ||
|
|
3db2ba2d2a | ||
|
|
bfded64b30 | ||
|
|
8c098ede6d | ||
|
|
6ff81d7919 | ||
|
|
0554c2b954 | ||
|
|
cfd4cf5cf0 | ||
|
|
84a8724ce2 | ||
|
|
032c3834f3 | ||
|
|
bc33141f43 | ||
|
|
8f637ef582 | ||
|
|
0c83e75a16 | ||
|
|
5081f95e63 | ||
|
|
04a83ad54c | ||
|
|
8d3b35fa4f | ||
|
|
d3f7c43550 | ||
|
|
cd531e323b | ||
|
|
18f2fe92b2 | ||
|
|
00617059fd | ||
|
|
e0c4a1c92f | ||
|
|
071ba3edec | ||
|
|
4aa8f3cd85 | ||
|
|
3b4208c595 | ||
|
|
013c9bb583 | ||
|
|
d7b5a89955 | ||
|
|
f0cc7f15ae | ||
|
|
42398de090 | ||
|
|
cf51409f3f | ||
|
|
42f5819f1e | ||
|
|
cf8def4c8a | ||
|
|
a9b4af5fc5 | ||
|
|
a89bb5594e | ||
|
|
4409f8fdd9 | ||
|
|
81993afd60 | ||
|
|
b4aa30d283 | ||
|
|
d6d6dbc81d | ||
|
|
63d9a58a57 | ||
|
|
26441c1378 | ||
|
|
87545cc41f | ||
|
|
1535e27606 | ||
|
|
897f386f81 | ||
|
|
57ebcbc72d | ||
|
|
e2f5503cfe | ||
|
|
31f5f2eeeb | ||
|
|
ff41566f4e | ||
|
|
bd3c7382e8 | ||
|
|
073634b899 | ||
|
|
1f787df070 | ||
|
|
3c29bd8674 | ||
|
|
bca38814e9 |
No files matched your search
@@ -330,6 +330,7 @@ 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
|
||||
@@ -357,7 +358,3 @@ Control behavior in production.
|
||||
- After a MEX write, say exactly what changed and its sharing boundary: a local draft is checkout-only and nothing is shared; a canonical artifact is written to the working tree and requires commit/push to share.
|
||||
- Skill activation is not approval for canonical actions.
|
||||
<!-- mex-agent:skills:end -->
|
||||
|
||||
## Guided proxy session confirmation
|
||||
|
||||
Guided sessions never award votes or rewards. Proxy confirmation requires a bounded versioned session-fence reply matching the player, backend and random session nonce; keep the first fence for each source. New original proxy ingress assigns an opaque UUIDv8 before asynchronous handoff, with random source incarnation and monotonically increasing sequence. Preserve that canonical ID through every journal, retry, cache and multi-proxy hop; forwarding or replay must never mint new occurrence order. Do not compare proxy clocks with backend clocks or infer freshness from delivery/queue flags. New post-fence delayed/queued votes can confirm only through the existing credited pipeline; older, legacy, fake/test and unsupported-source occurrences remain unconfirmed without changing normal accounting. Unknown peers must preserve ordinary voting. No cache schema migration or new reward path.
|
||||
@@ -53,6 +53,4 @@ or disabling it; inspections gate disabled state immediately and report a newly
|
||||
|
||||
`LATEST` resolves to the latest stable release.
|
||||
|
||||
## Guided voting session
|
||||
|
||||
Optional `/vote session` chat navigation tracks real accepted votes without changing existing menus or rewards. See [configuration, controls and timing limits](docs/guided-voting-session.md).
|
||||
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.
|
||||
+11
-4
@@ -271,7 +271,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
<version>3.6.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-test</id>
|
||||
@@ -316,6 +316,13 @@
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>tcoded-releases</id>
|
||||
<url>https://repo.tcoded.com/releases</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>velocity</id>
|
||||
<url>https://nexus.velocitypowered.com/repository/maven-public/</url>
|
||||
@@ -401,7 +408,7 @@
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
<version>2.6</version>
|
||||
<version>2.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.spigotmc</groupId>
|
||||
@@ -424,7 +431,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.14.0</version>
|
||||
<version>2.15.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -475,7 +482,7 @@
|
||||
<dependency>
|
||||
<groupId>redis.clients</groupId>
|
||||
<artifactId>jedis</artifactId>
|
||||
<version>8.0.1</version>
|
||||
<version>8.0.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.clip</groupId>
|
||||
|
||||
@@ -156,13 +156,6 @@ import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private final com.bencodez.votingplugin.session.GuidedVotingSessions guidedVotingSessions =
|
||||
new com.bencodez.votingplugin.session.GuidedVotingSessions(this);
|
||||
|
||||
public com.bencodez.votingplugin.session.GuidedVotingSessions getGuidedVotingSessions() {
|
||||
return guidedVotingSessions;
|
||||
}
|
||||
|
||||
|
||||
@Getter
|
||||
public static VotingPluginMain plugin;
|
||||
@@ -294,6 +287,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Getter
|
||||
private TopVoterState topVoterState;
|
||||
private volatile com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter topVoterDisplayFilter;
|
||||
|
||||
|
||||
@Getter
|
||||
@@ -345,6 +339,9 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
private volatile VotingPluginBackgroundTask backgroundTask;
|
||||
private volatile AtomicBoolean basicBungeeUpdateRunning;
|
||||
private final AtomicBoolean unloadInProgress = new AtomicBoolean();
|
||||
private Set<String> completedUnloadSteps;
|
||||
private java.util.concurrent.CompletionStage<Void> signShutdownPublication;
|
||||
|
||||
private synchronized AtomicBoolean basicBungeeUpdateAdmission() {
|
||||
if (basicBungeeUpdateRunning == null) basicBungeeUpdateRunning = new AtomicBoolean();
|
||||
@@ -354,7 +351,13 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private VotingPluginConfigHealth configHealth;
|
||||
private VotifierIntegration votifierIntegration;
|
||||
@Getter
|
||||
private VotifierVoteOverflowQueue votifierVoteOverflowQueue;
|
||||
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 VoteLogManager voteLogManager;
|
||||
private VotingPluginWebhookManager webhookManager;
|
||||
|
||||
@@ -533,6 +536,84 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
return topVoterState.getLastMonthTopVoters();
|
||||
}
|
||||
|
||||
/** Display-only snapshots; raw getters remain the award/accounting contract. */
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> getTopVoterDisplay(TopVoter top) {
|
||||
return projectTopVoterDisplay(getTopVoter(top));
|
||||
}
|
||||
|
||||
public java.util.concurrent.CompletionStage<LinkedHashMap<TopVoterPlayer, Integer>> getTopVoterDisplayAsync(TopVoter top) {
|
||||
var filter = topVoterDisplayFilter;
|
||||
var raw = new LinkedHashMap<>(getTopVoter(top));
|
||||
return filter == null || !isEnabled() ? java.util.concurrent.CompletableFuture.completedFuture(raw)
|
||||
: filter.projectAsync(raw, configFile != null && configFile.isHideTopVoterIfNotPlayedBefore());
|
||||
}
|
||||
|
||||
private final java.util.concurrent.atomic.AtomicLong topVoterDisplayGeneration = new java.util.concurrent.atomic.AtomicLong();
|
||||
|
||||
public long getTopVoterDisplayGeneration() { return topVoterDisplayGeneration.get(); }
|
||||
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> getLastMonthTopVoterDisplay() {
|
||||
return projectTopVoterDisplay(getLastMonthTopVoter());
|
||||
}
|
||||
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> getPreviousMonthTopVoterDisplay(YearMonth month) {
|
||||
return projectTopVoterDisplay(getPreviousMonthsTopVoters().getOrDefault(month, new LinkedHashMap<>()));
|
||||
}
|
||||
|
||||
private LinkedHashMap<TopVoterPlayer, Integer> projectTopVoterDisplay(LinkedHashMap<TopVoterPlayer, Integer> raw) {
|
||||
var filter = topVoterDisplayFilter;
|
||||
return filter == null || !isEnabled() ? new LinkedHashMap<>(raw)
|
||||
: filter.project(raw, configFile != null && configFile.isHideTopVoterIfNotPlayedBefore());
|
||||
}
|
||||
|
||||
public void initializeTopVoterDisplayFilter() {
|
||||
retireTopVoterDisplayFilter();
|
||||
boolean onlineMode = getOptions().isOnlineMode();
|
||||
var filter = new com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter(row ->
|
||||
com.bencodez.votingplugin.topvoter.TopVoterPresenceIdentity
|
||||
.identities(row, onlineMode, UuidLookup.getInstance()::getCachedUUID),
|
||||
(row, aliases) -> checkTopVoterPresence(aliases, onlineMode), System::nanoTime,
|
||||
this::topVoterDisplayRankingRows);
|
||||
registerEvents(filter);
|
||||
topVoterDisplayFilter = filter;
|
||||
}
|
||||
|
||||
/** Cache capacity follows already loaded rankings, including disjoint historical identities. */
|
||||
private long topVoterDisplayRankingRows() {
|
||||
var state = topVoterState;
|
||||
if (state == null) return 0L;
|
||||
long rows = state.getLastMonthTopVoters().size();
|
||||
for (var board : state.getTopVoters().values()) if (board != null) rows += board.size();
|
||||
for (var board : state.getPreviousMonthsTopVoters().values()) if (board != null) rows += board.size();
|
||||
return rows;
|
||||
}
|
||||
|
||||
private java.util.concurrent.CompletionStage<com.bencodez.advancedcore.api.user.presence.PlayerPresence>
|
||||
checkTopVoterPresence(java.util.List<java.util.UUID> aliases, boolean onlineMode) {
|
||||
boolean completeOnlineHistory = onlineMode || aliases.stream().anyMatch(alias ->
|
||||
alias != null && alias.version() == 4 && alias.variant() == 2);
|
||||
if (!completeOnlineHistory) return checkPlayerPresenceIdentitiesAsync(aliases);
|
||||
try {
|
||||
return checkPlayerPresenceIdentitiesAsync(aliases, true);
|
||||
} catch (NoSuchMethodError | AbstractMethodError unsupported) {
|
||||
// Older SDKs can retain a positive but cannot certify the wider absence scope.
|
||||
return checkPlayerPresenceIdentitiesAsync(aliases).thenApply(evidence ->
|
||||
evidence == com.bencodez.advancedcore.api.user.presence.PlayerPresence.NEVER_JOINED
|
||||
? com.bencodez.advancedcore.api.user.presence.PlayerPresence.UNKNOWN : evidence);
|
||||
}
|
||||
}
|
||||
|
||||
private void retireTopVoterDisplayFilter() {
|
||||
topVoterDisplayGeneration.incrementAndGet();
|
||||
if (discordHandler != null) discordHandler.retireLeaderboardPublications();
|
||||
var filter = topVoterDisplayFilter;
|
||||
topVoterDisplayFilter = null;
|
||||
if (filter != null) {
|
||||
filter.close();
|
||||
org.bukkit.event.HandlerList.unregisterAll(filter);
|
||||
}
|
||||
}
|
||||
|
||||
public LinkedHashMap<YearMonth, LinkedHashMap<TopVoterPlayer, Integer>> getPreviousMonthsTopVoters() {
|
||||
return topVoterState.getPreviousMonthsTopVoters();
|
||||
}
|
||||
@@ -636,6 +717,11 @@ 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.
|
||||
*
|
||||
@@ -649,6 +735,7 @@ 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);
|
||||
@@ -720,8 +807,37 @@ 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()) {
|
||||
@@ -749,7 +865,6 @@ 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");
|
||||
@@ -762,13 +877,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
autoCreateVoteSitesSetupReminder.start();
|
||||
getVotingPluginUserManager().startSharedPointTransferRecovery();
|
||||
|
||||
if (bungeeSettings.isUseBungeecoord()) {
|
||||
loadBungeeHandler();
|
||||
}
|
||||
// Proxy ingress opens below, after every accepted-vote handler is initialized.
|
||||
|
||||
if (!bungeeSettings.isUseBungeecoord() || !bungeeSettings.isGloblalDataEnabled()) {
|
||||
this.timeQueueHandler = new TimeQueueHandler(this);
|
||||
}
|
||||
|
||||
if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) {
|
||||
if (getConfigFile().isLoadInteralExpansion()) {
|
||||
@@ -783,7 +893,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
voteStreakHandler.reload();
|
||||
|
||||
registerCommands();
|
||||
checkVotifier();
|
||||
registerEvents();
|
||||
refreshPlaceholderPlayerPresence();
|
||||
|
||||
@@ -809,6 +918,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
});
|
||||
|
||||
topVoterState = new TopVoterState();
|
||||
initializeTopVoterDisplayFilter();
|
||||
topVoterHandler = new TopVoterHandler(this);
|
||||
|
||||
new AdminGUI(this).loadHook();
|
||||
@@ -873,11 +983,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
}
|
||||
|
||||
VotingPluginRewardRegistrar.register(this);
|
||||
// Recovered Votifier votes may now traverse the fully initialized vote,
|
||||
// reward, placeholder, shop, and vote-party pipeline.
|
||||
if (votifierVoteOverflowQueue != null) {
|
||||
votifierVoteOverflowQueue.start();
|
||||
}
|
||||
initializeDateVoteIngress(this::loadBungeeHandler);
|
||||
|
||||
plugin.getLogger().info("Enabled VotingPlugin " + plugin.getDescription().getVersion());
|
||||
if (plugin.getDescription().getVersion().contains("SNAPSHOT")) {
|
||||
@@ -1938,9 +2044,112 @@ 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"))) {
|
||||
@@ -1982,16 +2191,68 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
stopAutoCreateVoteSitesSetupReminder();
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
|
||||
// The rejected enable owns no SDK shutdown stage to complete a new reservation.
|
||||
if (serverDataEnableAdmissionRejected) return;
|
||||
runShutdownStep("server-data process drain", this::beginServerDataProcessDrain);
|
||||
serverDataEnableCleanupStarted = true;
|
||||
runShutdownStep("Discord leaderboards", () -> {
|
||||
if (discordHandler != null) discordHandler.close();
|
||||
});
|
||||
runShutdownStep("setup reminder", this::stopAutoCreateVoteSitesSetupReminder);
|
||||
runShutdownStep("server-data writer retirement", () -> {
|
||||
if (serverDataPublicationFence != null) {
|
||||
retainServerDataFinalPublication();
|
||||
var coreWriters = getServerDataWriterExecutors();
|
||||
var writers = java.util.Arrays.copyOf(coreWriters, coreWriters.length + 1);
|
||||
writers[coreWriters.length] = voteTimer;
|
||||
serverDataPublicationFence.retainUntilTermination(writers);
|
||||
}
|
||||
});
|
||||
try {
|
||||
guidedVotingSessions.clear();
|
||||
shutdownVoteReminders();
|
||||
runShutdownStep("offline vote handoffs", () -> {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
|
||||
});
|
||||
} finally {
|
||||
super.onDisable();
|
||||
try { shutdownVoteReminders(); }
|
||||
finally {
|
||||
try { super.onDisable(); }
|
||||
finally { runShutdownStep("server-data publication owner", () -> {
|
||||
if (serverDataPublicationFence != null) serverDataPublicationFence.retire();
|
||||
}); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserve before SDK shutdown can enqueue a final file publication. No YAML lock or wait. */
|
||||
private void retainServerDataFinalPublication() {
|
||||
synchronized (this) {
|
||||
if (serverDataFinalPublicationReserved) return;
|
||||
if (serverDataFinalPublication == null)
|
||||
serverDataFinalPublication = new java.util.concurrent.CompletableFuture<>();
|
||||
serverDataPublicationFence.retainUntilSuccessfulPublication(serverDataFinalPublication);
|
||||
serverDataFinalPublicationReserved = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShutdownComplete(java.util.concurrent.CompletionStage<Void> finalFilesStage) {
|
||||
java.util.concurrent.CompletableFuture<Void> publication;
|
||||
synchronized (this) {
|
||||
publication = serverDataFinalStageAttached ? null : serverDataFinalPublication;
|
||||
serverDataFinalStageAttached = true;
|
||||
}
|
||||
try {
|
||||
if (publication != null) {
|
||||
try {
|
||||
java.util.Objects.requireNonNull(finalFilesStage).whenComplete((ignored, failure) -> {
|
||||
if (failure == null) publication.complete(null);
|
||||
else publication.completeExceptionally(failure);
|
||||
});
|
||||
} catch (Throwable failure) { publication.completeExceptionally(failure); }
|
||||
}
|
||||
} finally { super.onShutdownComplete(finalFilesStage); }
|
||||
}
|
||||
|
||||
private Settings autoCreateVoteSitesReminderSettings() {
|
||||
return new Settings(true, configFile.isAutoCreateVoteSites(),
|
||||
AutoCreateVoteSitesReminderState.DEFAULT_THRESHOLD_MILLIS,
|
||||
@@ -2019,50 +2280,112 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onUnLoad() {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.close();
|
||||
stopAutoCreateVoteSitesSetupReminder();
|
||||
placeholderPlayerPresence.clear();
|
||||
stopBackendHostedControlLifecycle();
|
||||
stopBackendControlConnectorLifecycle();
|
||||
if (getBackendProxyHandler() != null) {
|
||||
try {
|
||||
getBackendProxyHandler().closeForShutdown();
|
||||
} catch (Exception e) {
|
||||
debug(e);
|
||||
}
|
||||
runShutdownStep("server-data process drain", this::beginServerDataProcessDrain);
|
||||
if (!unloadInProgress.compareAndSet(false, true)) {
|
||||
runShutdownStep("time queue retry", () -> {
|
||||
if (timeQueueHandler != null) timeQueueHandler.saveForShutdown();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (backendOrderedVoteOverflowQueue != null) {
|
||||
backendOrderedVoteOverflowQueue.close();
|
||||
backendOrderedVoteOverflowQueue = null;
|
||||
}
|
||||
if (webhookManager != null) {
|
||||
webhookManager.shutdown();
|
||||
}
|
||||
voteTimer.shutdown();
|
||||
try {
|
||||
voteTimer.awaitTermination(1, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
voteTimer.shutdownNow();
|
||||
if (votifierVoteOverflowQueue != null) {
|
||||
votifierVoteOverflowQueue.close();
|
||||
votifierVoteOverflowQueue = null;
|
||||
}
|
||||
if (timeQueueHandler != null) {
|
||||
timeQueueHandler.save();
|
||||
}
|
||||
if (completedUnloadSteps == null) completedUnloadSteps = new java.util.HashSet<>();
|
||||
runUnloadStep("Discord leaderboards", () -> {
|
||||
if (discordHandler != null) discordHandler.close();
|
||||
});
|
||||
runUnloadStep("top voter display", this::retireTopVoterDisplayFilter);
|
||||
runUnloadStep("streak menu", () -> {
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.close();
|
||||
});
|
||||
runUnloadStep("setup reminder", this::stopAutoCreateVoteSitesSetupReminder);
|
||||
runUnloadStep("player presence", placeholderPlayerPresence::clear);
|
||||
runUnloadStep("backend hosted Control", this::stopBackendHostedControlLifecycle);
|
||||
runUnloadStep("backend Control connector", this::stopBackendControlConnectorLifecycle);
|
||||
runUnloadStep("backend proxy", () -> {
|
||||
if (getBackendProxyHandler() != null) getBackendProxyHandler().closeForShutdown();
|
||||
});
|
||||
runUnloadStep("backend ordered vote queue", () -> {
|
||||
if (backendOrderedVoteOverflowQueue != null) {
|
||||
backendOrderedVoteOverflowQueue.close();
|
||||
backendOrderedVoteOverflowQueue = null;
|
||||
}
|
||||
});
|
||||
runUnloadStep("webhook manager", () -> {
|
||||
if (webhookManager != null) webhookManager.shutdown();
|
||||
});
|
||||
runUnloadStep("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); }
|
||||
}
|
||||
|
||||
shutdownVoteReminders();
|
||||
private void beginServerDataProcessDrain() {
|
||||
var owner = serverDataPublicationFence;
|
||||
if (owner != null) owner.beginShutdown(System.nanoTime());
|
||||
}
|
||||
|
||||
if (coolDownCheck != null) {
|
||||
coolDownCheck.shutdown();
|
||||
coolDownCheck = null;
|
||||
private void shutdownSigns() {
|
||||
if (signs == null) return;
|
||||
var previous = signShutdownPublication;
|
||||
if (previous != null && !previous.toCompletableFuture().isCompletedExceptionally()) return;
|
||||
signShutdownPublication = java.util.Objects.requireNonNull(signs.storeSignsForShutdown());
|
||||
}
|
||||
|
||||
private void runUnloadStep(String name, Runnable step) {
|
||||
if (!completedUnloadSteps.contains(name) && runShutdownStep(name, step)) completedUnloadSteps.add(name);
|
||||
}
|
||||
|
||||
private boolean runShutdownStep(String name, Runnable step) {
|
||||
try {
|
||||
step.run();
|
||||
return true;
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try {
|
||||
getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down " + name, failure);
|
||||
} catch (RuntimeException | Error ignored) {
|
||||
// Logging must not prevent the next independent cleanup step.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getSigns().storeSigns();
|
||||
HandlerList.unregisterAll(plugin);
|
||||
plugin = null;
|
||||
private void shutdownVoteTimer() {
|
||||
if (voteTimer == null) return;
|
||||
try {
|
||||
voteTimer.shutdown();
|
||||
if (!voteTimer.awaitTermination(1, TimeUnit.SECONDS)) voteTimer.shutdownNow();
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
voteTimer.shutdownNow();
|
||||
} catch (RuntimeException failure) {
|
||||
try {
|
||||
getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down vote timer", failure);
|
||||
} catch (RuntimeException | Error ignored) { }
|
||||
voteTimer.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2091,26 +2414,44 @@ 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");
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Register events.
|
||||
*/
|
||||
/** 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);
|
||||
}
|
||||
|
||||
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);
|
||||
if (isVotifierLoaded()) {
|
||||
VotiferEvent votifierEvent = new VotiferEvent(this);
|
||||
votifierVoteOverflowQueue = VotifierVoteOverflowQueue.withObservations(this, votifierEvent::processVote);
|
||||
pm.registerEvents(votifierEvent, this);
|
||||
}
|
||||
pm.registerEvents(new PlayerVoteListener(this), this);
|
||||
pm.registerEvents(guidedVotingSessions, this);
|
||||
pm.registerEvents(new com.bencodez.votingplugin.listeners.DateVoteMenuOpenGuard(), this);
|
||||
pm.registerEvents(new PlayerPostVoteLoggerListener(this), this);
|
||||
pm.registerEvents(new PlayerSpecialRewardLoggerListener(this), this);
|
||||
pm.registerEvents(new VoteShopPurchaseLoggerListener(this), this);
|
||||
@@ -2124,9 +2465,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
pm.registerEvents(new PlayerInteract(this), this);
|
||||
}
|
||||
|
||||
if (timeQueueHandler != null) {
|
||||
pm.registerEvents(timeQueueHandler, plugin);
|
||||
}
|
||||
|
||||
pm.registerEvents(new VotingPluginUpdateEvent(this), this);
|
||||
/*
|
||||
@@ -2165,8 +2503,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
}, () -> { }, () -> { }));
|
||||
}
|
||||
|
||||
/** Snapshot-only UUID resolution; call while owning the player, before storage work. */
|
||||
public static UUID placeholderStorageUuid(Player player, boolean onlineMode) {
|
||||
static UUID placeholderStorageUuid(Player player, boolean onlineMode) {
|
||||
if (onlineMode) return player.getUniqueId();
|
||||
String resolvedUuid = UuidLookup.getInstance().getUUIDWithoutStorage(player.getName());
|
||||
try {
|
||||
@@ -2186,12 +2523,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl,
|
||||
boolean updateActiveBackendRuntime) {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.pause();
|
||||
retireTopVoterDisplayFilter();
|
||||
try {
|
||||
guidedVotingSessions.clear();
|
||||
if (voteStreaksMenu != null) voteStreaksMenu.suspend();
|
||||
configFile.reloadData();
|
||||
configFile.loadValues();
|
||||
if (commandLoader != null) commandLoader.refreshGuidedSessionCommands();
|
||||
AutoCreateVoteSitesReminderService setupReminder = autoCreateVoteSitesSetupReminder;
|
||||
if (setupReminder != null) setupReminder.reload(autoCreateVoteSitesReminderSettings());
|
||||
|
||||
@@ -2208,10 +2544,13 @@ 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();
|
||||
|
||||
|
||||
+21
-16
@@ -57,12 +57,7 @@ public class BackendProxyMessageRouter {
|
||||
}
|
||||
|
||||
public void register(GlobalMessageHandler messages, BungeeMethod method) {
|
||||
this.messages = messages;
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REPLY) {
|
||||
@Override public void onReceive(JsonEnvelope message) {
|
||||
plugin.getGuidedVotingSessions().proxyFence(message);
|
||||
}
|
||||
});
|
||||
this.messages = messages;
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE) {
|
||||
@Override public void onReceive(JsonEnvelope msg) { handleWireVote(msg); }
|
||||
});
|
||||
@@ -574,7 +569,8 @@ public class BackendProxyMessageRouter {
|
||||
}
|
||||
|
||||
String totalsRaw = nvl(fields.get(VotingPluginWire.K_TOTALS));
|
||||
VoteTotalsSnapshot totals = totalsRaw.isEmpty() ? null : VoteTotalsSnapshot.parseStorage(totalsRaw);
|
||||
VoteTotalsSnapshot totals = !com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.hasPayload(totalsRaw)
|
||||
? null : VoteTotalsSnapshot.parseStorage(totalsRaw);
|
||||
VoteSite voteSite = plugin.getVoteSiteManager()
|
||||
.getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, service), true);
|
||||
if (voteSite == null) {
|
||||
@@ -720,20 +716,29 @@ public class BackendProxyMessageRouter {
|
||||
user.cache();
|
||||
boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : user.isOnline();
|
||||
boolean queuedDelivery = vote.queuedDeliveryKnown ? vote.queuedDelivery : vote.delayValidated;
|
||||
// Even an immediate proxy send can arrive after a guide opened on this backend.
|
||||
// Transport freshness flags cannot establish original occurrence order. The guide
|
||||
// separately validates the canonical ingress ID against its negotiated source fence.
|
||||
var event = user.bungeeVotePluginMessagingEvent(vote.service, vote.time, totals, !vote.manageTotals,
|
||||
wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown,
|
||||
vote.queuedDeliveryKnown, voteId,
|
||||
VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()),
|
||||
true, vote.realVote);
|
||||
if ((event == null || !event.isCancelled()) && plugin.getBungeeSettings().isPerServerPoints()) {
|
||||
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()) {
|
||||
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
@@ -0,0 +1,207 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -104,10 +104,6 @@ import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
|
||||
public class CommandLoader {
|
||||
private static final int BULK_PLAYER_CAPTURE_BATCH_SIZE = 64;
|
||||
private final Set<CommandHandler> guidedSessionCommands = new HashSet<>();
|
||||
private volatile CommandHandler guidedSessionPermissionHandler;
|
||||
private final java.util.concurrent.atomic.AtomicLong guidedPermissionRevision = new java.util.concurrent.atomic.AtomicLong();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean guidedPermissionsManaged = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final MaintenanceConfirmation maintenanceConfirmation =
|
||||
new MaintenanceConfirmation();
|
||||
|
||||
@@ -611,8 +607,31 @@ 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") {
|
||||
@@ -847,7 +866,7 @@ public class CommandLoader {
|
||||
@Override
|
||||
public void executeSinglePlayer(CommandSender sender, String[] args) {
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]);
|
||||
user.cache();
|
||||
if (!UserStorage.MYSQL.equals(plugin.getStorageType())) user.cache();
|
||||
user.removePoints(Integer.parseInt(args[3]), removed -> {
|
||||
if (!removed) {
|
||||
runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize(
|
||||
@@ -2951,9 +2970,7 @@ public class CommandLoader {
|
||||
public void execute(CommandSender sender, String[] args) {
|
||||
String topVoter = args[1];
|
||||
TopVoter top = TopVoter.getTopVoter(topVoter);
|
||||
plugin.getServerData().setTopVoterMessageId(top, 0);
|
||||
plugin.getDiscordHandler().getTopVoterMessageIds().put(top, 0L);
|
||||
sendMessage(sender, "&cCleared discord message ID for " + top.toString());
|
||||
clearDiscordMessageId(sender, top);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3278,7 +3295,6 @@ public class CommandLoader {
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshGuidedSessionPermissionIndex();
|
||||
}
|
||||
|
||||
static void registerAdditionalPermissions(PluginManager manager, CommandHandler handler) {
|
||||
@@ -3634,85 +3650,17 @@ public class CommandLoader {
|
||||
/**
|
||||
* Load vote command.
|
||||
*/
|
||||
/** Refresh only guide-owned handlers; preserve production/custom registrations and command-map aliases. */
|
||||
public void refreshGuidedSessionCommands() {
|
||||
boolean previouslyRegistered = !guidedSessionCommands.isEmpty();
|
||||
long permissionRevision = guidedPermissionRevision.incrementAndGet();
|
||||
ArrayList<CommandHandler> handlers = new ArrayList<>(plugin.getVoteCommand());
|
||||
handlers.removeAll(guidedSessionCommands);
|
||||
guidedSessionCommands.clear();
|
||||
if (plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)) {
|
||||
for (String action : java.util.List.of("", "next", "previous", "skip", "check", "finish", "restart")) {
|
||||
String[] path = action.isEmpty() ? new String[] { "Session" } : new String[] { "Session", action };
|
||||
var sessionCommand = plugin.getGuidedVotingSessions().commandHandler(action);
|
||||
CommandHandler handler = new CommandHandler(plugin, path,
|
||||
"VotingPlugin.Commands.Vote.Session|" + playerPerm, "Guided voting session", false) {
|
||||
@Override public void execute(CommandSender sender, String[] args) {
|
||||
sessionCommand.accept((Player) sender);
|
||||
}
|
||||
};
|
||||
if (plugin.getConfigFile().getDisabledCommands().stream().noneMatch(handler.getPerm()::contains)) {
|
||||
if (plugin.getConfigFile().getDisabledDefaultPermissions().stream()
|
||||
.anyMatch(disabled -> handler.getPerm().contains(disabled + "|"))) {
|
||||
handler.setPerm(handler.getPerm().replace("|" + playerPerm, ""));
|
||||
}
|
||||
guidedSessionCommands.add(handler);
|
||||
handlers.add(guidedSessionCommands.size() - 1, handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
plugin.setVoteCommand(handlers);
|
||||
refreshGuidedSessionPermissionIndex();
|
||||
if (previouslyRegistered || !guidedSessionCommands.isEmpty()) guidedPermissionsManaged.set(true);
|
||||
if (guidedPermissionsManaged.get()) {
|
||||
String inheritedPermission = "VotingPlugin.Commands.Vote.Session|" + playerPerm;
|
||||
boolean inherit = plugin.getConfigFile().getDisabledDefaultPermissions().stream()
|
||||
.noneMatch(disabled -> inheritedPermission.contains(disabled + "|"))
|
||||
&& handlers.stream().anyMatch(handler -> handler.getPerm().contains(inheritedPermission));
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
if (guidedPermissionRevision.get() != permissionRevision) return;
|
||||
Permission parent = Bukkit.getPluginManager().getPermission(playerPerm);
|
||||
if (parent == null) return;
|
||||
if (inherit) parent.getChildren().put("VotingPlugin.Commands.Vote.Session", true);
|
||||
else parent.getChildren().remove("VotingPlugin.Commands.Vote.Session");
|
||||
parent.recalculatePermissibles();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Use the registered built-in command policy for guide-owned callbacks and refreshes. */
|
||||
public boolean canUseGuidedSession(Player player) {
|
||||
CommandHandler handler = guidedSessionPermissionHandler;
|
||||
return handler != null && handler.hasPerm(player);
|
||||
}
|
||||
|
||||
/** Keep the session lookup consistent with routing without registering or replacing Bukkit aliases. */
|
||||
private void refreshGuidedSessionPermissionIndex() {
|
||||
CommandHandler selected = null;
|
||||
for (CommandHandler handler : plugin.getVoteCommand()) {
|
||||
if (!handler.isCommand("Session")) continue;
|
||||
if (guidedSessionCommands.contains(handler)) {
|
||||
if (handler.getArgs().length == 1) {
|
||||
selected = handler;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Preserve the existing alias index's last-handler policy when the guide is disabled.
|
||||
selected = handler;
|
||||
}
|
||||
}
|
||||
guidedSessionPermissionHandler = selected != null && guidedSessionCommands.contains(selected) ? selected : null;
|
||||
if (commands == null) return;
|
||||
final CommandHandler indexed = selected;
|
||||
commands.entrySet().removeIf(entry -> entry.getKey().equalsIgnoreCase("voteSession"));
|
||||
if (indexed != null) commands.put("voteSession", indexed);
|
||||
}
|
||||
|
||||
private void loadVoteCommand() {
|
||||
guidedSessionPermissionHandler = null;
|
||||
guidedSessionCommands.clear();
|
||||
plugin.setVoteCommand(new ArrayList<>());
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" },
|
||||
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&?" },
|
||||
"VotingPlugin.Commands.Vote.Help|" + playerPerm, "View help page") {
|
||||
|
||||
@Override
|
||||
@@ -3777,8 +3725,6 @@ public class CommandLoader {
|
||||
}
|
||||
});
|
||||
|
||||
refreshGuidedSessionCommands();
|
||||
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "URL", "(SiteName)" },
|
||||
"VotingPlugin.Commands.Vote.URL.VoteSite", "Open VoteURL GUI for VoteSite", false) {
|
||||
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
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
@@ -51,7 +51,7 @@ public class VoteTopVoter extends GUIHandler {
|
||||
String topVoter = top.getName();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin.getTopVoter(top)
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin.getTopVoterDisplay(top)
|
||||
.clone();
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
@@ -148,7 +148,7 @@ public class VoteTopVoter extends GUIHandler {
|
||||
String topVoter = top.getName();
|
||||
@SuppressWarnings("unchecked")
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin
|
||||
.getTopVoter(top).clone();
|
||||
.getTopVoterDisplay(top).clone();
|
||||
users = topVotes.entrySet();
|
||||
|
||||
ConfigurationSection customization = plugin.getGui().getChestVoteTopCustomization();
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ public class VoteTopVoterLastMonth extends GUIHandler {
|
||||
|
||||
@Override
|
||||
public void onDialog(Player player) {
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getLastMonthTopVoter().entrySet();
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getLastMonthTopVoterDisplay().entrySet();
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
.placeholder("player", user.getPlayerName())
|
||||
@@ -91,7 +91,7 @@ public class VoteTopVoterLastMonth extends GUIHandler {
|
||||
public void onChest(Player player) {
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = null;
|
||||
|
||||
users = plugin.getLastMonthTopVoter().entrySet();
|
||||
users = plugin.getLastMonthTopVoterDisplay().entrySet();
|
||||
|
||||
BInventory inv = new BInventory(plugin.getGui().getChestVoteTopName());
|
||||
inv.addPlaceholder("topvoter", "Last Month");
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ public class VoteTopVoterPreviousMonths extends GUIHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getPreviousMonthsTopVoters().get(yearMonth).entrySet();
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
.placeholder("player", user.getPlayerName())
|
||||
@@ -160,7 +160,7 @@ public class VoteTopVoterPreviousMonths extends GUIHandler {
|
||||
player.sendMessage(ChatColor.RED + "Can't open previous months, no data");
|
||||
return;
|
||||
}
|
||||
users = plugin.getPreviousMonthsTopVoters().get(yearMonth).entrySet();
|
||||
users = plugin.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
|
||||
|
||||
BInventory inv = new BInventory(plugin.getGui().getChestVoteTopName());
|
||||
inv.addPlaceholder("topvoter", yearMonth.toString());
|
||||
|
||||
@@ -809,6 +809,10 @@ public class Config extends YMLFile {
|
||||
@Getter
|
||||
private boolean topVoterAwardsTies = true;
|
||||
|
||||
@ConfigDataBoolean(path = "HideTopVoterIfNotPlayedBefore")
|
||||
@Getter
|
||||
private boolean hideTopVoterIfNotPlayedBefore = false;
|
||||
|
||||
@ConfigDataBoolean(path = "TopVoterIgnorePermission")
|
||||
@Getter
|
||||
private boolean topVoterIgnorePermission = false;
|
||||
|
||||
@@ -11,10 +11,13 @@ import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
import com.bencodez.advancedcore.api.rewards.Reward;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardOptions;
|
||||
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
@@ -36,12 +39,14 @@ public class CoolDownCheck implements Listener {
|
||||
private final VotingPluginMain plugin;
|
||||
|
||||
private final Map<UUID, ScheduledFuture<?>> perSiteTasks = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Object> perSiteGenerations = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, ScheduledFuture<?>> allSiteTasks = new ConcurrentHashMap<>();
|
||||
|
||||
@Getter
|
||||
private final ScheduledExecutorService timer;
|
||||
|
||||
private volatile boolean cooldownCheckEnabled = false;
|
||||
private volatile boolean stopped = false;
|
||||
|
||||
/**
|
||||
* Constructs a new cooldown check manager.
|
||||
@@ -74,6 +79,8 @@ public class CoolDownCheck implements Listener {
|
||||
* Shuts down the cooldown check timer.
|
||||
*/
|
||||
public void shutdown() {
|
||||
stopped = true;
|
||||
perSiteGenerations.clear();
|
||||
timer.shutdownNow();
|
||||
|
||||
for (ScheduledFuture<?> f : perSiteTasks.values()) {
|
||||
@@ -147,6 +154,7 @@ public class CoolDownCheck implements Listener {
|
||||
user.updateTempCacheWithColumns(cols);
|
||||
cols.clear(); // help GC (only useful if reused)
|
||||
}
|
||||
capturePlayerName(user);
|
||||
|
||||
checkPerSite(site, user);
|
||||
} finally {
|
||||
@@ -219,13 +227,20 @@ public class CoolDownCheck implements Listener {
|
||||
* Site-specific version used by batch checks / checkAllVoteSite.
|
||||
*/
|
||||
private void checkPerSite(VoteSite site, VotingPluginUser user) {
|
||||
checkPerSite(site, user, null);
|
||||
}
|
||||
|
||||
private HashMap<String, Boolean> checkPerSite(VoteSite site, VotingPluginUser user,
|
||||
HashMap<String, Boolean> coolDownChecks) {
|
||||
if (!plugin.getConfigFile().isPerSiteCoolDownEvents() || site == null) {
|
||||
return;
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
HashMap<String, Boolean> coolDownChecks = user.getCoolDownCheckSiteList();
|
||||
if (coolDownChecks == null) {
|
||||
coolDownChecks = user.getCoolDownCheckSiteList();
|
||||
}
|
||||
if (Boolean.TRUE.equals(coolDownChecks.get(site.getKey()))) {
|
||||
return;
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
if (user.canVoteSite(site)) {
|
||||
@@ -237,6 +252,7 @@ public class CoolDownCheck implements Listener {
|
||||
PlayerVoteSiteCoolDownEndEvent event = new PlayerVoteSiteCoolDownEndEvent(user, site);
|
||||
plugin.getServer().getPluginManager().callEvent(event);
|
||||
}
|
||||
return coolDownChecks;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,8 +283,8 @@ public class CoolDownCheck implements Listener {
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCoolDownEnd(PlayerVoteCoolDownEndEvent event) {
|
||||
plugin.getRewardHandler().giveReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
|
||||
"VoteCoolDownEndedReward", new RewardOptions());
|
||||
dispatchCooldownReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
|
||||
"VoteCoolDownEndedReward", "all", new RewardOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,11 +294,73 @@ public class CoolDownCheck implements Listener {
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCoolDownEnd(PlayerVoteSiteCoolDownEndEvent event) {
|
||||
plugin.getRewardHandler().giveReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
|
||||
new RewardOptions().addPlaceholder("sitename", event.getSite().getDisplayName())
|
||||
dispatchCooldownReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
|
||||
"site:" + event.getSite().getKey(), new RewardOptions()
|
||||
.addPlaceholder("sitename", event.getSite().getDisplayName())
|
||||
.addPlaceholder("url", event.getSite().getVoteURL(false)));
|
||||
}
|
||||
|
||||
private void dispatchCooldownReward(VotingPluginUser user, ConfigurationSection data, String path,
|
||||
String site, RewardOptions options) {
|
||||
boolean configured = false;
|
||||
try {
|
||||
configured = hasConfiguredCooldownReward(data, path);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Preserve legacy dispatch even when diagnostic configuration inspection fails.
|
||||
}
|
||||
if (!configured) {
|
||||
plugin.getRewardHandler().giveReward(user, data, path, options);
|
||||
return;
|
||||
}
|
||||
String occurrence = UUID.randomUUID().toString();
|
||||
String player = user.getUUID();
|
||||
String safeSite = String.valueOf(site).replaceAll("[\\r\\n\\p{Cntrl}]", "?");
|
||||
long time = System.currentTimeMillis();
|
||||
String context = " occurrence=" + occurrence + " player=" + player + " site=" + safeSite + " time=" + time;
|
||||
logCooldownDiagnostic(false, "Cooldown reward status=attempted" + context);
|
||||
options.setAsyncFailureObserver(failure -> logCooldownFailure(context, failure));
|
||||
try {
|
||||
plugin.getRewardHandler().giveReward(user, data, path, options);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
logCooldownFailure(context, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasConfiguredCooldownReward(ConfigurationSection data, String path) {
|
||||
if (data == null) return false;
|
||||
if (data instanceof FileConfiguration) {
|
||||
return plugin.getRewardHandler().hasRewards((FileConfiguration) data, path);
|
||||
}
|
||||
if (data.isList(path)) return !data.getList(path, new ArrayList<>()).isEmpty();
|
||||
if (data.isConfigurationSection(path)) return !data.getConfigurationSection(path).getKeys(false).isEmpty();
|
||||
return data.isString(path) && !data.getString(path, "").isEmpty();
|
||||
}
|
||||
|
||||
private void logCooldownFailure(String context, Throwable failure) {
|
||||
int completed = -1;
|
||||
Throwable current = failure;
|
||||
for (int depth = 0; current != null && depth < 32; depth++, current = current.getCause()) {
|
||||
if (current instanceof Reward.RewardReplayFailure) {
|
||||
completed = ((Reward.RewardReplayFailure) current).getCompletedInjectionCount();
|
||||
break;
|
||||
}
|
||||
}
|
||||
String error = failure == null ? "unknown" : failure.getClass().getSimpleName();
|
||||
logCooldownDiagnostic(true, "Cooldown reward outcome=unknown/partial" + context + " error=" + error
|
||||
+ " completedInjections=" + (completed < 0 ? "unknown" : completed)
|
||||
+ "; inspect effects before reissuing");
|
||||
}
|
||||
|
||||
private void logCooldownDiagnostic(boolean warning, String message) {
|
||||
try {
|
||||
if (warning) plugin.getLogger().warning(message);
|
||||
else plugin.getLogger().info(message);
|
||||
} catch (RuntimeException ignored) {
|
||||
// A broken logger must not suppress or retry the reward.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a cooldown check for a user.
|
||||
*
|
||||
@@ -324,7 +402,7 @@ public class CoolDownCheck implements Listener {
|
||||
* @param force whether to force the schedule
|
||||
*/
|
||||
public void schedulePerSite(VotingPluginUser user, boolean force) {
|
||||
if (!plugin.getConfigFile().isPerSiteCoolDownEvents()) {
|
||||
if (stopped || !plugin.getConfigFile().isPerSiteCoolDownEvents()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -333,6 +411,7 @@ public class CoolDownCheck implements Listener {
|
||||
}
|
||||
|
||||
final UUID uuid = user.getJavaUUID();
|
||||
final String scheduledName = user.getPlayerName();
|
||||
|
||||
final NextSite next = user.getNextSiteAvailable();
|
||||
|
||||
@@ -340,8 +419,13 @@ public class CoolDownCheck implements Listener {
|
||||
if (existing != null) {
|
||||
existing.cancel(false);
|
||||
}
|
||||
if (stopped) {
|
||||
perSiteGenerations.remove(uuid);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (next == null || next.getSite() == null || next.getSecondsUntilAvailable() <= 0) {
|
||||
perSiteGenerations.remove(uuid);
|
||||
plugin.extraDebug(user.getUUID() + "/" + user.getPlayerName() + " not scheduling per-site cooldown check");
|
||||
return null; // remove entry
|
||||
}
|
||||
@@ -349,21 +433,60 @@ public class CoolDownCheck implements Listener {
|
||||
plugin.devDebug("PerSiteCoolDownEvent schedule: site=" + next.getSite().getKey() + " time="
|
||||
+ next.getSecondsUntilAvailable() + " seconds");
|
||||
|
||||
return timer.schedule(() -> {
|
||||
if (!plugin.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check the next site; this will set the cooldown-end flag + fire event if ready
|
||||
checkPerSite(next.getSite(), user);
|
||||
|
||||
// Schedule the following site (if any)
|
||||
schedulePerSite(user, false);
|
||||
|
||||
}, next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS);
|
||||
Object generation = new Object();
|
||||
perSiteGenerations.put(uuid, generation);
|
||||
try {
|
||||
return timer.schedule(() -> runPerSiteTimer(uuid, scheduledName, next, generation, 0),
|
||||
next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
perSiteGenerations.remove(uuid, generation);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void runPerSiteTimer(UUID uuid, String scheduledName, NextSite next, Object generation, int retries) {
|
||||
if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return;
|
||||
UUID occurrence = UUID.randomUUID();
|
||||
boolean[] retried = { false };
|
||||
try {
|
||||
VotingPluginUser current;
|
||||
try {
|
||||
// The batch user's temporary row may have been cleared or replaced by a newer vote.
|
||||
current = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false);
|
||||
if (!current.isCached()) current.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
String storedName = current.getData().getString("PlayerName", current.getUserDataFetchMode());
|
||||
String name = storedName != null && !storedName.isBlank() ? storedName : scheduledName;
|
||||
if (name != null && !name.isBlank()) current.setPlayerName(name);
|
||||
} catch (RuntimeException refreshFailure) {
|
||||
if (retries < 3) {
|
||||
perSiteTasks.compute(uuid, (key, existing) -> {
|
||||
if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return existing;
|
||||
ScheduledFuture<?> retry = timer.schedule(
|
||||
() -> runPerSiteTimer(uuid, scheduledName, next, generation, retries + 1),
|
||||
30, TimeUnit.SECONDS);
|
||||
retried[0] = true;
|
||||
return retry;
|
||||
});
|
||||
if (retried[0]) return;
|
||||
}
|
||||
throw refreshFailure;
|
||||
}
|
||||
if (perSiteGenerations.get(uuid) != generation) return;
|
||||
checkPerSite(next.getSite(), current);
|
||||
if (perSiteGenerations.get(uuid) == generation) schedulePerSite(current, false);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
String siteKey = String.valueOf(next.getSite().getKey()).replaceAll("[\\r\\n\\p{Cntrl}]", "?");
|
||||
logCooldownDiagnostic(true, "Cooldown timer failed: occurrence=" + occurrence + " player=" + uuid
|
||||
+ " site=" + siteKey + " time=" + System.currentTimeMillis() + " error="
|
||||
+ failure.getClass().getSimpleName() + "; reward outcome is uncertain; inspect effects before reissuing");
|
||||
throw failure;
|
||||
} finally {
|
||||
if (!retried[0]) perSiteGenerations.remove(uuid, generation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a vote event for cooldown scheduling.
|
||||
*
|
||||
@@ -381,6 +504,14 @@ public class CoolDownCheck implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve the name needed by asynchronous rewards after the temporary row is cleared. */
|
||||
private static void capturePlayerName(VotingPluginUser user) {
|
||||
String name = user.getPlayerName();
|
||||
if (name != null && !name.isBlank()) {
|
||||
user.setPlayerName(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance-optimized: scan users ONCE, process all daily-delay sites.
|
||||
*/
|
||||
@@ -421,9 +552,11 @@ public class CoolDownCheck implements Listener {
|
||||
user.updateTempCacheWithColumns(cols);
|
||||
cols.clear();
|
||||
}
|
||||
capturePlayerName(user);
|
||||
|
||||
HashMap<String, Boolean> coolDownChecks = null;
|
||||
for (int i = 0; i < dailySites.size(); i++) {
|
||||
checkPerSite(dailySites.get(i), user);
|
||||
coolDownChecks = checkPerSite(dailySites.get(i), user, coolDownChecks);
|
||||
}
|
||||
|
||||
check(user);
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
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
@@ -0,0 +1,36 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Ephemeral, observation-only progress. Never invokes vote or reward processing. */
|
||||
public final class GuidedVoteSession {
|
||||
public enum Status { AWAITING, RECEIVED, UNAVAILABLE, SKIPPED }
|
||||
public record Site(String key, String name, String url, boolean eligible, long lastVote) { }
|
||||
public record Entry(Site site, Status status) { }
|
||||
public record View(List<Entry> entries, int index, boolean finished) {
|
||||
public Entry current() { return entries.isEmpty() ? null : entries.get(index); }
|
||||
public long received() { return entries.stream().filter(e -> e.status() == Status.RECEIVED).count(); }
|
||||
public boolean complete() { return !entries.isEmpty() && entries.stream().allMatch(e -> e.status() == Status.RECEIVED); }
|
||||
}
|
||||
private final long started;
|
||||
private final long observationOrder;
|
||||
private final Map<String, Site> candidates = new LinkedHashMap<>();
|
||||
private boolean candidatesBound;
|
||||
private final Map<String, Site> sites = new LinkedHashMap<>();
|
||||
private final Map<String, Long> received = new LinkedHashMap<>();
|
||||
private final java.util.Set<String> skipped = new java.util.HashSet<>();
|
||||
private final Map<UUID, String> occurrences = new LinkedHashMap<>();
|
||||
private final Map<UUID, ProxyVoteSequence.Fence> proxyFences = new LinkedHashMap<>();
|
||||
private final Map<UUID, String> pendingProxy = new LinkedHashMap<>();
|
||||
private boolean initialized;
|
||||
private boolean finished;
|
||||
private int index;
|
||||
|
||||
public GuidedVoteSession(long started) { this(started, VoteObservationSequence.next()); }
|
||||
public GuidedVoteSession(long started, long observationOrder) { this.started = started; this.observationOrder = observationOrder; }
|
||||
public long started() { return started; }
|
||||
|
||||
/** Bind visible identities on the player context before asynchronous eligibility sampling. */
|
||||
public synchronized void bindCandidates(List<Site> available) {
|
||||
if (candidatesBound) return;
|
||||
for (Site site : available) if (candidates.size() < 100) candidates.put(site.key(), site);
|
||||
candidatesBound = true;
|
||||
}
|
||||
public synchronized void refresh(List<Site> available) {
|
||||
bindCandidates(available);
|
||||
if (!initialized) {
|
||||
// A cooldown timestamp alone does not establish original ingress order.
|
||||
for (Site site : available) if (candidates.containsKey(site.key()) && (site.eligible() || received.containsKey(site.key())) && sites.size() < 100) sites.put(site.key(), site);
|
||||
// A temporarily hidden/removed site can be absent from the worker sample.
|
||||
// Keep its confirmed identity so visibility restoration can recover progress.
|
||||
for (Site candidate : candidates.values()) if (received.containsKey(candidate.key()))
|
||||
sites.putIfAbsent(candidate.key(), candidate);
|
||||
initialized = true;
|
||||
}
|
||||
Map<String, Site> current = new LinkedHashMap<>();
|
||||
for (Site site : available) current.put(site.key(), site);
|
||||
sites.replaceAll((key, old) -> {
|
||||
Site fresh = current.get(key);
|
||||
return fresh == null ? new Site(key, old.name(), old.url(), false, old.lastVote()) : fresh;
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized boolean candidate(String key) { return candidates.containsKey(key); }
|
||||
/** The accepted pipeline supplies stable IDs and its authoritative occurrence time. */
|
||||
public synchronized void accepted(String site, UUID occurrence, long time, boolean real, boolean cancelled) {
|
||||
if (real && !cancelled && occurrence != null && time > started && candidates.containsKey(site)
|
||||
&& !received.containsKey(site) && !occurrences.containsKey(occurrence)) {
|
||||
received.put(site, time);
|
||||
occurrences.put(occurrence, site);
|
||||
// A credited vote may make the first asynchronous eligibility sample unavailable.
|
||||
// Its identity was already bound, so a later notification can restore that entry.
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
}
|
||||
/** Fresh local ingress uses backend-local order; proxy occurrences require their source fence. */
|
||||
public synchronized void acceptedObserved(String site, UUID occurrence, long order) {
|
||||
if (order > observationOrder && occurrence != null && candidates.containsKey(site)
|
||||
&& !received.containsKey(site) && !occurrences.containsKey(occurrence)) {
|
||||
received.put(site, order);
|
||||
occurrences.put(occurrence, site);
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
}
|
||||
/** A negotiated source fence is fixed for the entire session, not advanced by status checks. */
|
||||
public synchronized void bindProxyFence(ProxyVoteSequence.Fence fence) {
|
||||
if (!proxyFences.containsKey(fence.incarnation()) && proxyFences.size() < 32) {
|
||||
proxyFences.put(fence.incarnation(), fence);
|
||||
var pending = new ArrayList<>(pendingProxy.entrySet());
|
||||
for (var receipt : pending) acceptedProxy(receipt.getValue(), receipt.getKey());
|
||||
}
|
||||
}
|
||||
public synchronized boolean hasProxyFence() { return !proxyFences.isEmpty(); }
|
||||
/** The normal credited pipeline supplies the original canonical identity, including delayed votes. */
|
||||
public synchronized void acceptedProxy(String site, UUID occurrence) {
|
||||
if (occurrence == null || occurrence.version() != 8 || occurrence.variant() != 2
|
||||
|| !candidates.containsKey(site) || received.containsKey(site) || occurrences.containsKey(occurrence)) return;
|
||||
UUID source = new UUID(occurrence.getMostSignificantBits(), 0x8000000000000000L);
|
||||
ProxyVoteSequence.Fence fence = proxyFences.get(source);
|
||||
if (fence == null) {
|
||||
// Bounded provisional receipts cover a new occurrence overtaking its fence reply.
|
||||
if (pendingProxy.size() < 400) pendingProxy.putIfAbsent(occurrence, site);
|
||||
return;
|
||||
}
|
||||
pendingProxy.remove(occurrence);
|
||||
if (!fence.newer(occurrence)) return;
|
||||
received.put(site, occurrence.getLeastSignificantBits() & 0x3fffffffffffffffL);
|
||||
occurrences.put(occurrence, site);
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
/**
|
||||
* Select the next eligible site for automatic guidance after the current site
|
||||
* can no longer be the active target. Explicit navigation remains owned by
|
||||
* {@link #view(String)}; this method only advances from a received,
|
||||
* unavailable, or skipped current entry.
|
||||
*
|
||||
* @return whether the active index changed
|
||||
*/
|
||||
public synchronized boolean selectNextAwaiting() {
|
||||
if (finished || sites.isEmpty()) return false;
|
||||
int start = Math.floorMod(index, sites.size());
|
||||
String current = new ArrayList<>(sites.keySet()).get(start);
|
||||
Status currentStatus = status(sites.get(current));
|
||||
if (currentStatus == Status.AWAITING) return false;
|
||||
List<Site> ordered = new ArrayList<>(sites.values());
|
||||
for (int offset = 1; offset <= ordered.size(); offset++) {
|
||||
int candidateIndex = (start + offset) % ordered.size();
|
||||
Site candidate = ordered.get(candidateIndex);
|
||||
if (status(candidate) == Status.AWAITING && candidate.eligible() && !skipped.contains(candidate.key())) {
|
||||
if (candidateIndex == index) return false;
|
||||
index = candidateIndex;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Status status(Site site) {
|
||||
if (received.containsKey(site.key())) return Status.RECEIVED;
|
||||
if (!site.eligible()) return Status.UNAVAILABLE;
|
||||
return skipped.contains(site.key()) ? Status.SKIPPED : Status.AWAITING;
|
||||
}
|
||||
public synchronized View view(String action) {
|
||||
if (!sites.isEmpty()) {
|
||||
String current = new ArrayList<>(sites.keySet()).get(index);
|
||||
if (action.equals("skip")) { skipped.add(current); index = (index + 1) % sites.size(); }
|
||||
if (action.equals("next")) index = (index + 1) % sites.size();
|
||||
if (action.equals("previous")) index = Math.floorMod(index - 1, sites.size());
|
||||
}
|
||||
if (action.equals("finish")) finished = true;
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
for (Site site : sites.values()) {
|
||||
entries.add(new Entry(site, status(site)));
|
||||
}
|
||||
return new View(List.copyOf(entries), index, finished);
|
||||
}
|
||||
public static String httpUrl(String input) {
|
||||
if (input == null || input.length() > 2048) return null;
|
||||
try {
|
||||
String destination = input.trim();
|
||||
if (destination.startsWith("[")) {
|
||||
var wrapper = java.util.regex.Pattern.compile("(?i)\\[Text=\"[^\"\\r\\n]*\",\\s*url=\"([^\"\\r\\n]*)\"\\]").matcher(destination);
|
||||
if (!wrapper.matches()) return null;
|
||||
destination = wrapper.group(1);
|
||||
}
|
||||
URI uri = URI.create(destination);
|
||||
return ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|
||||
&& uri.getHost() != null && uri.getUserInfo() == null ? uri.toASCIIString() : null;
|
||||
} catch (IllegalArgumentException invalid) { return null; }
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Original proxy ingress order, carried by the existing opaque canonical vote UUID.
|
||||
* UUIDv8: random 60-bit source incarnation and a monotonically increasing 62-bit sequence.
|
||||
* The process/classloader owns one source across runtime replacement; a restart creates
|
||||
* another. Never assign a new ID when forwarding, retrying or replaying an occurrence. */
|
||||
public final class ProxyVoteSequence {
|
||||
private static final long MAX_SEQUENCE = 0x3fffffffffffffffL;
|
||||
private static final ProxyVoteSequence LIVE = new ProxyVoteSequence(UUID.randomUUID());
|
||||
private final long source;
|
||||
private long sequence;
|
||||
|
||||
public ProxyVoteSequence(UUID incarnation) {
|
||||
source = (incarnation.getMostSignificantBits() & 0xffffffffffff0fffL) | 0x8000L;
|
||||
}
|
||||
public static UUID nextVoteId() { return LIVE.next(); }
|
||||
public static Fence currentFence() { return LIVE.fence(); }
|
||||
public synchronized UUID next() {
|
||||
if (sequence == MAX_SEQUENCE) throw new IllegalStateException("Proxy vote sequence exhausted");
|
||||
return new UUID(source, 0x8000000000000000L | ++sequence);
|
||||
}
|
||||
public synchronized Fence fence() { return new Fence(new UUID(source, 0x8000000000000000L), sequence); }
|
||||
|
||||
public record Fence(UUID incarnation, long watermark) {
|
||||
public Fence {
|
||||
if (incarnation == null || incarnation.version() != 8 || incarnation.variant() != 2
|
||||
|| incarnation.getLeastSignificantBits() != 0x8000000000000000L
|
||||
|| watermark < 0 || watermark > MAX_SEQUENCE)
|
||||
throw new IllegalArgumentException("Invalid proxy occurrence fence");
|
||||
}
|
||||
public boolean newer(UUID occurrence) {
|
||||
return occurrence != null && occurrence.version() == 8 && occurrence.variant() == 2
|
||||
&& occurrence.getMostSignificantBits() == incarnation.getMostSignificantBits()
|
||||
&& (occurrence.getLeastSignificantBits() & MAX_SEQUENCE) > watermark;
|
||||
}
|
||||
}
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/** Process-local total ordering shared by guide creation and vote ingress; never a remote timestamp. */
|
||||
public final class VoteObservationSequence {
|
||||
private static final AtomicLong SEQUENCE = new AtomicLong();
|
||||
private VoteObservationSequence() { }
|
||||
public static long next() { return SEQUENCE.incrementAndGet(); }
|
||||
}
|
||||
+8
-7
@@ -1,7 +1,5 @@
|
||||
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. */
|
||||
@@ -67,12 +65,15 @@ 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; }
|
||||
@@ -105,6 +106,7 @@ 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);
|
||||
@@ -186,10 +188,8 @@ public final class SharedVoteProcessor {
|
||||
+ (legacyRecordedProxyVote ? "; legacy timestamp matches LastVotes: " + ops.incomingTime()
|
||||
: "; stable vote ID identifies this queued delivery: " + ops.proxyVoteId()));
|
||||
}
|
||||
UUID voteId = UUID.randomUUID();
|
||||
if (ops.proxyVote() && ops.proxyVoteId() != null) {
|
||||
voteId = ops.proxyVoteId();
|
||||
}
|
||||
UUID voteId = ops.proxyVote() ? ops.proxyVoteId() : ops.localOccurrenceId();
|
||||
if (voteId == null) voteId = UUID.randomUUID();
|
||||
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 = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
voteTime = ops.currentTimeMillis();
|
||||
}
|
||||
boolean cached = false;
|
||||
if (SharedVoteDelivery.shouldDeliverNow(ops::proxyVote, () -> ops.userOnline(user),
|
||||
@@ -251,6 +251,7 @@ 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
@@ -0,0 +1,247 @@
|
||||
package com.bencodez.votingplugin.data;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import com.bencodez.advancedcore.api.time.TimeType;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
/**
|
||||
* Routes SDK publications through this generation's ownership lease. The SDK's
|
||||
* original file/configuration remains authoritative; no YAML snapshot is copied.
|
||||
*/
|
||||
public final class GenerationFencedServerDataFile extends com.bencodez.advancedcore.data.ServerData {
|
||||
private final com.bencodez.advancedcore.data.ServerData delegate;
|
||||
private final ServerDataPublicationFence fence;
|
||||
private final VotingPluginMain ownerPlugin;
|
||||
|
||||
public GenerationFencedServerDataFile(VotingPluginMain plugin,
|
||||
com.bencodez.advancedcore.data.ServerData delegate, ServerDataPublicationFence fence) {
|
||||
super(plugin);
|
||||
this.ownerPlugin = java.util.Objects.requireNonNull(plugin);
|
||||
this.delegate = java.util.Objects.requireNonNull(delegate);
|
||||
this.fence = java.util.Objects.requireNonNull(fence);
|
||||
}
|
||||
|
||||
public boolean wraps(com.bencodez.advancedcore.data.ServerData file) { return delegate == file; }
|
||||
|
||||
public boolean belongsTo(ServerDataPublicationFence owner) { return fence == owner; }
|
||||
|
||||
/** Checkpoint callers use the same monitor as native final publication. */
|
||||
@Override public Object publicationMonitor() {
|
||||
return java.util.Objects.requireNonNull(delegate.publicationMonitor(), "Server-data publication monitor");
|
||||
}
|
||||
|
||||
private void withOwner(Runnable operation) {
|
||||
try (var lease = fence.acquire()) {
|
||||
synchronized (publicationMonitor()) {
|
||||
try { operation.run(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { refreshVotePartyDisplay(); }
|
||||
catch (RuntimeException | Error observationFailure) {
|
||||
if (observationFailure != failure) failure.addSuppressed(observationFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
refreshVotePartyDisplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T withOwnedValue(Supplier<T> operation) {
|
||||
try (var lease = fence.acquire()) {
|
||||
synchronized (publicationMonitor()) { return operation.get(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public FileConfiguration getData() { return delegate.getData(); }
|
||||
@Override public File getdFile() { return delegate.getdFile(); }
|
||||
@Override public JavaPlugin getPlugin() { return delegate.getPlugin(); }
|
||||
@Override public boolean isJustCreated() { return delegate.isJustCreated(); }
|
||||
@Override public boolean isFailedToRead() { return delegate.isFailedToRead(); }
|
||||
@Override public boolean isIgnoreCase() { return delegate.isIgnoreCase(); }
|
||||
@Override public void setIgnoreCase(boolean value) {
|
||||
withOwner(() -> delegate.setIgnoreCase(value));
|
||||
}
|
||||
@Override public void createSection(String path) {
|
||||
mutateOrdinaryData(data -> data.createSection(path));
|
||||
}
|
||||
@Override public void setData(FileConfiguration data) {
|
||||
withOwner(() -> delegate.setData(data));
|
||||
}
|
||||
@Override public void setValue(String path, Object value) {
|
||||
mutateOrdinaryData(data -> data.set(path, value));
|
||||
}
|
||||
@Override public void onFileCreation() {
|
||||
withOwner(delegate::onFileCreation);
|
||||
}
|
||||
|
||||
@Override public void setup() {
|
||||
withOwner(delegate::setup);
|
||||
}
|
||||
@Override public void loadValues() {
|
||||
withOwner(delegate::loadValues);
|
||||
}
|
||||
@Override public void reloadData() {
|
||||
withOwner(delegate::reloadData);
|
||||
}
|
||||
private void refreshVotePartyDisplay() {
|
||||
var wrapper = ownerPlugin.getServerData();
|
||||
if (wrapper != null) {
|
||||
synchronized (publicationMonitor()) {
|
||||
if (delegate.getData() != null) wrapper.refreshVotePartyDisplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void saveData() {
|
||||
withOwner(() -> {
|
||||
refreshVotePartyDisplay();
|
||||
delegate.saveData();
|
||||
});
|
||||
}
|
||||
@Override public void saveDataChecked() {
|
||||
withOwner(() -> {
|
||||
refreshVotePartyDisplay();
|
||||
delegate.saveDataChecked();
|
||||
});
|
||||
}
|
||||
@Override protected void saveTimeChangeTransitionData() { saveDataChecked(); }
|
||||
|
||||
@Override public void mutateOrdinaryData(Consumer<FileConfiguration> mutation) {
|
||||
// Delegate admission, live mutation and saving to the actual SDK owner.
|
||||
// The projection is published inside that owner's callback before I/O.
|
||||
withOwner(() -> delegate.mutateOrdinaryData(data -> applyOwnedMutation(mutation, data)));
|
||||
}
|
||||
|
||||
private void refreshIfLiveMutation(FileConfiguration data) {
|
||||
var wrapper = ownerPlugin.getServerData();
|
||||
if (wrapper == null) return;
|
||||
synchronized (publicationMonitor()) {
|
||||
// Native final publication mutates a staged candidate first. Legacy
|
||||
// adapters may instead mutate the live owner before their save.
|
||||
if (data == delegate.getData()) wrapper.refreshVotePartyDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyOwnedMutation(Consumer<FileConfiguration> mutation, FileConfiguration data) {
|
||||
try { mutation.accept(data); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { refreshIfLiveMutation(data); }
|
||||
catch (RuntimeException | Error observationFailure) {
|
||||
if (observationFailure != failure) failure.addSuppressed(observationFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
refreshIfLiveMutation(data);
|
||||
}
|
||||
|
||||
private void finishFinalPublication(ServerDataPublicationFence.Lease lease,
|
||||
CompletableFuture<Void> result, Throwable failure) {
|
||||
Throwable outcome = failure;
|
||||
try {
|
||||
// Partial startup may have no display wrapper. In that case, do not
|
||||
// introduce a YAML-observation dependency into final-stage drain.
|
||||
refreshVotePartyDisplay();
|
||||
} catch (Throwable observationFailure) {
|
||||
if (outcome == null) outcome = observationFailure;
|
||||
else if (outcome != observationFailure) outcome.addSuppressed(observationFailure);
|
||||
}
|
||||
// Refresh observes only the actual live owner; it never acknowledges a
|
||||
// failed/cancelled publication or releases its retained generation.
|
||||
if (outcome != null) { result.completeExceptionally(outcome); return; }
|
||||
try { lease.close(); result.complete(null); }
|
||||
catch (Throwable releaseFailure) { result.completeExceptionally(releaseFailure); }
|
||||
}
|
||||
|
||||
/** Retain this handle only for retries of the same accepted mutation. */
|
||||
FinalPublication prepareFinalPublication(Consumer<FileConfiguration> mutation) {
|
||||
return new FinalPublication(mutation);
|
||||
}
|
||||
|
||||
final class FinalPublication {
|
||||
private final Consumer<FileConfiguration> mutation;
|
||||
private ServerDataPublicationFence.Lease lease;
|
||||
private CompletableFuture<Void> attempt;
|
||||
private boolean publicationConfirmed;
|
||||
|
||||
private FinalPublication(Consumer<FileConfiguration> mutation) { this.mutation = mutation; }
|
||||
|
||||
CompletionStage<Void> publish() {
|
||||
CompletableFuture<Void> result;
|
||||
ServerDataPublicationFence.Lease owner;
|
||||
boolean refreshOnly;
|
||||
synchronized (this) {
|
||||
if (attempt != null && !attempt.isCompletedExceptionally())
|
||||
return attempt.minimalCompletionStage();
|
||||
if (lease == null) {
|
||||
try { lease = fence.acquire(); }
|
||||
catch (Throwable failure) { return CompletableFuture.failedFuture(failure); }
|
||||
}
|
||||
owner = lease;
|
||||
result = new CompletableFuture<>();
|
||||
attempt = result;
|
||||
refreshOnly = publicationConfirmed;
|
||||
}
|
||||
// No operation lock is held across the actual owner's YAML or I/O.
|
||||
if (refreshOnly) finish(owner, result, null);
|
||||
else {
|
||||
try {
|
||||
java.util.Objects.requireNonNull(mutation, "mutation");
|
||||
java.util.Objects.requireNonNull(delegate.mutateFinalData(data -> applyOwnedMutation(mutation, data)))
|
||||
.whenComplete((ignored, failure) -> finish(owner, result, failure));
|
||||
} catch (Throwable failure) { finish(owner, result, failure); }
|
||||
}
|
||||
return result.minimalCompletionStage();
|
||||
}
|
||||
|
||||
private void finish(ServerDataPublicationFence.Lease owner,
|
||||
CompletableFuture<Void> result, Throwable failure) {
|
||||
if (failure == null) {
|
||||
synchronized (this) { publicationConfirmed = true; }
|
||||
}
|
||||
// A retry after a display-observation failure must not repeat a
|
||||
// confirmed mutation. Failed publication instead retains this one
|
||||
// lease until this same operation has a confirmed successful retry.
|
||||
finishFinalPublication(owner, result, failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public CompletionStage<Void> mutateFinalData(Consumer<FileConfiguration> mutation) {
|
||||
// Ordinary one-shot callers retain failed ownership conservatively.
|
||||
// A caller with an explicit retry contract retains the operation handle.
|
||||
return prepareFinalPublication(mutation).publish();
|
||||
}
|
||||
|
||||
@Override public void setData(String path, Object value) { mutateOrdinaryData(data -> data.set(path, value)); }
|
||||
@Override public void setIgnoreTime(boolean value) { withOwner(() -> delegate.setIgnoreTime(value)); }
|
||||
@Override public void setLastUpdated() { withOwner(delegate::setLastUpdated); }
|
||||
@Override public void setPluginVersion(Plugin plugin) { withOwner(() -> delegate.setPluginVersion(plugin)); }
|
||||
@Override public void setPrevDay(int value) { withOwner(() -> delegate.setPrevDay(value)); }
|
||||
@Override public void setPrevMonth(String value) { withOwner(() -> delegate.setPrevMonth(value)); }
|
||||
@Override public void setPrevWeekDay(int value) { withOwner(() -> delegate.setPrevWeekDay(value)); }
|
||||
|
||||
@Override public TimeChangeTransitionState beginTimeChangeTransition(TimeType type, String period, String marker) {
|
||||
return withOwnedValue(() -> delegate.beginTimeChangeTransition(type, period, marker));
|
||||
}
|
||||
@Override public TimeChangeTransitionState getPendingTimeChangeTransition(TimeType type) {
|
||||
return withOwnedValue(() -> delegate.getPendingTimeChangeTransition(type));
|
||||
}
|
||||
@Override public void completeTimeChangeTransition(TimeChangeTransitionState transition) {
|
||||
withOwner(() -> delegate.completeTimeChangeTransition(transition));
|
||||
}
|
||||
@Override public void failTimeChangeTransition(TimeChangeTransitionState transition) {
|
||||
withOwner(() -> delegate.failTimeChangeTransition(transition));
|
||||
}
|
||||
@Override public boolean isPendingTimeChangeTransition(TimeChangeTransitionState transition) {
|
||||
return withOwnedValue(() -> delegate.isPendingTimeChangeTransition(transition));
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,182 @@ import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
|
||||
public class ServerData {
|
||||
public class ServerData {
|
||||
|
||||
/** Immutable live display values, separate from durable publication acknowledgement. */
|
||||
public record VotePartyDisplay(int total, int extraRequired) {
|
||||
public int required(int configured) { return extraRequired > 0 ? configured + extraRequired : configured; }
|
||||
public int needed(int configured) { return required(configured) - total; }
|
||||
}
|
||||
|
||||
private volatile VotePartyDisplay votePartyDisplay = new VotePartyDisplay(0, 0);
|
||||
|
||||
/** Snapshot-only: never acquires a YAML owner or waits for filesystem publication. */
|
||||
public VotePartyDisplay getVotePartyDisplay() { return votePartyDisplay; }
|
||||
|
||||
/** Called during startup before placeholders are registered. */
|
||||
public void refreshVotePartyDisplay() { mutateData(this::captureVotePartyDisplay); }
|
||||
|
||||
private void captureVotePartyDisplay() {
|
||||
var data = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
votePartyDisplay = data == null ? new VotePartyDisplay(0, 0)
|
||||
: new VotePartyDisplay(data.getInt("VoteParty.Total"), data.getInt("VotePartyExtraRequired"));
|
||||
}
|
||||
|
||||
private VotingPluginMain plugin = VotingPluginMain.plugin;
|
||||
private volatile com.bencodez.advancedcore.data.ServerData generationFile;
|
||||
private final Object generationBinding = new Object();
|
||||
private volatile com.bencodez.votingplugin.util.ServerDataPublicationFence generationFence;
|
||||
|
||||
private com.bencodez.advancedcore.data.ServerData getFile() {
|
||||
var current = generationFile;
|
||||
if (current != null) return current;
|
||||
synchronized (generationBinding) {
|
||||
if (generationFile == null) {
|
||||
var owner = getPublicationFence();
|
||||
// Reject an old lazy wrapper before exposing successor mutable state.
|
||||
try (var lease = owner == null ? null : owner.acquire()) {
|
||||
var file = plugin.getServerDataFile();
|
||||
if (file instanceof GenerationFencedServerDataFile guarded && !guarded.belongsTo(owner))
|
||||
throw new IllegalStateException("Server-data file belongs to another generation");
|
||||
generationFile = file;
|
||||
}
|
||||
}
|
||||
return generationFile;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private record SignCoordinates(String world, int x, int y, int z) {
|
||||
static SignCoordinates capture(Location location) {
|
||||
return new SignCoordinates(location.getWorld().getName(), (int) location.getX(),
|
||||
(int) location.getY(), (int) location.getZ());
|
||||
}
|
||||
void store(ConfigurationSection data, String path) {
|
||||
data.set(path + ".World", world);
|
||||
data.set(path + ".X", x);
|
||||
data.set(path + ".Y", y);
|
||||
data.set(path + ".Z", z);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Detached shutdown payload: no platform object is accessed by its publisher. */
|
||||
public static final class SignSnapshot {
|
||||
private final String count;
|
||||
private final SignCoordinates location;
|
||||
private final SignCoordinates skull;
|
||||
private final String data;
|
||||
private final int position;
|
||||
|
||||
private SignSnapshot(String count, Location location, Location skull, String data, int position) {
|
||||
this.count = count;
|
||||
this.location = SignCoordinates.capture(location);
|
||||
this.skull = skull == null ? null : SignCoordinates.capture(skull);
|
||||
this.data = data;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
private void store(ConfigurationSection target) {
|
||||
location.store(target, "Signs." + count);
|
||||
target.set("Signs." + count + ".Data", data);
|
||||
target.set("Signs." + count + ".Position", position);
|
||||
if (skull != null) skull.store(target, "Signs." + count + ".Skull");
|
||||
}
|
||||
}
|
||||
|
||||
public static SignSnapshot captureSignSnapshot(String count, Location location, Location skull,
|
||||
String data, int position) {
|
||||
return new SignSnapshot(count, location, skull, data, position);
|
||||
}
|
||||
|
||||
private record ShutdownSignPublication(List<SignSnapshot> snapshot,
|
||||
java.util.function.Supplier<java.util.concurrent.CompletionStage<Void>> publisher) { }
|
||||
private final Object shutdownSignBinding = new Object();
|
||||
private ShutdownSignPublication shutdownSignPublication;
|
||||
|
||||
/** Worker-only: one accepted sign batch, with the same publication owner across retries. */
|
||||
public java.util.concurrent.CompletionStage<Void> publishShutdownSigns(List<SignSnapshot> signs) {
|
||||
var snapshot = List.copyOf(signs);
|
||||
ShutdownSignPublication publication;
|
||||
synchronized (shutdownSignBinding) {
|
||||
if (shutdownSignPublication == null) {
|
||||
var file = getFile();
|
||||
java.util.function.Consumer<org.bukkit.configuration.file.FileConfiguration> mutation = candidate -> {
|
||||
var target = candidate.getConfigurationSection("VotingPlugin");
|
||||
if (target == null) target = candidate.createSection("VotingPlugin");
|
||||
for (var sign : snapshot) sign.store(target);
|
||||
};
|
||||
java.util.function.Supplier<java.util.concurrent.CompletionStage<Void>> publisher;
|
||||
if (file instanceof GenerationFencedServerDataFile guarded) {
|
||||
var retained = guarded.prepareFinalPublication(mutation);
|
||||
publisher = retained::publish;
|
||||
} else publisher = () -> file.mutateFinalData(mutation);
|
||||
shutdownSignPublication = new ShutdownSignPublication(snapshot, publisher);
|
||||
} else if (!shutdownSignPublication.snapshot().equals(snapshot)) {
|
||||
return java.util.concurrent.CompletableFuture.failedFuture(
|
||||
new IllegalStateException("Shutdown sign publication already owns a different batch"));
|
||||
}
|
||||
publication = shutdownSignPublication;
|
||||
}
|
||||
return publication.publisher().get();
|
||||
}
|
||||
|
||||
/** Only YAML work belongs here; platform callbacks run outside the file owner. */
|
||||
private void mutateData(Runnable mutation) {
|
||||
var fence = getPublicationFence();
|
||||
try (var owner = fence == null ? null : fence.acquire()) {
|
||||
synchronized (publicationMonitor()) {
|
||||
mutation.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Admit the complete ordinary mutation before changing live YAML or its display. */
|
||||
private void mutateSavedData(Runnable mutation) {
|
||||
mutateData(() -> getFile().mutateOrdinaryData(ignored -> {
|
||||
try { mutation.run(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
try { captureVotePartyDisplay(); }
|
||||
catch (RuntimeException | Error observationFailure) {
|
||||
if (observationFailure != failure) failure.addSuppressed(observationFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
captureVotePartyDisplay();
|
||||
}));
|
||||
}
|
||||
|
||||
/** Store a scalar and save under the same YAML ownership as final SDK snapshots. */
|
||||
public void setInt(String path, int value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set(path, value);
|
||||
});
|
||||
}
|
||||
|
||||
/** Store a detached string list, preventing later caller mutation of live YAML. */
|
||||
public void setStringList(String path, List<String> values) {
|
||||
var snapshot = values == null ? null : new ArrayList<>(values);
|
||||
mutateSavedData(() -> {
|
||||
getData().set(path, snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
/** All mutations serialize with the actual SDK YAML owner. */
|
||||
private Object publicationMonitor() {
|
||||
var file = getFile();
|
||||
return java.util.Objects.requireNonNull(file.publicationMonitor(), "Server-data publication monitor");
|
||||
}
|
||||
|
||||
private com.bencodez.votingplugin.util.ServerDataPublicationFence getPublicationFence() {
|
||||
var current = generationFence;
|
||||
if (current != null) return current;
|
||||
synchronized (generationBinding) {
|
||||
if (generationFence == null) generationFence = plugin.getServerDataPublicationFence();
|
||||
return generationFence;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new ServerData.
|
||||
@@ -29,6 +202,7 @@ public class ServerData {
|
||||
*/
|
||||
public ServerData(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
this.generationFence = plugin.getServerDataPublicationFence();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,13 +210,15 @@ public class ServerData {
|
||||
*
|
||||
* @param placeholder the placeholder to add
|
||||
*/
|
||||
public void addAutoCachedPlaceholder(String placeholder) {
|
||||
List<String> p = getAutoCachedPlaceholder();
|
||||
|
||||
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
|
||||
p.add(placeholder);
|
||||
setAutoCachedPlaceholder(p);
|
||||
}
|
||||
public void addAutoCachedPlaceholder(String placeholder) {
|
||||
mutateData(() -> {
|
||||
List<String> p = getAutoCachedPlaceholder();
|
||||
|
||||
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
|
||||
p.add(placeholder);
|
||||
setAutoCachedPlaceholder(p);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,12 +226,14 @@ public class ServerData {
|
||||
*
|
||||
* @param site the service site to add
|
||||
*/
|
||||
public synchronized void addServiceSite(String site) {
|
||||
ArrayList<String> l = getServiceSites();
|
||||
if (!getServiceSites().contains(site)) {
|
||||
l.add(site);
|
||||
}
|
||||
setServiceSites(ArrayUtils.removeDuplicates(l));
|
||||
public void addServiceSite(String site) {
|
||||
mutateData(() -> {
|
||||
ArrayList<String> l = getServiceSites();
|
||||
if (!getServiceSites().contains(site)) {
|
||||
l.add(site);
|
||||
}
|
||||
setServiceSites(ArrayUtils.removeDuplicates(l));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,19 +243,20 @@ public class ServerData {
|
||||
* @param data the data
|
||||
* @param position the position
|
||||
*/
|
||||
public void addSign(Location location, String data, int position) {
|
||||
|
||||
int count = nextSignNumber();
|
||||
|
||||
getData().set("Signs." + count + ".World", location.getWorld().getName());
|
||||
getData().set("Signs." + count + ".X", location.getBlockX());
|
||||
getData().set("Signs." + count + ".Y", location.getBlockY());
|
||||
getData().set("Signs." + count + ".Z", location.getBlockZ());
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
saveData();
|
||||
plugin.getSigns().getSigns().add(new SignHandler(plugin, "" + count, getSignLocation("" + count),
|
||||
getSignSkullLocation("" + count), getSignData("" + count), getSignPosition("" + count)));
|
||||
public void addSign(Location location, String data, int position) {
|
||||
var coordinates = new SignCoordinates(location.getWorld().getName(), location.getBlockX(),
|
||||
location.getBlockY(), location.getBlockZ());
|
||||
int[] selected = new int[1];
|
||||
mutateSavedData(() -> {
|
||||
int count = nextSignNumber();
|
||||
selected[0] = count;
|
||||
coordinates.store(getData(), "Signs." + count);
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
});
|
||||
String count = Integer.toString(selected[0]);
|
||||
plugin.getSigns().getSigns().add(new SignHandler(plugin, count, getSignLocation(count),
|
||||
getSignSkullLocation(count), getSignData(count), getSignPosition(count)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,10 +266,14 @@ public class ServerData {
|
||||
* @param vote the vote time queue entry
|
||||
*/
|
||||
public void addTimeVoted(int num, VoteTimeQueue vote) {
|
||||
getData().set("TimedVoteCache." + num + ".Name", vote.getName());
|
||||
getData().set("TimedVoteCache." + num + ".Service", vote.getService());
|
||||
getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
|
||||
saveData();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,16 +281,28 @@ public class ServerData {
|
||||
*
|
||||
* @param ident the vote shop identifier
|
||||
*/
|
||||
public void addVoteShopPurchase(String ident) {
|
||||
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
|
||||
public void addVoteShopPurchase(String ident) {
|
||||
mutateData(() -> {
|
||||
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
|
||||
});
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveData();
|
||||
public void clearTimedVoteCache() {
|
||||
mutateData(() -> {
|
||||
getData().set("TimedVoteCache", null);
|
||||
saveTimedVoteCache();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,13 +337,15 @@ public class ServerData {
|
||||
*
|
||||
* @return the data
|
||||
*/
|
||||
public ConfigurationSection getData() {
|
||||
ConfigurationSection data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
if (data == null) {
|
||||
plugin.getServerDataFile().getData().createSection("VotingPlugin");
|
||||
data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
}
|
||||
return data;
|
||||
public ConfigurationSection getData() {
|
||||
var fence = getPublicationFence();
|
||||
try (var owner = fence == null ? null : fence.acquire()) {
|
||||
synchronized (publicationMonitor()) {
|
||||
ConfigurationSection data = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
if (data == null) data = getFile().getData().createSection("VotingPlugin");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,9 +362,10 @@ public class ServerData {
|
||||
*
|
||||
* @return the service sites
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public ArrayList<String> getServiceSites() {
|
||||
return (ArrayList<String>) getData().getList("GottenServiceSites", new ArrayList<>());
|
||||
public ArrayList<String> getServiceSites() {
|
||||
synchronized (publicationMonitor()) {
|
||||
return new ArrayList<>(getData().getStringList("GottenServiceSites"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,9 +374,11 @@ public class ServerData {
|
||||
*
|
||||
* @return persisted service-site observations
|
||||
*/
|
||||
public synchronized List<String> getServiceSitesReadOnly() {
|
||||
ConfigurationSection root = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
|
||||
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
|
||||
public List<String> getServiceSitesReadOnly() {
|
||||
synchronized (publicationMonitor()) {
|
||||
ConfigurationSection root = getFile().getData().getConfigurationSection("VotingPlugin");
|
||||
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,8 +539,11 @@ public class ServerData {
|
||||
/**
|
||||
* Reload data.
|
||||
*/
|
||||
public void reloadData() {
|
||||
plugin.getServerDataFile().reloadData();
|
||||
public void reloadData() {
|
||||
mutateData(() -> {
|
||||
getFile().reloadData();
|
||||
captureVotePartyDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,36 +562,168 @@ public class ServerData {
|
||||
* @param top the top voter type
|
||||
* @param messageId the message ID
|
||||
*/
|
||||
public void setTopVoterMessageId(TopVoter top, long messageId) {
|
||||
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
|
||||
saveData();
|
||||
public void setTopVoterMessageId(TopVoter top, long messageId) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Uses the existing writer and confirms this ID in the saved file. Legacy void saves may
|
||||
* swallow I/O failure; queued/custom writers may also remain unconfirmed at this point.
|
||||
* False is an uncertain outcome, never permission to delete a possibly recorded message.
|
||||
* Call only on the existing worker, under this facade's normal save ordering.
|
||||
*/
|
||||
public synchronized boolean setTopVoterMessageIdConfirmed(TopVoter top, long messageId) {
|
||||
setTopVoterMessageId(top, messageId);
|
||||
try {
|
||||
var saved = new org.bukkit.configuration.file.YamlConfiguration();
|
||||
saved.load(plugin.getServerDataFile().getdFile());
|
||||
return saved.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId." + top.toString(), 0) == messageId;
|
||||
} catch (java.io.IOException | org.bukkit.configuration.InvalidConfigurationException | RuntimeException failure) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the sign.
|
||||
*
|
||||
* @param sign the sign
|
||||
*/
|
||||
public void removeSign(String sign) {
|
||||
getData().set("Signs." + sign + ".World", null);
|
||||
getData().set("Signs." + sign + ".X", null);
|
||||
getData().set("Signs." + sign + ".Y", null);
|
||||
getData().set("Signs." + sign + ".Z", null);
|
||||
getData().set("Signs." + sign + ".Data", null);
|
||||
getData().set("Signs." + sign + ".Position", null);
|
||||
getData().set("Signs." + sign, null);
|
||||
getData().set("Signs." + sign + ".Skull.World", null);
|
||||
getData().set("Signs." + sign + ".Skull.X", null);
|
||||
getData().set("Signs." + sign + ".Skull.Y", null);
|
||||
getData().set("Signs." + sign + ".Skull.Z", null);
|
||||
saveData();
|
||||
public void removeSign(String sign) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("Signs." + sign + ".World", null);
|
||||
getData().set("Signs." + sign + ".X", null);
|
||||
getData().set("Signs." + sign + ".Y", null);
|
||||
getData().set("Signs." + sign + ".Z", null);
|
||||
getData().set("Signs." + sign + ".Data", null);
|
||||
getData().set("Signs." + sign + ".Position", null);
|
||||
getData().set("Signs." + sign, null);
|
||||
getData().set("Signs." + sign + ".Skull.World", null);
|
||||
getData().set("Signs." + sign + ".Skull.X", null);
|
||||
getData().set("Signs." + sign + ".Skull.Y", null);
|
||||
getData().set("Signs." + sign + ".Skull.Z", null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save data.
|
||||
*/
|
||||
public synchronized void saveData() {
|
||||
plugin.getServerDataFile().saveData();
|
||||
/** Checkpoint the pending suffix without retiring its live queue entries. */
|
||||
public void replaceTimedVoteCache(java.util.Collection<VoteTimeQueue> pending) {
|
||||
mutateData(() -> {
|
||||
Object previous = getData().get("TimedVoteCache");
|
||||
try {
|
||||
setTimedVoteCache(pending);
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set("TimedVoteCache", previous);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish the recovery suffix and conservative in-flight record in one file save. */
|
||||
public void prepareTimedVoteDispatch(UUID id, VoteTimeQueue vote,
|
||||
java.util.Collection<VoteTimeQueue> pending) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
Object previousCache = getData().get("TimedVoteCache");
|
||||
Object previousRecord = getData().get(path);
|
||||
try {
|
||||
setTimedVoteCache(pending);
|
||||
getData().set(path, null);
|
||||
getData().createSection(path, timedVoteFields(vote));
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set("TimedVoteCache", previousCache);
|
||||
getData().set(path, previousRecord);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Restore an undispatched occurrence and its pending suffix for ordinary restart recovery. */
|
||||
public void abortTimedVoteDispatch(UUID id, java.util.Collection<VoteTimeQueue> pending) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
Object previousCache = getData().get("TimedVoteCache");
|
||||
Object previousRecord = getData().get(path);
|
||||
try {
|
||||
setTimedVoteCache(pending);
|
||||
getData().set(path, null);
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set("TimedVoteCache", previousCache);
|
||||
getData().set(path, previousRecord);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear only a confirmed dispatch record; failed writes retain its live representation. */
|
||||
public void completeTimedVoteDispatch(UUID id) {
|
||||
mutateData(() -> {
|
||||
String path = "UncertainTimedVotes." + id;
|
||||
Object previous = getData().get(path);
|
||||
try {
|
||||
getData().set(path, null);
|
||||
saveTimedVoteCache();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
getData().set(path, previous);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setTimedVoteCache(java.util.Collection<VoteTimeQueue> pending) {
|
||||
java.util.Map<String, Object> rows = new java.util.LinkedHashMap<>();
|
||||
int index = 0;
|
||||
for (VoteTimeQueue vote : pending) rows.put(Integer.toString(index++), timedVoteFields(vote));
|
||||
getData().set("TimedVoteCache", null);
|
||||
getData().createSection("TimedVoteCache", rows);
|
||||
}
|
||||
|
||||
private static java.util.Map<String, Object> timedVoteFields(VoteTimeQueue vote) {
|
||||
java.util.Map<String, Object> fields = new java.util.LinkedHashMap<>();
|
||||
fields.put("Name", vote.getName());
|
||||
fields.put("Service", vote.getService());
|
||||
fields.put("Time", vote.getTime());
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,13 +731,10 @@ public class ServerData {
|
||||
*
|
||||
* @param disabledReminders the list of UUIDs with disabled reminders
|
||||
*/
|
||||
public void saveDisabledReminders(ArrayList<UUID> disabledReminders) {
|
||||
ArrayList<String> uuids = new ArrayList<>();
|
||||
for (UUID uuid : disabledReminders) {
|
||||
uuids.add(uuid.toString());
|
||||
}
|
||||
getData().set("DisabledReminders", uuids);
|
||||
saveData();
|
||||
public void saveDisabledReminders(ArrayList<UUID> disabledReminders) {
|
||||
ArrayList<String> uuids = new ArrayList<>();
|
||||
for (UUID uuid : disabledReminders) uuids.add(uuid.toString());
|
||||
setStringList("DisabledReminders", uuids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,9 +742,8 @@ public class ServerData {
|
||||
*
|
||||
* @param placeholders the list of placeholders
|
||||
*/
|
||||
public void setAutoCachedPlaceholder(List<String> placeholders) {
|
||||
getData().set("AutoCachePlaceholders", placeholders);
|
||||
saveData();
|
||||
public void setAutoCachedPlaceholder(List<String> placeholders) {
|
||||
setStringList("AutoCachePlaceholders", placeholders);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,9 +751,8 @@ public class ServerData {
|
||||
*
|
||||
* @param current the current vote count
|
||||
*/
|
||||
public void setBungeeVotePartyCurrent(int current) {
|
||||
getData().set("BungeeVotePartyCurrent", current);
|
||||
saveData();
|
||||
public void setBungeeVotePartyCurrent(int current) {
|
||||
setInt("BungeeVotePartyCurrent", current);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,9 +760,8 @@ public class ServerData {
|
||||
*
|
||||
* @param required the required vote count
|
||||
*/
|
||||
public void setBungeeVotePartyRequired(int required) {
|
||||
getData().set("BungeeVotePartyRequired", required);
|
||||
saveData();
|
||||
public void setBungeeVotePartyRequired(int required) {
|
||||
setInt("BungeeVotePartyRequired", required);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,9 +769,8 @@ public class ServerData {
|
||||
*
|
||||
* @param list the service sites list
|
||||
*/
|
||||
public void setServiceSites(ArrayList<String> list) {
|
||||
getData().set("GottenServiceSites", list);
|
||||
saveData();
|
||||
public void setServiceSites(ArrayList<String> list) {
|
||||
setStringList("GottenServiceSites", list);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,9 +778,10 @@ public class ServerData {
|
||||
*
|
||||
* @param value the converted status
|
||||
*/
|
||||
public void setShopConverted(boolean value) {
|
||||
getData().set("VoteShopConverted", value);
|
||||
saveData();
|
||||
public void setShopConverted(boolean value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VoteShopConverted", value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,21 +793,15 @@ public class ServerData {
|
||||
* @param data the sign data
|
||||
* @param position the sign position
|
||||
*/
|
||||
public void setSign(String count, Location location, Location skullLocation, String data, int position) {
|
||||
|
||||
getData().set("Signs." + count + ".World", location.getWorld().getName());
|
||||
getData().set("Signs." + count + ".X", (int) location.getX());
|
||||
getData().set("Signs." + count + ".Y", (int) location.getY());
|
||||
getData().set("Signs." + count + ".Z", (int) location.getZ());
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
if (skullLocation != null) {
|
||||
getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName());
|
||||
getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX());
|
||||
getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY());
|
||||
getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ());
|
||||
}
|
||||
saveData();
|
||||
public void setSign(String count, Location location, Location skullLocation, String data, int position) {
|
||||
var coordinates = SignCoordinates.capture(location);
|
||||
var skull = skullLocation == null ? null : SignCoordinates.capture(skullLocation);
|
||||
mutateSavedData(() -> {
|
||||
coordinates.store(getData(), "Signs." + count);
|
||||
getData().set("Signs." + count + ".Data", data);
|
||||
getData().set("Signs." + count + ".Position", position);
|
||||
if (skull != null) skull.store(getData(), "Signs." + count + ".Skull");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,21 +810,20 @@ public class ServerData {
|
||||
* @param count the sign count
|
||||
* @param skullLocation the skull location
|
||||
*/
|
||||
public void setSkullLocation(String count, Location skullLocation) {
|
||||
if (skullLocation != null) {
|
||||
getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName());
|
||||
getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX());
|
||||
getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY());
|
||||
getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ());
|
||||
}
|
||||
public void setSkullLocation(String count, Location skullLocation) {
|
||||
if (skullLocation == null) return;
|
||||
var coordinates = SignCoordinates.capture(skullLocation);
|
||||
mutateData(() -> coordinates.store(getData(), "Signs." + count + ".Skull"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the version.
|
||||
*/
|
||||
public void setVersion() {
|
||||
getData().set("Version", Bukkit.getVersion());
|
||||
saveData();
|
||||
public void setVersion() {
|
||||
String version = Bukkit.getVersion();
|
||||
mutateSavedData(() -> {
|
||||
getData().set("Version", version);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,9 +831,10 @@ public class ServerData {
|
||||
*
|
||||
* @param value the extra required votes
|
||||
*/
|
||||
public void setVotePartyExtraRequired(int value) {
|
||||
getData().set("VotePartyExtraRequired", value);
|
||||
saveData();
|
||||
public void setVotePartyExtraRequired(int value) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VotePartyExtraRequired", value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,42 +843,45 @@ public class ServerData {
|
||||
* @param ident the vote shop identifier
|
||||
* @param amount the purchase amount
|
||||
*/
|
||||
public void setVoteShopPurchases(String ident, int amount) {
|
||||
getData().set("VoteShopPurchases." + ident, amount);
|
||||
saveData();
|
||||
public void setVoteShopPurchases(String ident, int amount) {
|
||||
mutateSavedData(() -> {
|
||||
getData().set("VoteShopPurchases." + ident, amount);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last vote party timestamp to today.
|
||||
*/
|
||||
public void updateLastVoteParty() {
|
||||
getData().set("LastVoteParty", plugin.getTimeChecker().getTime().getDayOfYear());
|
||||
saveData();
|
||||
public void updateLastVoteParty() {
|
||||
int day = plugin.getTimeChecker().getTime().getDayOfYear();
|
||||
setInt("LastVoteParty", day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last vote party week timestamp to this week.
|
||||
*/
|
||||
public void updateLastVotePartyWeek() {
|
||||
getData().set("LastVotePartyWeek",
|
||||
plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear()));
|
||||
saveData();
|
||||
public void updateLastVotePartyWeek() {
|
||||
int week = plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear());
|
||||
setInt("LastVotePartyWeek", week);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates placeholders to lowercase format.
|
||||
*/
|
||||
public void updatePlaceholders() {
|
||||
boolean data = getData().getBoolean("AutoCacheUpdated", false);
|
||||
if (!data) {
|
||||
List<String> placeholders = getAutoCachedPlaceholder();
|
||||
for (int i = 0; i < placeholders.size(); i++) {
|
||||
placeholders.set(i, placeholders.get(i).toLowerCase());
|
||||
}
|
||||
setAutoCachedPlaceholder(placeholders);
|
||||
getData().set("AutoCacheUpdated", true);
|
||||
saveData();
|
||||
}
|
||||
public void updatePlaceholders() {
|
||||
mutateData(() -> {
|
||||
boolean data = getData().getBoolean("AutoCacheUpdated", false);
|
||||
if (!data) {
|
||||
mutateSavedData(() -> {
|
||||
List<String> placeholders = getAutoCachedPlaceholder();
|
||||
for (int i = 0; i < placeholders.size(); i++) {
|
||||
placeholders.set(i, placeholders.get(i).toLowerCase());
|
||||
}
|
||||
getData().set("AutoCachePlaceholders", placeholders);
|
||||
getData().set("AutoCacheUpdated", true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,8 @@ package com.bencodez.votingplugin.discord;
|
||||
import java.awt.Color;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@@ -27,6 +25,7 @@ import github.scarsz.discordsrv.api.events.DiscordReadyEvent;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.EmbedBuilder;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.JDA;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.entities.TextChannel;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.entities.Message;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.requests.ErrorResponse;
|
||||
import github.scarsz.discordsrv.dependencies.jda.api.exceptions.ErrorResponseException;
|
||||
import github.scarsz.discordsrv.util.DiscordUtil;
|
||||
@@ -40,8 +39,45 @@ public class DiscordHandler {
|
||||
private final VotingPluginMain plugin;
|
||||
@Getter
|
||||
private final HashMap<TopVoter, Long> topVoterMessageIds = new HashMap<TopVoter, Long>();
|
||||
private final Set<TopVoter> recoveringTopVoters = new HashSet<>();
|
||||
/** Identity, rather than generation alone, owns every request and its callbacks. */
|
||||
private static final class Publication {
|
||||
final long generation;
|
||||
final boolean persistent;
|
||||
boolean sending, editing, recovering, again, completing, retired;
|
||||
long requestedGeneration;
|
||||
final AtomicBoolean sendCompleted = new AtomicBoolean(false);
|
||||
Publication(long generation, boolean persistent, boolean sending) {
|
||||
this.generation = generation; this.requestedGeneration = generation;
|
||||
this.persistent = persistent; this.sending = sending; this.editing = !sending;
|
||||
}
|
||||
}
|
||||
private static final int PENDING = 0, WRITING = 1, RETAINED = 2, DISCARDED = 3, DETACHED = 4;
|
||||
private static final int MAX_OWNED_RECEIPTS = 128;
|
||||
/** External success is owned here before any cancellable worker handoff. */
|
||||
private static final class SentReceipt {
|
||||
final TopVoter top;
|
||||
final Message message;
|
||||
final long id;
|
||||
final long generation;
|
||||
final AtomicBoolean cleanupRequested = new AtomicBoolean(false);
|
||||
int state = PENDING;
|
||||
boolean confirmed;
|
||||
SentReceipt(TopVoter top, Message message, long generation) {
|
||||
this.top = top; this.message = message; this.id = message.getIdLong(); this.generation = generation;
|
||||
}
|
||||
}
|
||||
private final java.util.EnumMap<TopVoter, Publication> publications = new java.util.EnumMap<>(TopVoter.class);
|
||||
/** Guarded by publications; includes uncertain saves until confirmed replacement or explicit detachment. */
|
||||
private final java.util.Set<SentReceipt> ownedReceipts = new java.util.HashSet<>();
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final AtomicBoolean discordReady = new AtomicBoolean(false);
|
||||
private static final class DisplayUpdate {
|
||||
private final long generation;
|
||||
private long requestedGeneration;
|
||||
private boolean again;
|
||||
private DisplayUpdate(long generation) { this.generation = generation; this.requestedGeneration = generation; }
|
||||
}
|
||||
private final java.util.EnumMap<TopVoter, DisplayUpdate> preparing = new java.util.EnumMap<>(TopVoter.class);
|
||||
|
||||
/**
|
||||
* Constructs a new Discord handler.
|
||||
@@ -149,14 +185,347 @@ public class DiscordHandler {
|
||||
* @param top the top voter
|
||||
*/
|
||||
public void updateTopVoterMessageId(TopVoter top) {
|
||||
plugin.extraDebug("Updating Discord Top Voter message for: " + top);
|
||||
prepareTopVoterMessage(top, plugin.getTopVoterDisplayGeneration());
|
||||
}
|
||||
|
||||
long channelId = plugin.getConfigFile().getDiscordSRVTopVoterChannel(top);
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topVoters = plugin.getTopVoter(top);
|
||||
private void prepareTopVoterMessage(TopVoter top, long generation) {
|
||||
DisplayUpdate update;
|
||||
synchronized (preparing) {
|
||||
if (!currentGeneration(generation)) return;
|
||||
DisplayUpdate pending = preparing.get(top);
|
||||
if (pending != null) {
|
||||
pending.again = true;
|
||||
pending.requestedGeneration = generation;
|
||||
return;
|
||||
}
|
||||
update = new DisplayUpdate(generation);
|
||||
preparing.put(top, update);
|
||||
}
|
||||
try {
|
||||
plugin.getTopVoterDisplayAsync(top).whenComplete((snapshot, failure) -> {
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
if (!releasePreparation(top, update)) return;
|
||||
if (!currentGeneration(update.generation)) {
|
||||
retryRequestedPreparation(top, update);
|
||||
return;
|
||||
}
|
||||
// Joins and TTL/scope changes can revoke omissions while publication is queued.
|
||||
// Recheck current evidence without opening another asynchronous deadline.
|
||||
if (update.again || (failure == null && snapshot != null)) {
|
||||
try { publishTopVoterMessage(top, plugin.getTopVoterDisplay(top), update.generation); }
|
||||
catch (RuntimeException unavailable) { warnLeaderboardFailure("preparing a current display for " + top); }
|
||||
} else plugin.getLogger().warning("Unable to prepare Discord Top Voters: " + top);
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
if (releasePreparation(top, update)) {
|
||||
try { if (currentGeneration(update.generation)) warnLeaderboardFailure("preparation worker admission for " + top); }
|
||||
finally { retryRequestedPreparation(top, update); }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
if (releasePreparation(top, update)) {
|
||||
try { if (currentGeneration(update.generation)) warnLeaderboardFailure("preparing evidence for " + top); }
|
||||
finally { retryRequestedPreparation(top, update); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String title = plugin.getConfigFile().getDiscordSRVTopVoterTitle(top);
|
||||
private boolean releasePreparation(TopVoter top, DisplayUpdate update) {
|
||||
synchronized (preparing) { return preparing.remove(top, update); }
|
||||
}
|
||||
|
||||
private void retryRequestedPreparation(TopVoter top, DisplayUpdate update) {
|
||||
// The retired request's evidence never changes ownership to a replacement filter.
|
||||
if (update.again && update.requestedGeneration != update.generation)
|
||||
prepareTopVoterMessage(top, update.requestedGeneration);
|
||||
}
|
||||
|
||||
void publishTopVoterMessage(TopVoter top, LinkedHashMap<TopVoterPlayer, Integer> prepared) {
|
||||
publishTopVoterMessage(top, prepared, plugin.getTopVoterDisplayGeneration());
|
||||
}
|
||||
|
||||
void publishTopVoterMessage(TopVoter top, LinkedHashMap<TopVoterPlayer, Integer> prepared, long generation) {
|
||||
if (!currentGeneration(generation)) return;
|
||||
boolean newMessage = plugin.getConfigFile().isDiscordSRVTopVoterNewMessageOnUpdate(top);
|
||||
TextChannel channel;
|
||||
try { channel = leaderboardChannel(plugin.getConfigFile().getDiscordSRVTopVoterChannel(top)); }
|
||||
catch (RuntimeException unavailable) { warnLeaderboardFailure("channel lookup for " + top); return; }
|
||||
if (channel == null) { warnLeaderboardFailure("missing channel for " + top); return; }
|
||||
Publication publication;
|
||||
long existingId;
|
||||
synchronized (publications) {
|
||||
if (!currentGeneration(generation)) return;
|
||||
Publication pending = publications.get(top);
|
||||
if (pending != null && (pending.editing || (!pending.retired && pending.generation == generation
|
||||
&& (pending.sending || pending.completing)))) {
|
||||
pending.again = true;
|
||||
pending.requestedGeneration = generation;
|
||||
return;
|
||||
}
|
||||
existingId = topVoterMessageIds.getOrDefault(top, 0L);
|
||||
publication = new Publication(generation, !newMessage, existingId <= 0 || newMessage);
|
||||
publications.put(top, publication);
|
||||
}
|
||||
try {
|
||||
var embed = buildTopVoterEmbed(plugin.getConfigFile().getDiscordSRVTopVoterTitle(top), plugin.getTopVoterDisplay(top)).build();
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
if (publication.sending) send(top, channel, publication, embed);
|
||||
else channel.editMessageEmbedsById(existingId, embed).queue(
|
||||
message -> finishEdit(top, publication, existingId),
|
||||
error -> handleMessageUpdateFailure(top, channel, publication, error));
|
||||
} catch (RuntimeException unavailable) { failPublication(top, publication, "preparing or submitting a message"); }
|
||||
}
|
||||
|
||||
private boolean current(TopVoter top, Publication publication) {
|
||||
if (!currentGeneration(publication.generation)) return false;
|
||||
synchronized (publications) { return publications.get(top) == publication && !publication.retired; }
|
||||
}
|
||||
|
||||
/** Never hold the publication lock while waiting for the ServerData save monitor. */
|
||||
private boolean persist(TopVoter top, Publication publication, long id) {
|
||||
var data = plugin.getServerData();
|
||||
synchronized (data) {
|
||||
if (!current(top, publication)) return false;
|
||||
data.setTopVoterMessageId(top, id);
|
||||
}
|
||||
return current(top, publication);
|
||||
}
|
||||
|
||||
private void finish(TopVoter top, Publication publication) {
|
||||
boolean again, prepareAgain;
|
||||
long generation;
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) != publication) return;
|
||||
publications.remove(top);
|
||||
again = publication.again;
|
||||
generation = publication.requestedGeneration;
|
||||
prepareAgain = publication.retired || generation != publication.generation;
|
||||
}
|
||||
// A queued request is independent of whether the previous network operation succeeded.
|
||||
if (again) {
|
||||
if (prepareAgain) prepareTopVoterMessage(top, generation);
|
||||
else dispatchLeaderboard(generation,
|
||||
() -> publishTopVoterMessage(top, plugin.getTopVoterDisplay(top), generation), () -> { });
|
||||
}
|
||||
}
|
||||
|
||||
private void warnLeaderboardFailure(String stage) {
|
||||
// Provider exceptions can contain request URLs or message content. Report the phase only.
|
||||
plugin.getLogger().warning("Unable to publish Discord Top Voters (" + stage
|
||||
+ "). Check the Discord channel, permissions and configuration; a later update can retry.");
|
||||
}
|
||||
|
||||
private void failPublication(TopVoter top, Publication publication, String stage) {
|
||||
try { if (current(top, publication)) warnLeaderboardFailure(stage + " for " + top); }
|
||||
finally { finish(top, publication); }
|
||||
}
|
||||
|
||||
private void send(TopVoter top, TextChannel channel, Publication publication,
|
||||
github.scarsz.discordsrv.dependencies.jda.api.entities.MessageEmbed embed) {
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
channel.sendMessageEmbeds(embed).queue(message -> {
|
||||
if (!publication.sendCompleted.compareAndSet(false, true)) return;
|
||||
SentReceipt receipt = new SentReceipt(top, message, publication.generation);
|
||||
boolean admitted;
|
||||
synchronized (publications) {
|
||||
admitted = !closed.get() && (!publication.persistent || ownedReceipts.size() < MAX_OWNED_RECEIPTS);
|
||||
if (admitted && publication.persistent) ownedReceipts.add(receipt);
|
||||
publication.completing = true;
|
||||
}
|
||||
if (!admitted) {
|
||||
discardCreatedMessage(publication, receipt); finish(top, publication); return;
|
||||
}
|
||||
dispatchLeaderboard(publication.generation, () -> {
|
||||
try {
|
||||
if (!current(top, publication)) return;
|
||||
if (publication.persistent) { retainCreatedMessage(top, publication, receipt); return; }
|
||||
if (!current(top, publication)) return;
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) == publication) topVoterMessageIds.put(top, receipt.id);
|
||||
}
|
||||
} catch (RuntimeException failure) { warnLeaderboardFailure("storing a message ID for " + top); }
|
||||
finally { discardCreatedMessage(publication, receipt); finish(top, publication); }
|
||||
}, () -> { discardCreatedMessage(publication, receipt); finish(top, publication); });
|
||||
}, failure -> failPublication(top, publication, "sending a message"));
|
||||
}
|
||||
|
||||
/** Retry an uncertain ID save after a successful edit, without creating another external post. */
|
||||
private void finishEdit(TopVoter top, Publication publication, long id) {
|
||||
synchronized (publications) { publication.editing = false; publication.completing = true; }
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
SentReceipt retry;
|
||||
synchronized (publications) {
|
||||
retry = ownedReceipts.stream().filter(receipt -> receipt.top == top && receipt.id == id
|
||||
&& receipt.state == RETAINED && !receipt.confirmed).findFirst().orElse(null);
|
||||
}
|
||||
if (retry == null) { finish(top, publication); return; }
|
||||
dispatchLeaderboard(publication.generation, () -> {
|
||||
try { retainCreatedMessage(top, publication, retry); }
|
||||
finally { discardCreatedMessage(publication, retry); finish(top, publication); }
|
||||
}, () -> finish(top, publication));
|
||||
}
|
||||
|
||||
/** Never hold publication ownership while waiting for the existing save monitor or file I/O. */
|
||||
private void retainCreatedMessage(TopVoter top, Publication publication, SentReceipt receipt) {
|
||||
var obsolete = new java.util.ArrayList<SentReceipt>();
|
||||
var data = plugin.getServerData();
|
||||
boolean uncertain = false;
|
||||
synchronized (data) {
|
||||
if (!current(top, publication)) return;
|
||||
synchronized (publications) {
|
||||
if (!current(top, publication) || (receipt.state != PENDING && receipt.state != RETAINED)) return;
|
||||
receipt.state = WRITING;
|
||||
}
|
||||
try {
|
||||
boolean confirmed = data.setTopVoterMessageIdConfirmed(top, receipt.id);
|
||||
long actual = data.getTopVoterMessageId(top);
|
||||
synchronized (publications) {
|
||||
if (receipt.state == DETACHED) return;
|
||||
// A reentrant newer confirmed write already owns the file. Reconcile only this older post.
|
||||
if (actual != receipt.id && ownedReceipts.stream().anyMatch(other -> other != receipt
|
||||
&& other.top == top && other.id == actual && other.state == RETAINED && other.confirmed)) return;
|
||||
receipt.state = RETAINED;
|
||||
receipt.confirmed = confirmed && actual == receipt.id;
|
||||
uncertain = !receipt.confirmed;
|
||||
if (actual == receipt.id && (publications.get(top) == publication
|
||||
|| topVoterMessageIds.getOrDefault(top, 0L) <= 0)) topVoterMessageIds.put(top, receipt.id);
|
||||
if (receipt.confirmed) {
|
||||
var iterator = ownedReceipts.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
SentReceipt previous = iterator.next();
|
||||
if (previous != receipt && previous.top == top && previous.state == RETAINED) {
|
||||
previous.state = DISCARDED; iterator.remove(); obsolete.add(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
// The legacy writer may have mutated or saved before throwing; do not delete a possibly recorded post.
|
||||
synchronized (publications) {
|
||||
if (receipt.state == DETACHED) return;
|
||||
receipt.state = RETAINED; receipt.confirmed = false;
|
||||
if (publications.get(top) == publication && topVoterMessageIds.getOrDefault(top, 0L) <= 0)
|
||||
topVoterMessageIds.put(top, receipt.id);
|
||||
}
|
||||
uncertain = true;
|
||||
}
|
||||
}
|
||||
obsolete.forEach(this::removeCreatedMessage);
|
||||
if (uncertain) warnMessageStorage();
|
||||
}
|
||||
|
||||
private void discardCreatedMessage(Publication publication, SentReceipt receipt) {
|
||||
if (!publication.persistent) return; // Append mode deliberately keeps its posts.
|
||||
synchronized (publications) {
|
||||
if (receipt.state != PENDING && receipt.state != WRITING) return;
|
||||
receipt.state = DISCARDED; ownedReceipts.remove(receipt);
|
||||
}
|
||||
removeCreatedMessage(receipt);
|
||||
}
|
||||
|
||||
/** Detach the administrator's old posts; clearing an ID never authorizes deleting those posts. */
|
||||
public boolean clearTopVoterMessageId(TopVoter top) {
|
||||
var data = plugin.getServerData();
|
||||
synchronized (data) {
|
||||
synchronized (publications) {
|
||||
publications.remove(top);
|
||||
synchronized (preparing) { preparing.remove(top); }
|
||||
topVoterMessageIds.put(top, 0L);
|
||||
ownedReceipts.removeIf(receipt -> {
|
||||
if (receipt.top != top || (receipt.state != PENDING && receipt.state != RETAINED
|
||||
&& receipt.state != WRITING)) return false;
|
||||
receipt.state = DETACHED; return true;
|
||||
});
|
||||
}
|
||||
try { if (data.setTopVoterMessageIdConfirmed(top, 0L)) return true; }
|
||||
catch (RuntimeException failure) { /* The outgoing diagnostic remains redacted. */ }
|
||||
}
|
||||
warnMessageStorage(); return false;
|
||||
}
|
||||
|
||||
/** Retire invalidated generations before worker cancellation; close invalidates every generation. */
|
||||
public void retireLeaderboardPublications() {
|
||||
var abandoned = new java.util.ArrayList<SentReceipt>();
|
||||
var restart = new java.util.EnumMap<TopVoter, Long>(TopVoter.class);
|
||||
synchronized (publications) {
|
||||
// An admitted edit can still write on Discord after retirement. Keep its
|
||||
// bounded slot until its network callback rather than submitting overlapping edits.
|
||||
publications.entrySet().removeIf(entry -> {
|
||||
Publication publication = entry.getValue();
|
||||
if (currentGeneration(publication.generation) && !publication.retired) return false;
|
||||
boolean again = publication.again && currentGeneration(publication.requestedGeneration);
|
||||
if (!publication.editing) {
|
||||
if (again) restart.put(entry.getKey(), publication.requestedGeneration);
|
||||
return true;
|
||||
}
|
||||
publication.retired = true;
|
||||
publication.again = again;
|
||||
return false;
|
||||
});
|
||||
synchronized (preparing) {
|
||||
preparing.entrySet().removeIf(entry -> {
|
||||
DisplayUpdate update = entry.getValue();
|
||||
if (currentGeneration(update.generation)) return false;
|
||||
if (update.again && currentGeneration(update.requestedGeneration))
|
||||
restart.put(entry.getKey(), update.requestedGeneration);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
var iterator = ownedReceipts.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
SentReceipt receipt = iterator.next();
|
||||
if (receipt.state == PENDING && !currentGeneration(receipt.generation)) {
|
||||
receipt.state = DISCARDED; iterator.remove(); abandoned.add(receipt);
|
||||
}
|
||||
}
|
||||
}
|
||||
abandoned.forEach(this::removeCreatedMessage);
|
||||
// Old worker completions can be cancelled; transfer explicit requests now,
|
||||
// outside both ownership monitors and without waiting for those callbacks.
|
||||
restart.forEach(this::prepareTopVoterMessage);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
synchronized (publications) {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
}
|
||||
retireLeaderboardPublications();
|
||||
boolean uncertain;
|
||||
synchronized (publications) {
|
||||
uncertain = ownedReceipts.stream().anyMatch(receipt -> receipt.state == WRITING || !receipt.confirmed);
|
||||
}
|
||||
if (uncertain) warnMessageStorage();
|
||||
}
|
||||
|
||||
private void warnMessageStorage() {
|
||||
plugin.getLogger().warning("Unable to confirm Discord Top Voters message ID storage. "
|
||||
+ "The post remains tracked in memory; check storage before restarting and reconcile posts manually.");
|
||||
}
|
||||
|
||||
/** JDA owns asynchronous deletion; cleanup does not depend on the retiring plugin worker. */
|
||||
private void removeCreatedMessage(SentReceipt receipt) {
|
||||
if (!receipt.cleanupRequested.compareAndSet(false, true)) return;
|
||||
try {
|
||||
receipt.message.delete().queue(ignored -> { }, failure -> {
|
||||
if (!(failure instanceof ErrorResponseException response)
|
||||
|| response.getErrorResponse() != ErrorResponse.UNKNOWN_MESSAGE) warnMessageCleanup();
|
||||
});
|
||||
} catch (RuntimeException failure) { warnMessageCleanup(); }
|
||||
}
|
||||
|
||||
private void warnMessageCleanup() {
|
||||
plugin.getLogger().warning("Unable to remove an untracked Discord Top Voters message. "
|
||||
+ "Check Discord permissions and remove the unwanted post manually.");
|
||||
}
|
||||
|
||||
TextChannel leaderboardChannel(long channelId) {
|
||||
return DiscordUtil.getJda().getTextChannelById(channelId);
|
||||
}
|
||||
|
||||
/** Render an inspected display snapshot without accessing the Discord runtime. */
|
||||
static EmbedBuilder buildTopVoterEmbed(String title, LinkedHashMap<TopVoterPlayer, Integer> topVoters) {
|
||||
EmbedBuilder eb = new EmbedBuilder().setTitle(title).setColor(Color.CYAN).setTimestamp(Instant.now());
|
||||
|
||||
if (topVoters == null || topVoters.isEmpty()) {
|
||||
@@ -187,34 +556,7 @@ public class DiscordHandler {
|
||||
eb.setDescription(sb.toString()).setFooter("Top 10 • Updated");
|
||||
}
|
||||
|
||||
TextChannel channel = DiscordUtil.getJda().getTextChannelById(channelId);
|
||||
if (channel == null) {
|
||||
plugin.getLogger().warning("Discord channel ID " + channelId + " not found: " + top);
|
||||
return;
|
||||
}
|
||||
|
||||
long existingId;
|
||||
synchronized (recoveringTopVoters) {
|
||||
if (recoveringTopVoters.contains(top)) {
|
||||
plugin.debug("Skipping Discord Top Voter update while recovery is in progress: " + top);
|
||||
return;
|
||||
}
|
||||
existingId = topVoterMessageIds.getOrDefault(top, 0L);
|
||||
}
|
||||
|
||||
if (existingId <= 0 || newMessage) {
|
||||
channel.sendMessageEmbeds(eb.build()).queue(msg -> {
|
||||
long newId = msg.getIdLong();
|
||||
topVoterMessageIds.put(top, newId);
|
||||
if (!newMessage)
|
||||
plugin.getServerData().setTopVoterMessageId(top, newId);
|
||||
plugin.debug("Posted new Top Voters " + top + " (ID: " + newId + ")");
|
||||
}, err -> plugin.getLogger().warning("Error sending Top Voters " + top + ": " + err.getMessage()));
|
||||
} else {
|
||||
channel.editMessageEmbedsById(existingId, eb.build()).queue(
|
||||
m -> plugin.debug("Edited Top Voters " + top + " (ID: " + existingId + ")"),
|
||||
err -> handleMessageUpdateFailure(top, channel, eb, existingId, err));
|
||||
}
|
||||
return eb;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,41 +566,65 @@ public class DiscordHandler {
|
||||
* condition as recoverable so one deleted message cannot permanently stop
|
||||
* leaderboard updates.
|
||||
*/
|
||||
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, EmbedBuilder eb, long existingId,
|
||||
Throwable error) {
|
||||
if (plugin.getConfigFile().isDiscordSRVTopVoterAutoRecoverMessageOnFailure()
|
||||
&& error instanceof ErrorResponseException
|
||||
&& ((ErrorResponseException) error).getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) {
|
||||
synchronized (recoveringTopVoters) {
|
||||
if (topVoterMessageIds.getOrDefault(top, 0L) != existingId || !recoveringTopVoters.add(top)) {
|
||||
plugin.debug("Skipping stale Discord Top Voter recovery because another update handled: " + top);
|
||||
return;
|
||||
private boolean currentGeneration(long generation) {
|
||||
return !closed.get() && plugin.isEnabled() && plugin.getTopVoterDisplayGeneration() == generation;
|
||||
}
|
||||
|
||||
private void dispatchLeaderboard(long generation, Runnable action, Runnable discarded) {
|
||||
if (!currentGeneration(generation)) { discarded.run(); return; }
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
if (currentGeneration(generation)) action.run(); else discarded.run();
|
||||
} catch (RuntimeException failure) {
|
||||
try { if (currentGeneration(generation)) warnLeaderboardFailure("asynchronous publication"); }
|
||||
finally { discarded.run(); }
|
||||
}
|
||||
}
|
||||
plugin.getLogger().warning("Discord Top Voters " + top + " message " + existingId
|
||||
+ " no longer exists; clearing the stored ID and posting a replacement.");
|
||||
synchronized (recoveringTopVoters) {
|
||||
topVoterMessageIds.put(top, 0L);
|
||||
}
|
||||
plugin.getServerData().setTopVoterMessageId(top, 0L);
|
||||
channel.sendMessageEmbeds(eb.build()).queue(msg -> {
|
||||
long newId = msg.getIdLong();
|
||||
synchronized (recoveringTopVoters) {
|
||||
topVoterMessageIds.put(top, newId);
|
||||
plugin.getServerData().setTopVoterMessageId(top, newId);
|
||||
recoveringTopVoters.remove(top);
|
||||
}
|
||||
plugin.debug("Recovered Top Voters " + top + " with replacement message (ID: " + newId + ")");
|
||||
}, sendError -> {
|
||||
synchronized (recoveringTopVoters) {
|
||||
recoveringTopVoters.remove(top);
|
||||
}
|
||||
plugin.getLogger().warning("Error recovering Top Voters " + top + ": " + sendError.getMessage());
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
try { if (currentGeneration(generation)) warnLeaderboardFailure("worker admission"); }
|
||||
finally { discarded.run(); }
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, Publication publication,
|
||||
Throwable error) {
|
||||
synchronized (publications) { publication.editing = false; }
|
||||
if (!current(top, publication)) { finish(top, publication); return; }
|
||||
if (!plugin.getConfigFile().isDiscordSRVTopVoterAutoRecoverMessageOnFailure()
|
||||
|| !(error instanceof ErrorResponseException response)
|
||||
|| response.getErrorResponse() != ErrorResponse.UNKNOWN_MESSAGE) {
|
||||
failPublication(top, publication, "editing a message");
|
||||
return;
|
||||
}
|
||||
|
||||
plugin.getLogger().warning("Error editing Top Voters " + top + ": " + error.getMessage());
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) != publication || publication.recovering) return;
|
||||
publication.recovering = true;
|
||||
publication.sending = true;
|
||||
}
|
||||
dispatchLeaderboard(publication.generation, () -> {
|
||||
try {
|
||||
if (!persist(top, publication, 0L)) { finish(top, publication); return; }
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) != publication) return;
|
||||
topVoterMessageIds.put(top, 0L);
|
||||
}
|
||||
// Persistence can block while a join, TTL, reload or disable revokes the earlier frame.
|
||||
var replacement = buildTopVoterEmbed(plugin.getConfigFile().getDiscordSRVTopVoterTitle(top),
|
||||
plugin.getTopVoterDisplay(top)).build();
|
||||
send(top, channel, publication, replacement);
|
||||
} catch (RuntimeException failure) { failPublication(top, publication, "recovering a deleted message"); }
|
||||
}, () -> {
|
||||
// Rejected admission has no effects; the same failed edit may be retried.
|
||||
boolean again = false;
|
||||
synchronized (publications) {
|
||||
if (publications.get(top) == publication) {
|
||||
publication.recovering = false; publication.sending = false;
|
||||
again = publication.again;
|
||||
}
|
||||
}
|
||||
if (again) finish(top, publication);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-13
@@ -12,20 +12,12 @@ import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class PlayerPostVoteEvent extends Event {
|
||||
@Getter @Setter private boolean proxySessionRealVote = true;
|
||||
/** Local recovered ingress cannot prove ordering relative to a new guide. */
|
||||
@Getter @Setter private boolean unconfirmedLocalSessionDelivery;
|
||||
public class PlayerPostVoteEvent extends Event {
|
||||
|
||||
/** Backend-local ingress order forwarded by the accepted pipeline. Zero means unknown. */
|
||||
@Getter @Setter private long backendObservationOrder;
|
||||
@Getter @Setter private boolean proxyQueueClassificationKnown;
|
||||
@Getter @Setter private boolean queuedProxyVote;
|
||||
@Getter @Setter private boolean proxySessionDelivery;
|
||||
/** Guide-only provenance; independent of normal reward queue/delay policy. */
|
||||
@Getter @Setter private boolean unconfirmedProxySessionDelivery;
|
||||
/** Fresh local ingress with no supplied historical occurrence timestamp. */
|
||||
@Getter @Setter private boolean liveLocalSessionDelivery;
|
||||
/** 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();
|
||||
@@ -90,6 +82,11 @@ 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,17 @@ 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 {
|
||||
@Getter @Setter private boolean proxySessionRealVote = true;
|
||||
public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
|
||||
/** Backend-local strictly increasing observation, never a timestamp from another node. */
|
||||
/** Stable local receipt identity, separate from proxy transport provenance. */
|
||||
@Getter
|
||||
@Setter
|
||||
private long backendObservationOrder = com.bencodez.votingplugin.core.session.VoteObservationSequence.next();
|
||||
/** Guide-only marker; preserves normal original vote timestamp and accounting semantics. */
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean timeQueuedLocalSessionDelivery;
|
||||
private UUID localOccurrenceId;
|
||||
|
||||
/** 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();
|
||||
@@ -55,7 +55,7 @@ public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
@Setter
|
||||
private VoteTotalsSnapshot bungeeTextTotals;
|
||||
|
||||
/** Stable identity supplied by a proxy delivery, independent of totals. */
|
||||
/** Original explicit proxy transport ID; null for legacy totals-only correlation. */
|
||||
@Getter
|
||||
@Setter
|
||||
private UUID proxyVoteId;
|
||||
@@ -96,6 +96,20 @@ 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
|
||||
@@ -106,11 +120,6 @@ public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
@Setter
|
||||
private boolean proxyQueueClassificationKnown;
|
||||
|
||||
/** Guide-only provenance: original ingress is forwarded, replayed, or unknown. */
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean unconfirmedProxySessionDelivery;
|
||||
|
||||
/** Whether the proxy explicitly supplied its delay-validation decision. */
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+26
-14
@@ -35,7 +35,9 @@ 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;
|
||||
SharedVoteProcessor.process(new BukkitOperations(plugin, event));
|
||||
BukkitOperations operations = new BukkitOperations(plugin, event);
|
||||
SharedVoteProcessor.process(operations);
|
||||
if (event.isNativeProcessingStarted()) event.setNativeProcessingCompleted(true);
|
||||
}
|
||||
|
||||
private static final class BukkitOperations implements SharedVoteProcessor.Operations<VoteSite, VotingPluginUser> {
|
||||
@@ -47,7 +49,11 @@ public class PlayerVoteListener implements Listener {
|
||||
this.event = event;
|
||||
}
|
||||
|
||||
@Override public boolean enabled() { return plugin.isEnabled(); }
|
||||
@Override public boolean enabled() {
|
||||
boolean enabled = plugin.isEnabled();
|
||||
if (enabled) event.setNativeProcessingStarted(true);
|
||||
return enabled;
|
||||
}
|
||||
@Override public String incomingName() { return event.getPlayer(); }
|
||||
@Override public String serviceSite() { return event.getServiceSite(); }
|
||||
@Override public void debug(String message) { plugin.debug(message); }
|
||||
@@ -131,6 +137,7 @@ 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(); }
|
||||
@@ -176,6 +183,20 @@ 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);
|
||||
@@ -184,7 +205,9 @@ public class PlayerVoteListener implements Listener {
|
||||
long voteTime, UUID voteId, boolean cached) {
|
||||
PlayerPostVoteEvent post = new PlayerPostVoteEvent(site, user, event.isRealVote(), event.isForceBungee(),
|
||||
voteTime, cached, site.getServiceSite(), user.getJavaUUID(), playerName, voteId);
|
||||
copySessionObservation(post, event);
|
||||
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(); }
|
||||
@@ -193,17 +216,6 @@ public class PlayerVoteListener implements Listener {
|
||||
@Override public void setUpdate() { plugin.setUpdate(true); }
|
||||
}
|
||||
|
||||
static void copySessionObservation(PlayerPostVoteEvent post, PlayerVoteEvent input) {
|
||||
post.setProxySessionDelivery(input.isBungee());
|
||||
post.setProxySessionRealVote(input.isProxySessionRealVote());
|
||||
post.setUnconfirmedProxySessionDelivery(input.isUnconfirmedProxySessionDelivery());
|
||||
post.setLiveLocalSessionDelivery(!input.isBungee() && (input.getTime() == 0 || input.isTimeQueuedLocalSessionDelivery()) && input.getBackendObservationOrder() > 0);
|
||||
post.setUnconfirmedLocalSessionDelivery(!input.isBungee() && (input.getTime() == 0 || input.isTimeQueuedLocalSessionDelivery()) && input.getBackendObservationOrder() <= 0);
|
||||
post.setBackendObservationOrder(input.getBackendObservationOrder());
|
||||
post.setProxyQueueClassificationKnown(input.isProxyQueueClassificationKnown());
|
||||
post.setQueuedProxyVote(input.isQueuedProxyVote());
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
static UUID resolveProxyVoteId(PlayerVoteEvent event) {
|
||||
if (event.getProxyVoteId() != null) return event.getProxyVoteId();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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;
|
||||
@@ -17,16 +20,133 @@ import com.vexsoftware.votifier.model.VotifierEvent;
|
||||
|
||||
public class VotiferEvent implements Listener {
|
||||
|
||||
private VotingPluginMain plugin;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates a new votifer event.
|
||||
*
|
||||
* @param plugin the plugin
|
||||
*/
|
||||
public VotiferEvent(VotingPluginMain plugin) {
|
||||
this.plugin = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a validated vote. The overflow queue invokes this callback from
|
||||
@@ -35,11 +155,23 @@ 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) { processVote(voteSite, voteUsername, 0L); }
|
||||
|
||||
public void processVote(String voteSite, String voteUsername, long occurredAt) {
|
||||
processVote(voteSite, voteUsername, occurredAt, UUID.randomUUID());
|
||||
}
|
||||
/** Original process-local ingress order; zero identifies recovered or unknown ordering. */
|
||||
public void processVote(String voteSite, String voteUsername, long observationOrder) {
|
||||
|
||||
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");
|
||||
try {
|
||||
plugin.getServerData().addServiceSite(voteSite);
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass()
|
||||
@@ -50,7 +182,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;
|
||||
return true;
|
||||
}
|
||||
|
||||
String matchSite = "";
|
||||
@@ -81,23 +213,29 @@ 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, observationOrder);
|
||||
return;
|
||||
plugin.getTimeQueueHandler().addVote(voteUsername, voteSite, occurredAt, localOccurrenceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite);
|
||||
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true);
|
||||
voteEvent.setBackendObservationOrder(observationOrder);
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +246,9 @@ 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();
|
||||
@@ -140,17 +281,58 @@ public class VotiferEvent implements Listener {
|
||||
plugin.debug("VoteSite: " + voteSite);
|
||||
plugin.debug("IP: " + IP);
|
||||
|
||||
final long observationOrder = com.bencodez.votingplugin.core.session.VoteObservationSequence.next();
|
||||
try {
|
||||
plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername, observationOrder));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue();
|
||||
if (overflow == null || !overflow.enqueue(voteUsername, voteSite, observationOrder)) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+628
-36
@@ -4,6 +4,9 @@ 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;
|
||||
@@ -17,10 +20,15 @@ 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;
|
||||
|
||||
@@ -37,24 +45,65 @@ 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 ObservedVoteProcessor { void accept(String site, String player, long order); }
|
||||
private final ObservedVoteProcessor processor;
|
||||
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 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;
|
||||
|
||||
@@ -65,22 +114,56 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
* @param processor callback receiving service site and player name
|
||||
*/
|
||||
public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer<String, String> processor) {
|
||||
this(plugin, (site, player, order) -> processor.accept(site, player));
|
||||
this(plugin, (site, user, occurredAt, id) -> processor.accept(site, user), () -> true);
|
||||
}
|
||||
public static VotifierVoteOverflowQueue withObservations(VotingPluginMain plugin, ObservedVoteProcessor processor) {
|
||||
return new VotifierVoteOverflowQueue(plugin, processor);
|
||||
|
||||
/** 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);
|
||||
}
|
||||
private VotifierVoteOverflowQueue(VotingPluginMain plugin, ObservedVoteProcessor processor) {
|
||||
|
||||
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);
|
||||
load();
|
||||
if (asynchronousInitialization) worker.execute(this::load);
|
||||
else load();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,13 +188,88 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
* @return false when the bounded overflow is full or shutting down
|
||||
*/
|
||||
public boolean enqueue(String username, String serviceSite) {
|
||||
return enqueue(username, serviceSite, com.bencodez.votingplugin.core.session.VoteObservationSequence.next());
|
||||
return enqueue(username, serviceSite, System.currentTimeMillis());
|
||||
}
|
||||
public boolean enqueue(String username, String serviceSite, long observationOrder) {
|
||||
if (username == null || serviceSite == null) return false;
|
||||
|
||||
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;
|
||||
synchronized (lock) {
|
||||
if (closed || entries.size() >= MAX_ENTRIES) return false;
|
||||
entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis(), observationOrder));
|
||||
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);
|
||||
stateVersion++;
|
||||
requestPersistenceLocked();
|
||||
scheduleDrainLocked();
|
||||
@@ -119,6 +277,162 @@ 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.
|
||||
*
|
||||
@@ -137,7 +451,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void scheduleDrainLocked() {
|
||||
if (closed || !started || drainScheduled) return;
|
||||
if (closed || closing || !loaded || loadFailed || !started || drainScheduled) return;
|
||||
drainScheduled = true;
|
||||
try {
|
||||
worker.schedule(this::drain, 0, TimeUnit.MILLISECONDS);
|
||||
@@ -148,7 +462,14 @@ 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;
|
||||
@@ -163,10 +484,23 @@ 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 {
|
||||
processor.accept(pending.serviceSite, pending.username, pending.observationOrder);
|
||||
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;
|
||||
} finally {
|
||||
acknowledge(pending);
|
||||
if (completed) acknowledge(pending);
|
||||
else retainFailedAttempt(pending, attempted);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
@@ -179,27 +513,102 @@ 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) return pending;
|
||||
if (pending.submitted || !pending.executionReleased.getAsBoolean()) continue;
|
||||
if (pending.retryNotBeforeNanos != 0 && now - pending.retryNotBeforeNanos < 0) {
|
||||
retryWaiting = true;
|
||||
continue;
|
||||
}
|
||||
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) {
|
||||
entries.remove(pending);
|
||||
if (closed) {
|
||||
plugin.getLogger().warning("Completed Votifier callback outlived queue shutdown; retained occurrence requires reconciliation");
|
||||
return;
|
||||
}
|
||||
if (!entries.remove(pending)) return;
|
||||
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;
|
||||
@@ -250,18 +659,32 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void load() {
|
||||
ArrayDeque<PendingVote> recovered = new ArrayDeque<>();
|
||||
boolean successful = false;
|
||||
try {
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
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;
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.loadFromString(readQueueFile());
|
||||
Object raw = yaml.get("Votes");
|
||||
if (!(raw instanceof List<?> values)) return;
|
||||
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");
|
||||
boolean skipped = false;
|
||||
for (Object value : values) {
|
||||
if (entries.size() >= MAX_ENTRIES) {
|
||||
skipped = true;
|
||||
break;
|
||||
}
|
||||
if (!(value instanceof Map<?, ?> map)) {
|
||||
skipped = true;
|
||||
continue;
|
||||
@@ -276,16 +699,111 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
entries.addLast(new PendingVote(name, site, timestamp.longValue(), 0L));
|
||||
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));
|
||||
}
|
||||
synchronized (lock) { entries.clear(); entries.addAll(recovered); }
|
||||
if (skipped) {
|
||||
synchronized (lock) {
|
||||
stateVersion++;
|
||||
requestPersistenceLocked();
|
||||
}
|
||||
}
|
||||
successful = true;
|
||||
} catch (Exception failure) {
|
||||
plugin.getLogger().warning("Unable to load queued Votifier votes: " + failure.getClass().getSimpleName());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +827,8 @@ 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) {
|
||||
@@ -316,6 +836,7 @@ 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);
|
||||
@@ -341,21 +862,26 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
List<PendingVote> snapshot;
|
||||
List<PendingVote> snapshot = null;
|
||||
boolean initializedWorker;
|
||||
synchronized (lock) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
// At-least-once delivery: retain any unacknowledged callback. A callback
|
||||
// interrupted during shutdown must be replayed rather than silently lost.
|
||||
snapshot = new ArrayList<>(entries);
|
||||
persistenceScheduled = false;
|
||||
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 (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);
|
||||
@@ -364,21 +890,87 @@ 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;
|
||||
|
||||
// Process-local ordering is intentionally not restored across server restarts.
|
||||
private final long observationOrder;
|
||||
private PendingVote(String username, String serviceSite, long time, long observationOrder) {
|
||||
this.observationOrder = observationOrder;
|
||||
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) {
|
||||
this.username = username;
|
||||
this.serviceSite = serviceSite;
|
||||
this.time = time;
|
||||
this.localOccurrenceId = localOccurrenceId;
|
||||
this.executionReleased = java.util.Objects.requireNonNull(executionReleased);
|
||||
}
|
||||
}
|
||||
}
|
||||
+267
-34
@@ -67,6 +67,8 @@ public class PlaceHolders {
|
||||
private record PlatformUpdateKey(UUID uuid, PlaceHolder<VotingPluginUser> placeholder) { }
|
||||
private final ConcurrentHashMap<PlatformUpdateKey, Long> platformUpdateGenerations = new ConcurrentHashMap<>();
|
||||
private boolean userDataChangeListenerRegistered;
|
||||
private final PointPlaceholderFence pointFence = new PointPlaceholderFence();
|
||||
private final ConcurrentHashMap<UUID, Object> pointRecoveryWorkers = new ConcurrentHashMap<>();
|
||||
|
||||
@Getter
|
||||
private PlaceholderCacheLevel cacheLevel;
|
||||
@@ -130,6 +132,8 @@ 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")
|
||||
@@ -168,7 +172,9 @@ public class PlaceHolders {
|
||||
* @param identifier the placeholder identifier
|
||||
* @return the placeholder value
|
||||
*/
|
||||
public String getPlaceHolder(Player p, String identifier) {
|
||||
public String getPlaceHolder(Player p, String identifier) {
|
||||
if (identifier.regionMatches(true, 0, "DateVoteMilestones_", 0, "DateVoteMilestones_".length()))
|
||||
return getPlaceholderValue(p, identifier, false, true, false);
|
||||
if (plugin.getConfigFile().isUseJavascriptPlaceholders()) {
|
||||
identifier = PlaceholderUtils.replaceJavascript(p, identifier);
|
||||
}
|
||||
@@ -185,7 +191,118 @@ 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);
|
||||
}
|
||||
@@ -205,6 +322,12 @@ public class PlaceHolders {
|
||||
return "no player";
|
||||
}
|
||||
|
||||
// These ranks use bounded snapshot projections; persistent result caches outlive presence evidence.
|
||||
if (plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore() && switch (identifier.toLowerCase(Locale.ROOT)) {
|
||||
case "top_all_position", "top_month_position", "top_lastmonth_position", "top_week_position",
|
||||
"top_daily_position" -> true;
|
||||
default -> false;
|
||||
}) useCache = false;
|
||||
VotingPluginUser user = resolvePlaceholderUser(p);
|
||||
if (useCache) {
|
||||
if (!cachedPlaceholders.contains(identifier)) {
|
||||
@@ -420,6 +543,14 @@ 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
|
||||
@@ -492,7 +623,8 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
return Integer.toString(user.getPoints());
|
||||
Integer points = pointBalance(user);
|
||||
return points == null ? "..." : Integer.toString(points);
|
||||
}
|
||||
}.withDescription("User points").updateDataKey("Points"));
|
||||
|
||||
@@ -501,7 +633,8 @@ public class PlaceHolders {
|
||||
@Override
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.US);
|
||||
return numberFormat.format(user.getPoints());
|
||||
Integer points = pointBalance(user);
|
||||
return points == null ? "..." : numberFormat.format(points);
|
||||
}
|
||||
}.withDescription("User points").updateDataKey("Points"));
|
||||
|
||||
@@ -609,7 +742,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -627,7 +760,9 @@ public class PlaceHolders {
|
||||
return "invalid";
|
||||
}
|
||||
int number = Integer.parseInt(identifier.split("_")[1]);
|
||||
if (user.getPoints() >= number) {
|
||||
Integer points = pointBalance(user);
|
||||
if (points == null) return "...";
|
||||
if (points >= number) {
|
||||
return "true";
|
||||
}
|
||||
return "false";
|
||||
@@ -705,7 +840,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -724,7 +859,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -740,7 +875,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -757,7 +892,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -775,7 +910,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -791,7 +926,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoter().entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getLastMonthTopVoterDisplay().entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -812,7 +947,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -828,7 +963,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -844,7 +979,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -863,7 +998,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -879,7 +1014,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -895,7 +1030,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(VotingPluginUser user, String identifier) {
|
||||
int num = 1;
|
||||
UUID toMatch = UUID.fromString(user.getUUID());
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
if (entry.getKey().getUuid().equals(toMatch)) {
|
||||
return "" + num;
|
||||
}
|
||||
@@ -914,7 +1049,7 @@ public class PlaceHolders {
|
||||
}
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
if (num == number) {
|
||||
return entry.getKey().getPlayerName();
|
||||
}
|
||||
@@ -938,7 +1073,7 @@ public class PlaceHolders {
|
||||
public String placeholderRequest(String identifier) {
|
||||
int num = 1;
|
||||
int number = Integer.parseInt(identifier.split("_")[2]);
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
if (num == number) {
|
||||
return "" + entry.getValue().intValue();
|
||||
}
|
||||
@@ -954,7 +1089,7 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getVoteParty().getTotalVotes());
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay().total());
|
||||
}
|
||||
}.withDescription("Current amount of voteparty votes"));
|
||||
|
||||
@@ -962,7 +1097,8 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getVoteParty().getNeededVotes());
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay()
|
||||
.needed(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()));
|
||||
}
|
||||
}.withDescription("Voteparty votes needed"));
|
||||
|
||||
@@ -970,7 +1106,8 @@ public class PlaceHolders {
|
||||
|
||||
@Override
|
||||
public String placeholderRequest(String identifier) {
|
||||
return Integer.toString(plugin.getVoteParty().getVotesRequired());
|
||||
return Integer.toString(plugin.getServerData().getVotePartyDisplay()
|
||||
.required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()));
|
||||
}
|
||||
}.withDescription("Amount of votes needed for voteparty"));
|
||||
|
||||
@@ -1407,6 +1544,13 @@ public class PlaceHolders {
|
||||
@SuppressWarnings("unchecked")
|
||||
private void updateCachedPlaceholder(PlaceHolder<VotingPluginUser> placeholder, VotingPluginUser user,
|
||||
UUID uuid, String... keys) {
|
||||
var pointGeneration = pointFence.generation(uuid);
|
||||
try {
|
||||
if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey())
|
||||
&& !pointFence.readable(uuid, pointGeneration)) {
|
||||
if (pointFence.recoverable(uuid)) schedulePointRecovery(user);
|
||||
return;
|
||||
}
|
||||
for (String ident : Set.copyOf(placeholder.getCache().keySet())) {
|
||||
ConcurrentHashMap<UUID, String> values = placeholder.getCache().get(ident);
|
||||
if (values == null) continue;
|
||||
@@ -1427,7 +1571,11 @@ public class PlaceHolders {
|
||||
}
|
||||
} else {
|
||||
String value = placeholder.placeholderRequest(user, ident);
|
||||
values.put(uuid, value);
|
||||
if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey())) {
|
||||
pointFence.publish(uuid, pointGeneration, () -> {
|
||||
if (plugin.isEnabled()) values.put(uuid, value);
|
||||
});
|
||||
} else values.put(uuid, value);
|
||||
if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) {
|
||||
values.remove(uuid);
|
||||
continue;
|
||||
@@ -1436,6 +1584,9 @@ public class PlaceHolders {
|
||||
+ matchingKey(placeholder, keys) + " with " + value);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pointFence.release(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private void publishCachedValue(PlaceHolder<VotingPluginUser> placeholder, VotingPluginUser user, UUID uuid,
|
||||
@@ -1460,6 +1611,52 @@ public class PlaceHolders {
|
||||
return placeholder.getUpdateDataKey();
|
||||
}
|
||||
|
||||
/** Blocks stale point results before a shared debit enters its worker. */
|
||||
public void beginSharedPointDebit(UUID uuid) {
|
||||
pointFence.begin(uuid, () -> clearPointResults(uuid));
|
||||
}
|
||||
|
||||
/** Owns a queued batch without replacing its worker's mutation outcome. */
|
||||
public java.util.function.Consumer<Boolean> sharedPointAdmissionCompletion(UUID uuid) {
|
||||
beginSharedPointDebit(uuid);
|
||||
var completed = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
return settled -> {
|
||||
if (completed.compareAndSet(false, true)) pointFence.finishAdmission(uuid,
|
||||
settled && plugin.isEnabled() && plugin.getPlaceholders() == this);
|
||||
};
|
||||
}
|
||||
|
||||
private void clearPointResults(UUID uuid) {
|
||||
for (PlaceHolder<VotingPluginUser> placeholder : userDataChangeClassification.all()) {
|
||||
if ("Points".equalsIgnoreCase(placeholder.getUpdateDataKey()) && placeholder.isUsesCache()) {
|
||||
placeholder.clearCachePlayer(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Completes the fence only after the storage worker has reconciled its snapshot. */
|
||||
public void finishSharedPointDebit(UUID uuid, boolean trustworthySnapshot) {
|
||||
finishSharedPointDebit(uuid, trustworthySnapshot, trustworthySnapshot);
|
||||
}
|
||||
|
||||
public void finishSharedPointDebit(UUID uuid, boolean knownOutcome, boolean trustworthySnapshot) {
|
||||
pointFence.finish(uuid, knownOutcome, trustworthySnapshot);
|
||||
}
|
||||
|
||||
/** Captures a worker refill generation without granting permission to retry a mutation. */
|
||||
public java.util.function.Consumer<Boolean> sharedPointRefillCompletion(UUID uuid) {
|
||||
var stamp = pointFence.beginRefill(uuid, () -> clearPointResults(uuid));
|
||||
var completed = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
return verified -> {
|
||||
if (!completed.compareAndSet(false, true)) return;
|
||||
try {
|
||||
pointFence.finishRefill(uuid, stamp, verified && plugin.isEnabled() && plugin.getPlaceholders() == this);
|
||||
} finally {
|
||||
pointFence.release(uuid);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle player logout.
|
||||
* @param user the voting plugin user
|
||||
@@ -1505,11 +1702,12 @@ public class PlaceHolders {
|
||||
/**
|
||||
* Update placeholders for specific user.
|
||||
* @param user the voting plugin user
|
||||
* @param login whether this is a login update
|
||||
* @param login whether to include uncached entries, such as a login or worker warmup
|
||||
*/
|
||||
public void onUpdate(VotingPluginUser user, boolean login) {
|
||||
if (user == null) return;
|
||||
UUID uuid = user.getJavaUUID();
|
||||
if (pointFence.recoverable(uuid)) schedulePointRecovery(user);
|
||||
Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid);
|
||||
PlaceholderClassification classification = userDataChangeClassification;
|
||||
List<PlaceHolder<VotingPluginUser>> platformUpdates = new ArrayList<>();
|
||||
@@ -1551,6 +1749,8 @@ public class PlaceHolders {
|
||||
* Reload placeholders.
|
||||
*/
|
||||
public void reload() {
|
||||
pointFence.invalidateLifecycle();
|
||||
pointRecoveryWorkers.clear();
|
||||
platformUpdateGenerations.clear();
|
||||
plugin.refreshPlaceholderPlayerPresence();
|
||||
cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel();
|
||||
@@ -1584,14 +1784,47 @@ public class PlaceHolders {
|
||||
* Schedule placeholder check for user.
|
||||
* @param user the voting plugin user
|
||||
*/
|
||||
public void schedulePlaceholderCheck(VotingPluginUser user) {
|
||||
plugin.getTimer().execute(new Runnable() {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
checkNonCachedPlaceholders();
|
||||
onUpdate(user, plugin.getPlaceholderPlayerPresence().isOnline(user.getJavaUUID()));
|
||||
}
|
||||
});
|
||||
}
|
||||
public void schedulePlaceholderCheck(VotingPluginUser user) {
|
||||
if (!plugin.isEnabled() || plugin.getPlaceholders() != this) return;
|
||||
UUID uuid = user.getJavaUUID();
|
||||
Object token = new Object();
|
||||
long lifecycle = pointFence.lifecycle();
|
||||
// Common warming and direct point recovery share queued and executing admission.
|
||||
if (pointRecoveryWorkers.putIfAbsent(uuid, token) != null) return;
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
boolean warmed = false;
|
||||
try {
|
||||
if (!plugin.isEnabled() || plugin.getPlaceholders() != this || lifecycle != pointFence.lifecycle()) return;
|
||||
var observed = pointFence.generation(uuid);
|
||||
try {
|
||||
// Invalid data in a complete published row is not a missing snapshot.
|
||||
// Pending/unknown writes cannot authorize a competing point refill.
|
||||
if (pointFence.recoverable(uuid)
|
||||
|| (pointFence.readable(uuid, observed)
|
||||
&& com.bencodez.advancedcore.api.user.UserStorage.MYSQL.equals(plugin.getStorageType())
|
||||
&& publishedPointSnapshot(user) == null)) {
|
||||
com.bencodez.votingplugin.user.SharedMysqlCacheReconciler.invalidateAndRefreshOnWorker(
|
||||
plugin, user.getUUID(), user.getPointsPath());
|
||||
}
|
||||
} finally { pointFence.release(uuid); }
|
||||
if (!plugin.isEnabled() || plugin.getPlaceholders() != this || lifecycle != pointFence.lifecycle()) return;
|
||||
checkNonCachedPlaceholders();
|
||||
// Preserve each cache level's existing offline-entry admission policy.
|
||||
onUpdate(user, !getCacheLevel().onlineOnly() || plugin.getPlaceholderPlayerPresence().isOnline(uuid));
|
||||
warmed = true;
|
||||
} finally {
|
||||
boolean retired = pointRecoveryWorkers.remove(uuid, token);
|
||||
// A new identifier may have arrived after this worker's discovery pass.
|
||||
if (retired && warmed && !placeholdersToSetCacheOn.isEmpty() && plugin.isEnabled()
|
||||
&& plugin.getPlaceholders() == this && lifecycle == pointFence.lifecycle()) {
|
||||
schedulePlaceholderCheck(user);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
pointRecoveryWorkers.remove(uuid, token);
|
||||
plugin.debug(rejected);
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.bencodez.votingplugin.placeholders;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Coordinates point rendering with in-flight shared-storage mutations. */
|
||||
final class PointPlaceholderFence {
|
||||
private static final class State {
|
||||
long generation;
|
||||
int pending;
|
||||
int readers;
|
||||
int refills;
|
||||
boolean uncertain;
|
||||
boolean snapshotUnavailable;
|
||||
}
|
||||
|
||||
private final Map<UUID, State> states = new HashMap<>();
|
||||
private long generation;
|
||||
private long lifecycle;
|
||||
record Stamp(long lifecycle, long generation) { }
|
||||
|
||||
synchronized Stamp generation(UUID uuid) {
|
||||
State state = states.computeIfAbsent(uuid, ignored -> {
|
||||
State created = new State();
|
||||
created.generation = ++generation;
|
||||
return created;
|
||||
});
|
||||
state.readers++;
|
||||
return new Stamp(lifecycle, state.generation);
|
||||
}
|
||||
|
||||
synchronized boolean readable(UUID uuid, Stamp observed) {
|
||||
if (lifecycle != observed.lifecycle()) return false;
|
||||
State state = states.get(uuid);
|
||||
return state != null && state.generation == observed.generation()
|
||||
&& state.pending == 0 && state.refills == 0 && !state.uncertain && !state.snapshotUnavailable;
|
||||
}
|
||||
|
||||
synchronized void release(UUID uuid) {
|
||||
State state = states.get(uuid);
|
||||
if (state == null || state.readers == 0) throw new IllegalStateException("Unbalanced point reader");
|
||||
state.readers--;
|
||||
retireIfSafe(uuid, state);
|
||||
}
|
||||
|
||||
private void retireIfSafe(UUID uuid, State state) {
|
||||
if (state.readers == 0 && state.pending == 0 && state.refills == 0 && !state.uncertain && !state.snapshotUnavailable) states.remove(uuid);
|
||||
}
|
||||
|
||||
synchronized void invalidateLifecycle() { lifecycle++; }
|
||||
synchronized long lifecycle() { return lifecycle; }
|
||||
synchronized boolean recoverable(UUID uuid) {
|
||||
State state = states.get(uuid);
|
||||
return state != null && state.snapshotUnavailable && !state.uncertain && state.pending == 0 && state.refills == 0;
|
||||
}
|
||||
|
||||
synchronized Stamp beginRefill(UUID uuid, Runnable invalidate) {
|
||||
State state = states.computeIfAbsent(uuid, ignored -> new State());
|
||||
state.generation = ++generation;
|
||||
state.snapshotUnavailable = true;
|
||||
state.refills++;
|
||||
state.readers++;
|
||||
invalidate.run();
|
||||
return new Stamp(lifecycle, state.generation);
|
||||
}
|
||||
|
||||
synchronized void begin(UUID uuid, Runnable invalidate) {
|
||||
State state = states.computeIfAbsent(uuid, ignored -> new State());
|
||||
state.generation = ++generation;
|
||||
state.pending++;
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
synchronized void finish(UUID uuid, boolean knownOutcome, boolean trustworthy) {
|
||||
State state = states.get(uuid);
|
||||
if (state == null || state.pending == 0) throw new IllegalStateException("Unbalanced point mutation");
|
||||
state.uncertain |= !knownOutcome;
|
||||
state.snapshotUnavailable = !trustworthy || state.refills != 0;
|
||||
state.pending--;
|
||||
state.generation = ++generation;
|
||||
retireIfSafe(uuid, state);
|
||||
}
|
||||
|
||||
synchronized void finishAdmission(UUID uuid, boolean workerSettled) {
|
||||
State state = states.get(uuid);
|
||||
if (state == null || state.pending == 0) throw new IllegalStateException("Unbalanced point admission");
|
||||
state.pending--;
|
||||
state.snapshotUnavailable |= !workerSettled;
|
||||
state.generation = ++generation;
|
||||
retireIfSafe(uuid, state);
|
||||
}
|
||||
|
||||
synchronized void finishRefill(UUID uuid, Stamp observed, boolean verified) {
|
||||
State state = states.get(uuid);
|
||||
if (state == null || state.refills == 0) throw new IllegalStateException("Unbalanced point refill");
|
||||
boolean current = lifecycle == observed.lifecycle() && state.generation == observed.generation();
|
||||
state.refills--;
|
||||
state.snapshotUnavailable = !(verified && current && state.pending == 0 && state.refills == 0 && !state.uncertain);
|
||||
// Even an obsolete read completion can replace storage data: retire every older render.
|
||||
state.generation = ++generation;
|
||||
}
|
||||
|
||||
synchronized void publish(UUID uuid, Stamp observed, Runnable publication) {
|
||||
if (readable(uuid, observed)) publication.run();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,19 @@ 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
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ public final class PendingIncomingVoteQueue {
|
||||
|
||||
public synchronized PendingIncomingVote admit(String player, String service) {
|
||||
if (!accepting || votes.size() >= MAX_PENDING) return null;
|
||||
PendingIncomingVote vote = new PendingIncomingVote(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId(), player, service,
|
||||
PendingIncomingVote vote = new PendingIncomingVote(UUID.randomUUID(), player, service,
|
||||
System.currentTimeMillis());
|
||||
votes.put(vote.getVoteId(), vote);
|
||||
return vote;
|
||||
|
||||
+1
-11
@@ -74,7 +74,6 @@ final class ReliableVoteDeliveryOutbox {
|
||||
long deferredCount = entries.values().stream().filter(Entry::deferredUpdate).count();
|
||||
if (deferred ? !update || deferredCount >= MAX_DEFERRED_UPDATE_ENTRIES
|
||||
: update ? readyUpdates() >= MAX_UPDATE_ENTRIES : entries.size() - updates >= MAX_ENTRIES) return false;
|
||||
envelope = retryEnvelope(envelope);
|
||||
String record = addRecord(server, envelope, deferred);
|
||||
Entry entry = new Entry(server, envelope, false, false, false, deferred);
|
||||
long remainingReserve = terminalRecordReserve() + terminalRecordReserve(entry);
|
||||
@@ -239,7 +238,7 @@ final class ReliableVoteDeliveryOutbox {
|
||||
try {
|
||||
if (parts.length == 3 && (ADD.equals(parts[0]) || DEFERRED.equals(parts[0]))) {
|
||||
String server = decode(parts[1]);
|
||||
JsonEnvelope envelope = retryEnvelope(JsonEnvelopeCodec.decode(decode(parts[2])));
|
||||
JsonEnvelope envelope = JsonEnvelopeCodec.decode(decode(parts[2]));
|
||||
String key = key(server, envelope);
|
||||
if (key == null) throw new IllegalArgumentException("Invalid vote envelope");
|
||||
boolean deferred = DEFERRED.equals(parts[0]);
|
||||
@@ -396,15 +395,6 @@ final class ReliableVoteDeliveryOutbox {
|
||||
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
/** A retained delivery is no longer an immediate live observation, including pre-upgrade rows. */
|
||||
private static JsonEnvelope retryEnvelope(JsonEnvelope envelope) {
|
||||
if (!VotingPluginWire.SUB_VOTE.equals(envelope.getSubChannel())
|
||||
&& !VotingPluginWire.SUB_VOTE_ONLINE.equals(envelope.getSubChannel())) return envelope;
|
||||
var copy = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
envelope.getFields().forEach(copy::put);
|
||||
return copy.put(VotingPluginWire.K_SESSION_DELIVERY_FRESH, false).build();
|
||||
}
|
||||
|
||||
private static String key(String server, JsonEnvelope envelope) {
|
||||
if (server == null || server.isBlank() || envelope == null) return null;
|
||||
String subChannel = envelope.getSubChannel();
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
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,6 +181,7 @@ 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;
|
||||
@@ -200,6 +201,7 @@ 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;
|
||||
@@ -212,7 +214,8 @@ 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) {
|
||||
boolean delayValidationKnown, long canonicalOccurrenceTime) {
|
||||
this.canonicalOccurrenceTime = canonicalOccurrenceTime;
|
||||
this.player = player;
|
||||
this.service = service;
|
||||
this.realVote = realVote;
|
||||
@@ -2253,10 +2256,7 @@ public abstract class VotingPluginProxy {
|
||||
votePartyVotes = getVoteCacheCurrentVotePartyVotes();
|
||||
|
||||
globalMessageProxyHandler = new VotingPluginGlobalMessageProxyHandler();
|
||||
registerControlEnrollmentListener(globalMessageProxyHandler);
|
||||
globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REQUEST) {
|
||||
@Override public void onReceive(JsonEnvelope message) { handleSessionFenceRequest(message); }
|
||||
});
|
||||
registerControlEnrollmentListener(globalMessageProxyHandler);
|
||||
|
||||
globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_LOGIN) {
|
||||
@Override
|
||||
@@ -2747,14 +2747,7 @@ public abstract class VotingPluginProxy {
|
||||
// encryption policy during a soft reload.
|
||||
SharedTransportEnvelopeAuthenticator multiProxyAuthenticator = getConfig().getMultiProxySupport()
|
||||
? sharedTransportAuthenticator() : sharedTransportAuthenticator;
|
||||
multiProxyHandler = new MultiProxyHandler() {
|
||||
@Override protected void onSessionFenceReply(JsonEnvelope message) {
|
||||
var reply = VotingPluginWire.readSessionFenceReply(message);
|
||||
if (reply != null && reply.request().origin().equals(getConfig().getProxyServerName())
|
||||
&& isServerValid(reply.request().server()))
|
||||
globalMessageProxyHandler.sendMessage(reply.request().server(), 0, message);
|
||||
}
|
||||
|
||||
multiProxyHandler = new MultiProxyHandler() {
|
||||
|
||||
@Override
|
||||
public void addNonVotedPlayerCache(String uuid, String player) {
|
||||
@@ -2905,9 +2898,8 @@ public abstract class VotingPluginProxy {
|
||||
@Override
|
||||
public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
|
||||
VoteTotalsSnapshot text, String uuid) {
|
||||
// Legacy callbacks have no ACK/retirement owner. Preserve their ordinary
|
||||
// delivery path rather than creating permanent multi-proxy completion files.
|
||||
vote(player, service, realVote, timeQueue, queueTime, text, uuid);
|
||||
// Origin-less forwarding cannot establish an original canonical occurrence.
|
||||
vote(player, service, realVote, timeQueue, queueTime, text, uuid, null, null, false, false, -1L);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -2930,6 +2922,14 @@ 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,6 +2966,13 @@ 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);
|
||||
@@ -2984,7 +2991,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)) {
|
||||
delayValidated, delayValidationKnown, canonicalOccurrenceTime)) {
|
||||
scheduleTimeVoteRetry();
|
||||
return;
|
||||
}
|
||||
@@ -2992,7 +2999,7 @@ public abstract class VotingPluginProxy {
|
||||
return;
|
||||
}
|
||||
retry = new MultiProxyVoteRetry(player, service, realVote, timeQueue, queueTime, totals, uuid, voteId, origin,
|
||||
delayValidated, delayValidationKnown);
|
||||
delayValidated, delayValidationKnown, canonicalOccurrenceTime);
|
||||
multiProxyVoteRetries.put(voteId, retry);
|
||||
}
|
||||
if (!retry.scheduled) attemptMultiProxyVote(retry);
|
||||
@@ -3001,7 +3008,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) {
|
||||
boolean delayValidationKnown, long canonicalOccurrenceTime) {
|
||||
if (voteId == null || player == null || service == null || uuid == null) return false;
|
||||
for (VoteTimeQueue queued : getVoteCacheHandler().getTimeChangeQueue()) {
|
||||
if (voteId.equals(queued.getVoteId())) return true;
|
||||
@@ -3009,6 +3016,7 @@ 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);
|
||||
@@ -3086,6 +3094,7 @@ 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);
|
||||
@@ -3632,6 +3641,7 @@ 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);
|
||||
@@ -3643,6 +3653,7 @@ 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);
|
||||
@@ -4074,7 +4085,7 @@ public abstract class VotingPluginProxy {
|
||||
vote.setRealVote(queuedRealVote);
|
||||
vote.setDeliveryStateDirty(true);
|
||||
}
|
||||
VoteTotalsSnapshot queuedTotals = forwardedTotals[1].isEmpty() ? null
|
||||
VoteTotalsSnapshot queuedTotals = !VoteOccurrenceMetadata.hasPayload(forwardedTotals[1]) ? null
|
||||
: VoteTotalsSnapshot.parseStorage(forwardedTotals[1]);
|
||||
QueuedVoteResult result = replayQueuedVote(vote, queuedTotals, queuedRealVote);
|
||||
if (result == QueuedVoteResult.RETRY || result == QueuedVoteResult.RETRY_NONBLOCKING) {
|
||||
@@ -6004,18 +6015,6 @@ public abstract class VotingPluginProxy {
|
||||
return sendProxyBroadcastEnvelopeNow(server, envelope);
|
||||
}
|
||||
|
||||
/** Read-only session negotiation uses existing encrypted/authenticated transports. */
|
||||
protected void handleSessionFenceRequest(JsonEnvelope message) {
|
||||
var request = VotingPluginWire.readSessionFenceRequest(message);
|
||||
if (request == null || !request.origin().isEmpty() || !isServerValid(request.server())) return;
|
||||
var fence = com.bencodez.votingplugin.core.session.ProxyVoteSequence.currentFence();
|
||||
globalMessageProxyHandler.sendMessage(request.server(), 0, VotingPluginWire.sessionFenceReply(request, fence));
|
||||
if (multiProxyHandler != null && getConfig().getMultiProxySupport()) {
|
||||
var relayed = new VotingPluginWire.SessionFenceRequest(request.player(), request.nonce(),
|
||||
request.server(), getConfig().getProxyServerName());
|
||||
multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.sessionFenceRequest(relayed));
|
||||
}
|
||||
}
|
||||
protected void handleStatusOkay(JsonEnvelope message) {
|
||||
String server = message.getFields().getOrDefault(VotingPluginWire.K_SERVER, "");
|
||||
String request = message.getFields().getOrDefault(VotingPluginWire.K_REQUEST_ID, "");
|
||||
@@ -6073,16 +6072,11 @@ public abstract class VotingPluginProxy {
|
||||
return sendVoteEnvelopeAccepted(playerServer, 1, envelope);
|
||||
}
|
||||
|
||||
/** Backend ingress cannot establish original freshness after a forwarding hop or replay. */
|
||||
private JsonEnvelope withSessionDeliveryProvenance(JsonEnvelope envelope, UUID voteId, VoteTimeQueue queuedVote) {
|
||||
MultiProxyVoteRetry forwarded = multiProxyVoteRetries.get(voteId);
|
||||
if (forwarded != null) return VotingPluginWire.unconfirmedSessionDelivery(envelope, forwarded.origin);
|
||||
if (queuedVote != null) return VotingPluginWire.unconfirmedSessionDelivery(envelope, queuedVote.getMultiProxyOrigin());
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private JsonEnvelope cachedVoteEnvelope(OfflineBungeeVote vote, boolean online, boolean broadcast,
|
||||
int num, int numberOfVotes) {
|
||||
// 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();
|
||||
@@ -6091,23 +6085,20 @@ public abstract class VotingPluginProxy {
|
||||
if (vote.isDelayValidationKnown()) {
|
||||
envelope = online
|
||||
? VotingPluginWire.voteOnline(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(),
|
||||
getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes, vote.isDelayValidated())
|
||||
: VotingPluginWire.vote(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), payload, 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(), vote.getText(), vote.getVoteId(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(),
|
||||
getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes)
|
||||
: VotingPluginWire.vote(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(),
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(),
|
||||
getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes);
|
||||
}
|
||||
// Cached delivery cannot prove fresh original ingress in the backend's clock
|
||||
// domain, even when the reward policy correctly classifies it as non-queued.
|
||||
envelope = VotingPluginWire.unconfirmedSessionDelivery(envelope, "");
|
||||
// A cache entry is only known to have passed the proxy's delay gate when
|
||||
// that decision was persisted with it. Unknown legacy entries must still
|
||||
// be checked by the receiving backend.
|
||||
@@ -6256,6 +6247,16 @@ 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);
|
||||
@@ -6280,6 +6281,7 @@ 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);
|
||||
@@ -6310,6 +6312,7 @@ 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;
|
||||
}
|
||||
@@ -6334,7 +6337,7 @@ public abstract class VotingPluginProxy {
|
||||
if (retry == null && !recovery.isVotePartyApplied() && !recovery.isTotalsApplied()
|
||||
&& recovery.isRealVote() && !recovery.isWasOnlineKnown()
|
||||
&& (recovery.getUuid() == null || recovery.getUuid().isEmpty())
|
||||
&& (recovery.getTotals() == null || recovery.getTotals().isEmpty())) return true;
|
||||
&& !VoteOccurrenceMetadata.hasPayload(recovery.getTotals())) return true;
|
||||
VoteTimeQueue state = retry == null ? recovery
|
||||
: snapshotIncomingVoteForRestart(new PendingIncomingVote(recovery.getVoteId(), recovery.getName(),
|
||||
recovery.getService(), recovery.getTime()));
|
||||
@@ -6382,6 +6385,13 @@ 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)) {
|
||||
@@ -6505,6 +6515,13 @@ 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<>()
|
||||
@@ -6574,6 +6591,7 @@ 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) {
|
||||
@@ -6614,12 +6632,14 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
retryState.totalsInput = data;
|
||||
if (queuedVote != null && queuedVote.isTotalsApplied()) {
|
||||
retryState.totals = text != null ? text : VoteTotalsSnapshot.parseStorage(queuedVote.getTotals());
|
||||
retryState.totals = text != null ? text : VoteOccurrenceMetadata.hasPayload(queuedVote.getTotals())
|
||||
? VoteTotalsSnapshot.parseStorage(queuedVote.getTotals()) : null;
|
||||
}
|
||||
retryState.player = requestPlayer;
|
||||
retryState.service = service;
|
||||
retryState.uuid = uuid;
|
||||
retryState.time = time;
|
||||
retryState.canonicalOccurrenceTime = canonicalOccurrenceTime;
|
||||
retryState.realVote = realVote;
|
||||
retryState.playerOnline = playerOnline;
|
||||
retryState.delayValidated = authoritativeDelayValidated;
|
||||
@@ -6728,6 +6748,9 @@ 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));
|
||||
@@ -6762,7 +6785,7 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
if (standaloneBroadcastState == null) {
|
||||
standaloneBroadcastState = new OfflineBungeeVote(voteId, player, uuid, service, time, realVote,
|
||||
text == null ? "" : text.toString(), false, true, proxyBroadcastTargets,
|
||||
text == null ? "" : votePayload, false, true, proxyBroadcastTargets,
|
||||
broadcastForwardedServers, !sendVotesToAllServers, Collections.emptyMap(), queuedVote == null
|
||||
? Collections.emptyMap() : queuedVote.getHttpBroadcastDeliveryIds());
|
||||
standaloneBroadcastState.setWasOnline(playerOnline);
|
||||
@@ -6810,7 +6833,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,
|
||||
text.toString(), false, playerOnline);
|
||||
votePayload, false, playerOnline);
|
||||
if (retryState.delayValidationKnown) rewardJournalOwner.setDelayValidated(authoritativeDelayValidated);
|
||||
markPendingRewardJournalTargets(rewardJournalOwner, rewardServers, retryState.deliveredRewardServers);
|
||||
retryState.rewardJournalOwner = rewardJournalOwner;
|
||||
@@ -6828,7 +6851,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, text.toString(), standaloneProxyBroadcast, playerOnline);
|
||||
realVote, votePayload, standaloneProxyBroadcast, playerOnline);
|
||||
if (retryState.delayValidationKnown) rewardState.setDelayValidated(authoritativeDelayValidated);
|
||||
retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), rewardState);
|
||||
}
|
||||
@@ -6881,12 +6904,11 @@ 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, text.toString(),
|
||||
? VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, votePayload,
|
||||
voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1,
|
||||
authoritativeDelayValidated)
|
||||
: VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, text.toString(),
|
||||
: VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, votePayload,
|
||||
voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
rewardEnvelope = withSessionDeliveryProvenance(rewardEnvelope, voteId, queuedVote);
|
||||
boolean rewardAccepted = sendVoteEnvelopeAccepted(s, 2, rewardEnvelope, pendingVote);
|
||||
if (!rewardAccepted) {
|
||||
pendingVote.setRewardDelivered(false);
|
||||
@@ -6930,10 +6952,10 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
if (pendingVote == null) {
|
||||
pendingVote = standaloneProxyBroadcast
|
||||
? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), false,
|
||||
? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, votePayload, false,
|
||||
true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(),
|
||||
standaloneBroadcastState.getHttpBroadcastDeliveryIds())
|
||||
: createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false, playerOnline);
|
||||
: createCachedRewardVote(voteId, player, uuid, service, time, realVote, votePayload, false, playerOnline);
|
||||
retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), pendingVote);
|
||||
}
|
||||
pendingVote.setWasOnline(playerOnline);
|
||||
@@ -6942,11 +6964,10 @@ public abstract class VotingPluginProxy {
|
||||
if (!rewardAccepted) {
|
||||
JsonEnvelope rewardEnvelope = retryState.delayValidationKnown
|
||||
? VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote,
|
||||
text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1,
|
||||
votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1,
|
||||
authoritativeDelayValidated)
|
||||
: VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote,
|
||||
text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
rewardEnvelope = withSessionDeliveryProvenance(rewardEnvelope, voteId, queuedVote);
|
||||
votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
rewardAccepted = sendVoteEnvelopeAccepted(server, 1, rewardEnvelope, pendingVote);
|
||||
if (rewardAccepted) {
|
||||
retryState.deliveredRewardServers.add(server);
|
||||
@@ -7007,7 +7028,7 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
if (standaloneBroadcastState == null) {
|
||||
standaloneBroadcastState = new OfflineBungeeVote(voteId, player, uuid, service, time,
|
||||
realVote, text.toString(), false, true, targets, broadcastForwardedServers,
|
||||
realVote, votePayload, false, true, targets, broadcastForwardedServers,
|
||||
true, Collections.emptyMap(), Collections.emptyMap());
|
||||
standaloneBroadcastState.setWasOnline(true);
|
||||
retryState.standaloneBroadcastState = standaloneBroadcastState;
|
||||
@@ -7033,7 +7054,7 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
|
||||
JsonEnvelope broadcast = VotingPluginWire.voteBroadcast(uuid, player, service, time,
|
||||
text == null ? "" : text.toString(), true);
|
||||
text == null ? "" : votePayload, true);
|
||||
if (method == BungeeMethod.HTTP) {
|
||||
boolean forwarded = sendStableHttpEnvelope(targetServer,
|
||||
stableLiveHttpBroadcastDeliveryId(voteId, targetServer), broadcast);
|
||||
@@ -7065,10 +7086,10 @@ public abstract class VotingPluginProxy {
|
||||
}
|
||||
}
|
||||
OfflineBungeeVote cachedReward = standaloneProxyBroadcast
|
||||
? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), false,
|
||||
? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, votePayload, false,
|
||||
true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(),
|
||||
standaloneBroadcastState.getHttpBroadcastDeliveryIds())
|
||||
: createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false, playerOnline);
|
||||
: createCachedRewardVote(voteId, player, uuid, service, time, realVote, votePayload, false, playerOnline);
|
||||
cachedReward.setWasOnline(playerOnline);
|
||||
if (retryState.delayValidationKnown) cachedReward.setDelayValidated(authoritativeDelayValidated);
|
||||
retryState.pendingOnlineRewardState = cachedReward;
|
||||
@@ -7246,6 +7267,10 @@ 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.
|
||||
@@ -7295,12 +7320,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, totals == null ? "" : totals.toString(), findLiveVoteId(retryState), false, false,
|
||||
false, realVote, forwardingTotals, findLiveVoteId(retryState), false, false,
|
||||
1, 1), legacyRecipients);
|
||||
}
|
||||
if (outbox == null) {
|
||||
outbox = new VoteTimeQueue(null, player, service, time, false, Collections.emptySet(),
|
||||
Collections.emptySet(), totals == null ? "" : totals.toString(), true, uuid);
|
||||
Collections.emptySet(), forwardingTotals, 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));
|
||||
@@ -7355,7 +7380,7 @@ public abstract class VotingPluginProxy {
|
||||
outbox.setDeliveryStateDirty(true);
|
||||
if (!persistTimeVoteDelivery(outbox)) return false;
|
||||
if (!multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.vote(player, uuid, service, time,
|
||||
false, realVote, totals == null ? "" : totals.toString(), outbox.getVoteId(), false, false, 1, 1),
|
||||
false, realVote, forwardingTotals, outbox.getVoteId(), false, false, 1, 1),
|
||||
legacyPending)) {
|
||||
outbox.setMultiProxyLegacyPendingRecipients(legacyPending);
|
||||
outbox.setDeliveryStateDirty(true);
|
||||
@@ -7385,10 +7410,14 @@ 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(), totals == null ? "" : totals.toString(), true, uuid);
|
||||
Collections.emptySet(), forwardingTotals, true, uuid);
|
||||
outbox.setVoteId(findLiveVoteId(retryState));
|
||||
if (outbox.getVoteId() == null) return null;
|
||||
retryState.queuedVote = outbox;
|
||||
|
||||
@@ -34,60 +34,12 @@ public final class VotingPluginWire {
|
||||
}
|
||||
|
||||
public static final int SCHEMA_VERSION = 1;
|
||||
|
||||
/** Additive request/reply negotiation; old peers simply ignore these subchannels. */
|
||||
public record SessionFenceRequest(UUID player, UUID nonce, String server, String origin) { }
|
||||
public record SessionFenceReply(SessionFenceRequest request,
|
||||
com.bencodez.votingplugin.core.session.ProxyVoteSequence.Fence fence) { }
|
||||
public static JsonEnvelope sessionFenceRequest(SessionFenceRequest request) {
|
||||
return sessionFenceBuilder(SUB_VOTE_SESSION_FENCE_REQUEST, request).build();
|
||||
}
|
||||
public static JsonEnvelope sessionFenceReply(SessionFenceRequest request,
|
||||
com.bencodez.votingplugin.core.session.ProxyVoteSequence.Fence fence) {
|
||||
return sessionFenceBuilder(SUB_VOTE_SESSION_FENCE_REPLY, request)
|
||||
.put(K_SESSION_INCARNATION, fence.incarnation().toString())
|
||||
.put(K_SESSION_WATERMARK, Long.toString(fence.watermark())).build();
|
||||
}
|
||||
private static JsonEnvelope.Builder sessionFenceBuilder(String sub, SessionFenceRequest request) {
|
||||
return base(sub).put(K_SESSION_FENCE_VERSION, 1).put(K_UUID, request.player().toString())
|
||||
.put(K_SESSION_NONCE, request.nonce().toString()).put(K_SERVER, request.server())
|
||||
.put(K_SESSION_ORIGIN, request.origin());
|
||||
}
|
||||
public static SessionFenceRequest readSessionFenceRequest(JsonEnvelope message) {
|
||||
if (message == null || message.getSchema() != SCHEMA_VERSION
|
||||
|| !(SUB_VOTE_SESSION_FENCE_REQUEST.equals(message.getSubChannel())
|
||||
|| SUB_VOTE_SESSION_FENCE_REPLY.equals(message.getSubChannel()))
|
||||
|| !"1".equals(message.getFields().get(K_SESSION_FENCE_VERSION))) return null;
|
||||
var fields = message.getFields();
|
||||
try {
|
||||
String server = fields.getOrDefault(K_SERVER, "");
|
||||
String origin = fields.getOrDefault(K_SESSION_ORIGIN, "");
|
||||
if (server.isBlank() || server.length() > 128 || origin.length() > 128) return null;
|
||||
return new SessionFenceRequest(UUID.fromString(fields.get(K_UUID)),
|
||||
UUID.fromString(fields.get(K_SESSION_NONCE)), server, origin);
|
||||
} catch (IllegalArgumentException | NullPointerException invalid) { return null; }
|
||||
}
|
||||
public static SessionFenceReply readSessionFenceReply(JsonEnvelope message) {
|
||||
SessionFenceRequest request = readSessionFenceRequest(message);
|
||||
if (request == null || !SUB_VOTE_SESSION_FENCE_REPLY.equals(message.getSubChannel())) return null;
|
||||
try {
|
||||
return new SessionFenceReply(request, new com.bencodez.votingplugin.core.session.ProxyVoteSequence.Fence(
|
||||
UUID.fromString(message.getFields().get(K_SESSION_INCARNATION)),
|
||||
Long.parseLong(message.getFields().get(K_SESSION_WATERMARK))));
|
||||
} catch (IllegalArgumentException | NullPointerException invalid) { return null; }
|
||||
}
|
||||
public static final String K_CANONICAL_OCCURRENCE_TIME = "canonicalOccurrenceTime";
|
||||
|
||||
// =========================
|
||||
// Subchannels (canonical)
|
||||
// =========================
|
||||
public static final String SUB_VOTE_SESSION_FENCE_REQUEST = "VoteSessionFenceRequest";
|
||||
public static final String SUB_VOTE_SESSION_FENCE_REPLY = "VoteSessionFenceReply";
|
||||
public static final String K_SESSION_FENCE_VERSION = "sessionFenceVersion";
|
||||
public static final String K_SESSION_NONCE = "sessionNonce";
|
||||
public static final String K_SESSION_ORIGIN = "sessionOrigin";
|
||||
public static final String K_SESSION_INCARNATION = "sessionIncarnation";
|
||||
public static final String K_SESSION_WATERMARK = "sessionWatermark";
|
||||
public static final String SUB_VOTE = "Vote";
|
||||
public static final String SUB_VOTE = "Vote";
|
||||
public static final String SUB_VOTE_ONLINE = "VoteOnline";
|
||||
public static final String SUB_VOTE_UPDATE = "VoteUpdate";
|
||||
/** Internal queue metadata; never a backend delivery target. */
|
||||
@@ -170,8 +122,6 @@ public final class VotingPluginWire {
|
||||
public static final String K_NUMBER_OF_VOTES = "numberOfVotes";
|
||||
public static final String K_DELAY_VALIDATED = "delayValidated";
|
||||
public static final String K_QUEUED_DELIVERY = "queuedDelivery";
|
||||
/** Guide-only delivery hint; cross-node occurrence order remains unconfirmed. */
|
||||
public static final String K_SESSION_DELIVERY_FRESH = "sessionDeliveryFresh";
|
||||
public static final String K_VOTE_DELIVERY_ACK_VERSION = "voteDeliveryAckVersion";
|
||||
public static final String K_AUTHENTICATED_SOCKET_VOTE_DELIVERY_VERSION =
|
||||
"authenticatedSocketVoteDeliveryVersion";
|
||||
@@ -233,13 +183,16 @@ 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) {
|
||||
return base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service))
|
||||
JsonEnvelope.Builder builder = 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_SESSION_DELIVERY_FRESH, false)
|
||||
.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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,12 +218,15 @@ 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) {
|
||||
return base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service))
|
||||
JsonEnvelope.Builder builder = 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}. */
|
||||
@@ -303,19 +259,6 @@ public final class VotingPluginWire {
|
||||
return builder.put(K_QUEUED_DELIVERY, true).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retains origin provenance for guide observations without changing reward queue
|
||||
* classification. An empty origin explicitly means the original ingress is unknown
|
||||
* (for example a cached delivery); it does not claim a reliable multi-proxy sender.
|
||||
*/
|
||||
public static JsonEnvelope unconfirmedSessionDelivery(JsonEnvelope envelope, String origin) {
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
for (Map.Entry<String, String> field : envelope.getFields().entrySet()) {
|
||||
builder.put(field.getKey(), field.getValue());
|
||||
}
|
||||
return builder.put(K_MULTI_PROXY_ORIGIN, safe(origin)).put(K_SESSION_DELIVERY_FRESH, false).build();
|
||||
}
|
||||
|
||||
/** Removes the additive queue marker for cache rows created by pre-marker proxies. */
|
||||
public static JsonEnvelope legacyUnclassifiedDelivery(JsonEnvelope envelope) {
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
@@ -702,6 +645,7 @@ 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;
|
||||
@@ -723,12 +667,13 @@ 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) {
|
||||
boolean broadcast, int num, int numberOfVotes, long canonicalOccurrenceTime) {
|
||||
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;
|
||||
@@ -759,6 +704,13 @@ 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);
|
||||
@@ -775,7 +727,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);
|
||||
broadcast, num, numberOfVotes, occurrence);
|
||||
}
|
||||
|
||||
public static final class VoteDelayRejected {
|
||||
|
||||
+14
-2
@@ -614,6 +614,18 @@ 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);
|
||||
@@ -629,7 +641,7 @@ public class VotingPluginBungee extends Plugin implements Listener {
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
return;
|
||||
}
|
||||
pending = new PendingIncomingVote(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId(), player, service, System.currentTimeMillis());
|
||||
pending = new PendingIncomingVote(UUID.randomUUID(), player, service, System.currentTimeMillis());
|
||||
if (votingPluginProxy == null || !votingPluginProxy.retainIncomingVoteForRestart(pending)) {
|
||||
getLogger().severe("Pending vote admission is full and durable overflow failed; vote was not accepted for "
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
@@ -672,7 +684,7 @@ public class VotingPluginBungee extends Plugin implements Listener {
|
||||
try {
|
||||
IncomingVoteRuntimeResult result;
|
||||
synchronized (reloadLock) {
|
||||
result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId());
|
||||
result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId(), pending.getAcceptedAt());
|
||||
if (result == IncomingVoteRuntimeResult.PROCESSED) pendingIncomingVotes.complete(pending);
|
||||
}
|
||||
if (result == IncomingVoteRuntimeResult.PROCESSED) {
|
||||
|
||||
Vendored
+2
@@ -132,6 +132,8 @@ 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,6 +97,8 @@ 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") + " = ?;";
|
||||
|
||||
|
||||
+15
-17
@@ -444,6 +444,14 @@ 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.
|
||||
@@ -958,8 +966,6 @@ public abstract class MultiProxyHandler {
|
||||
&& getRedisPrefix() != null && !getRedisPrefix().isEmpty();
|
||||
}
|
||||
|
||||
protected void onSessionFenceReply(JsonEnvelope message) { }
|
||||
|
||||
private synchronized void loadMultiProxyRedisListener(String channel) {
|
||||
if (!registeredRedisChannels.add(channel)) return;
|
||||
try {
|
||||
@@ -1163,18 +1169,6 @@ public abstract class MultiProxyHandler {
|
||||
f = new HashMap<>();
|
||||
}
|
||||
|
||||
if (sub.equals(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REQUEST)) {
|
||||
var request = VotingPluginWire.readSessionFenceRequest(envelope);
|
||||
if (request != null && configuredMultiProxyRecipientNames().containsKey(request.origin().toLowerCase(Locale.ROOT)))
|
||||
sendMultiProxyEnvelopeAccepted(VotingPluginWire.sessionFenceReply(request,
|
||||
com.bencodez.votingplugin.core.session.ProxyVoteSequence.currentFence()), List.of(request.origin()));
|
||||
return;
|
||||
}
|
||||
if (sub.equals(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REPLY)) {
|
||||
var reply = VotingPluginWire.readSessionFenceReply(envelope);
|
||||
if (reply != null && reply.request().origin().equals(getMultiProxyServerName())) onSessionFenceReply(envelope);
|
||||
return;
|
||||
}
|
||||
if (sub.equalsIgnoreCase(VotingPluginWire.SUB_STATUS)) {
|
||||
logInfo("Multi-proxy status message received");
|
||||
return;
|
||||
@@ -1290,10 +1284,14 @@ public abstract class MultiProxyHandler {
|
||||
if (origin.isBlank()) {
|
||||
triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid);
|
||||
} else {
|
||||
// Preserve the receiver-local time used by ordinary backend cooldowns.
|
||||
// Guide confirmation rejects forwarded ingress independently of this time.
|
||||
// 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);
|
||||
wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown,
|
||||
occurrenceTime);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
+14
-2
@@ -509,6 +509,18 @@ 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);
|
||||
@@ -524,7 +536,7 @@ public class VotingPluginVelocity {
|
||||
MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
return;
|
||||
}
|
||||
pending = new PendingIncomingVote(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId(), player, service, System.currentTimeMillis());
|
||||
pending = new PendingIncomingVote(UUID.randomUUID(), player, service, System.currentTimeMillis());
|
||||
if (votingPluginProxy == null || !votingPluginProxy.retainIncomingVoteForRestart(pending)) {
|
||||
logger.error("Pending vote admission is full and durable overflow failed; vote was not accepted for {}",
|
||||
MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
@@ -558,7 +570,7 @@ public class VotingPluginVelocity {
|
||||
try {
|
||||
IncomingVoteRuntimeResult result;
|
||||
synchronized (reloadLock) {
|
||||
result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId());
|
||||
result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId(), pending.getAcceptedAt());
|
||||
if (result == IncomingVoteRuntimeResult.PROCESSED) pendingIncomingVotes.complete(pending);
|
||||
}
|
||||
if (result == IncomingVoteRuntimeResult.PROCESSED) {
|
||||
|
||||
+1
-2
@@ -123,8 +123,7 @@ public class ServiceSiteHandler {
|
||||
|
||||
public void saveToServerData() {
|
||||
List<String> out = toListFormat(sitesRef.get());
|
||||
plugin.getServerData().getData().set(SERVERDATA_PATH, out);
|
||||
plugin.getServerData().saveData();
|
||||
plugin.getServerData().setStringList(SERVERDATA_PATH, out);
|
||||
}
|
||||
|
||||
public void refreshFromWebOnce(boolean logMore) {
|
||||
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
package com.bencodez.votingplugin.session;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import io.github.projectunified.unidialog.core.payload.DialogPayload;
|
||||
|
||||
/** Lazy host adapter over already bundled UniDialog; owns its own callbacks and event hook. */
|
||||
final class GuidedSessionDialogs implements SessionDialogView {
|
||||
private record Invocation(BiConsumer<UUID, String> callback, UUID owner, String action) { }
|
||||
private final ThreadLocal<List<Invocation>> pendingCallbacks = new ThreadLocal<>();
|
||||
private static final String NAMESPACE = "votingplugin_session";
|
||||
private record Platform(String manager, Class<?> event, boolean paper) {}
|
||||
private final Object manager;
|
||||
private final Platform platform;
|
||||
|
||||
private static Platform probe() throws ReflectiveOperationException {
|
||||
ClassLoader loader = GuidedSessionDialogs.class.getClassLoader();
|
||||
try {
|
||||
Class<?> dialog = Class.forName("io.papermc.paper.dialog.Dialog", false, loader);
|
||||
Class<?> event = Class.forName("io.papermc.paper.event.player.PlayerCustomClickEvent", false, loader);
|
||||
event.getMethod("getIdentifier");
|
||||
event.getMethod("getCommonConnection");
|
||||
Class<?> dialogLike = Class.forName("net.kyori.adventure.dialog.DialogLike", false, loader);
|
||||
Player.class.getMethod("showDialog", dialogLike);
|
||||
Class.forName("io.papermc.paper.registry.data.dialog.ActionButton", false, loader);
|
||||
if (!dialogLike.isAssignableFrom(dialog)) throw new NoSuchMethodException("Incompatible Paper dialog API");
|
||||
return new Platform("io.github.projectunified.unidialog.paper.PaperDialogManager", event, true);
|
||||
} catch (ClassNotFoundException | NoSuchMethodException noPaper) {
|
||||
Class<?> dialog = Class.forName("net.md_5.bungee.api.dialog.Dialog", false, loader);
|
||||
Class<?> event = Class.forName("org.bukkit.event.player.PlayerCustomClickEvent", false, loader);
|
||||
event.getMethod("getId");
|
||||
event.getMethod("getPlayer");
|
||||
Player.class.getMethod("showDialog", dialog);
|
||||
return new Platform("io.github.projectunified.unidialog.spigot.SpigotDialogManager", event, false);
|
||||
}
|
||||
}
|
||||
static boolean supported() {
|
||||
try { probe(); return true; }
|
||||
catch (ReflectiveOperationException | LinkageError unavailable) { return false; }
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
GuidedSessionDialogs(VotingPluginMain plugin) {
|
||||
try {
|
||||
platform = probe();
|
||||
Class<?> type = Class.forName(platform.manager(), true, getClass().getClassLoader());
|
||||
manager = type.getConstructor(Plugin.class, String.class).newInstance(plugin, NAMESPACE);
|
||||
Method dispatch = type.getMethod("onCustomClick", platform.event());
|
||||
type.getMethod("registerCustomAction", String.class, String.class, Consumer.class);
|
||||
type.getMethod("unregisterCustomAction", String.class, String.class);
|
||||
// Do not register the library listener: all its HashMap accesses use this private lock.
|
||||
plugin.getServer().getPluginManager().registerEvent((Class<? extends Event>) platform.event(),
|
||||
new Listener() {}, EventPriority.NORMAL, (listener, event) -> {
|
||||
if (!platform.event().isInstance(event)) return;
|
||||
List<Invocation> callbacks = new ArrayList<>();
|
||||
pendingCallbacks.set(callbacks);
|
||||
try {
|
||||
synchronized (manager) { dispatch.invoke(manager, event); }
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
plugin.getLogger().warning("Voting session dialog callback failed: " + failure.getClass().getSimpleName());
|
||||
} finally {
|
||||
pendingCallbacks.remove();
|
||||
}
|
||||
// Controller callbacks can acquire their own locks or schedule work.
|
||||
// Never invoke them while holding the library manager's registry lock.
|
||||
for (Invocation callback : callbacks)
|
||||
callback.callback().accept(callback.owner(), callback.action());
|
||||
}, plugin, false);
|
||||
} catch (ReflectiveOperationException | LinkageError failure) {
|
||||
throw new IllegalStateException("Native dialog API unavailable", failure);
|
||||
}
|
||||
}
|
||||
public Runnable show(Player player, String title, String body, List<Button> buttons,
|
||||
BiConsumer<UUID, String> action) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
synchronized (manager) {
|
||||
try {
|
||||
Object dialog = call(manager, "createMultiActionDialog", new Class<?>[0]);
|
||||
call(dialog, "title", new Class<?>[] {String.class}, title);
|
||||
call(dialog, "canCloseWithEscape", new Class<?>[] {boolean.class}, true);
|
||||
call(dialog, "pause", new Class<?>[] {boolean.class}, false);
|
||||
call(dialog, "afterAction", new Class<?>[] {io.github.projectunified.unidialog.core.dialog.Dialog.AfterAction.class},
|
||||
io.github.projectunified.unidialog.core.dialog.Dialog.AfterAction.CLOSE);
|
||||
call(dialog, "columns", new Class<?>[] {int.class}, 1);
|
||||
call(dialog, "body", new Class<?>[] {Consumer.class}, (Consumer<Object>) builder -> {
|
||||
Object text = call(builder, "text", new Class<?>[0]);
|
||||
call(text, "text", new Class<?>[] {String.class}, body);
|
||||
});
|
||||
for (var button : buttons) {
|
||||
String id = UUID.randomUUID().toString();
|
||||
if (button.action() != null) {
|
||||
Consumer<DialogPayload> callback = payload -> {
|
||||
List<Invocation> queued = pendingCallbacks.get();
|
||||
if (queued != null && queued.size() < 16)
|
||||
queued.add(new Invocation(action, payload.owner(), button.action()));
|
||||
};
|
||||
call(manager, "registerCustomAction", new Class<?>[] {String.class, String.class, Consumer.class},
|
||||
NAMESPACE, id, callback);
|
||||
ids.add(id);
|
||||
}
|
||||
call(dialog, "action", new Class<?>[] {Consumer.class}, (Consumer<Object>) builder -> {
|
||||
call(builder, "label", new Class<?>[] {String.class}, button.label());
|
||||
call(builder, "tooltip", new Class<?>[] {String.class}, button.tooltip());
|
||||
if (button.url() != null) call(builder, "openUrl", new Class<?>[] {String.class}, button.url());
|
||||
else call(builder, "dynamicCustom", new Class<?>[] {String.class, String.class}, NAMESPACE, id);
|
||||
});
|
||||
}
|
||||
Object opener = call(dialog, "opener", new Class<?>[0]);
|
||||
Class<?> audience = platform.paper()
|
||||
? Class.forName("net.kyori.adventure.audience.Audience", false, getClass().getClassLoader()) : Player.class;
|
||||
call(opener, "open", new Class<?>[] {audience}, player);
|
||||
} catch (RuntimeException | LinkageError | ClassNotFoundException failure) {
|
||||
release(ids);
|
||||
throw new IllegalStateException("Native dialog could not open", failure);
|
||||
}
|
||||
}
|
||||
List<String> owned = List.copyOf(ids);
|
||||
return () -> { synchronized (manager) { release(owned); } };
|
||||
}
|
||||
private void release(List<String> ids) {
|
||||
for (String id : ids) call(manager, "unregisterCustomAction", new Class<?>[] {String.class, String.class}, NAMESPACE, id);
|
||||
}
|
||||
private static Object call(Object target, String name, Class<?>[] types, Object... arguments) {
|
||||
try { return target.getClass().getMethod(name, types).invoke(target, arguments); }
|
||||
catch (ReflectiveOperationException failure) { throw new IllegalStateException("Native dialog method unavailable: " + name, failure); }
|
||||
}
|
||||
}
|
||||
-429
@@ -1,429 +0,0 @@
|
||||
package com.bencodez.votingplugin.session;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.core.session.GuidedVoteSession;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import net.md_5.bungee.api.chat.ClickEvent;
|
||||
import net.md_5.bungee.api.chat.TextComponent;
|
||||
|
||||
/** Observation-only native guidance with a clear chat fallback on older hosts. */
|
||||
public final class GuidedVotingSessions implements Listener {
|
||||
private final VotingPluginMain plugin;
|
||||
private final Map<UUID, GuidedVoteSession> sessions = new LinkedHashMap<>();
|
||||
private final Map<UUID, Long> requests = new LinkedHashMap<>();
|
||||
private record EarlyReceipt(UUID storageId, String site, UUID occurrence, long time, boolean observed, boolean proxy) { }
|
||||
private final Map<UUID, UUID> storageIds = new LinkedHashMap<>();
|
||||
private final Map<UUID, String> playerNames = new LinkedHashMap<>();
|
||||
private final Map<UUID, Player> owners = new LinkedHashMap<>();
|
||||
private final Map<UUID, Map<String, List<EarlyReceipt>>> early = new LinkedHashMap<>();
|
||||
private record ProxyRequest(UUID nonce, String server, long startedNanos, boolean sent) { }
|
||||
private final Map<UUID, ProxyRequest> proxyRequests = new LinkedHashMap<>();
|
||||
private long lifecycle;
|
||||
private final java.util.Set<Long> outstanding = new java.util.HashSet<>();
|
||||
private final Map<UUID, Long> pending = new LinkedHashMap<>();
|
||||
private long generation;
|
||||
private final java.util.function.Supplier<SessionDialogView> dialogFactory;
|
||||
private SessionDialogView dialogView;
|
||||
private boolean dialogChecked;
|
||||
private record DialogRegistration(UUID nonce, Runnable cleanup) { }
|
||||
private java.util.concurrent.ScheduledFuture<?> dialogCleanup;
|
||||
private final Map<UUID, DialogRegistration> dialogs = new LinkedHashMap<>();
|
||||
private final java.util.Set<UUID> automaticRefresh = new java.util.HashSet<>();
|
||||
|
||||
public GuidedVotingSessions(VotingPluginMain plugin) {
|
||||
this(plugin, () -> GuidedSessionDialogs.supported() ? new GuidedSessionDialogs(plugin) : null);
|
||||
}
|
||||
GuidedVotingSessions(VotingPluginMain plugin, java.util.function.Supplier<SessionDialogView> factory) {
|
||||
this.plugin = plugin; this.dialogFactory = factory;
|
||||
}
|
||||
public synchronized void clear() { for (UUID owner : List.copyOf(dialogs.keySet())) retireDialog(owner); if (dialogCleanup != null) { dialogCleanup.cancel(false); dialogCleanup = null; } automaticRefresh.clear(); sessions.clear(); proxyRequests.clear(); requests.clear(); storageIds.clear(); playerNames.clear(); owners.clear(); early.clear(); pending.clear(); generation++; lifecycle++; }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public synchronized void credited(PlayerPostVoteEvent event) {
|
||||
if (event.getVoteSite() == null || !event.isRealVote() || event.isCancelled()
|
||||
|| event.getVoteUUID() == null || event.getUuid() == null) return;
|
||||
boolean proxy = event.isProxySessionDelivery() || event.isBungee();
|
||||
// Proxy transport flags and clocks prove no original order; negotiated source fences do.
|
||||
if (proxy ? !event.isProxySessionRealVote() : event.isUnconfirmedLocalSessionDelivery()) return;
|
||||
boolean observed = event.isLiveLocalSessionDelivery() && event.getBackendObservationOrder() != 0;
|
||||
long time = observed ? event.getBackendObservationOrder() : event.getVoteTime();
|
||||
expire(System.currentTimeMillis());
|
||||
String key = event.getVoteSite().getKey();
|
||||
for (var entry : sessions.entrySet()) {
|
||||
UUID owner = entry.getKey(); GuidedVoteSession session = entry.getValue();
|
||||
if (event.getUuid().equals(storageIds.get(owner))) {
|
||||
var before = session.view("");
|
||||
if (proxy) session.acceptedProxy(key, event.getVoteUUID());
|
||||
else if (observed) session.acceptedObserved(key, event.getVoteUUID(), time);
|
||||
else session.accepted(key, event.getVoteUUID(), time, true, false);
|
||||
if (confirmedTarget(before, session.view(""))) refresh(owner, session);
|
||||
} else if (!storageIds.containsKey(owner) && session.candidate(key)
|
||||
&& (proxy || observed || time > session.started()) && event.getPlayerName() != null
|
||||
&& event.getPlayerName().equalsIgnoreCase(playerNames.get(owner))) {
|
||||
// Name only routes a bounded provisional receipt. Resolved storage UUID must
|
||||
// match before it can confirm progress; no player/entity access occurs here.
|
||||
// A failed lookup releases pending work, but this active guide still owns
|
||||
// its bounded provisional receipts until identity resolution or expiration.
|
||||
var receipts = early.computeIfAbsent(owner, ignored -> new LinkedHashMap<>())
|
||||
.computeIfAbsent(key, ignored -> new ArrayList<>());
|
||||
receipts.removeIf(receipt -> receipt.occurrence().equals(event.getVoteUUID()));
|
||||
if (receipts.size() >= 4) receipts.remove(0);
|
||||
receipts.add(new EarlyReceipt(event.getUuid(), key, event.getVoteUUID(), time, observed, proxy));
|
||||
}
|
||||
}
|
||||
// Never execute rewards from a notification; a newly accepted occurrence may refresh the guide.
|
||||
}
|
||||
|
||||
/** Recheck current eligibility on the existing worker before reopening a credited guide. */
|
||||
private void refresh(UUID owner, GuidedVoteSession session) {
|
||||
if (session.view("").finished()) return;
|
||||
automaticRefresh.add(owner);
|
||||
Player player = owners.get(owner);
|
||||
if (player == null) return;
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) {
|
||||
expire(System.currentTimeMillis());
|
||||
if (sessions.get(owner) != session || owners.get(owner) != player || session.view("").finished()) return;
|
||||
// A current worker owns a pending refresh; its final owner callback
|
||||
// consumes the flag and samples the latest confirmed progress.
|
||||
if (!pending.containsKey(owner)) prepare(player, "automatic");
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
}
|
||||
|
||||
/** Transport callback: bounded identities only; no Bukkit, user storage or reward execution. */
|
||||
public synchronized void proxyFence(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope message) {
|
||||
var reply = com.bencodez.votingplugin.proxy.VotingPluginWire.readSessionFenceReply(message);
|
||||
if (reply == null) return;
|
||||
expire(System.currentTimeMillis());
|
||||
UUID owner = reply.request().player();
|
||||
ProxyRequest request = proxyRequests.get(owner);
|
||||
GuidedVoteSession session = sessions.get(owner);
|
||||
if (request == null || !request.sent() || session == null || !request.nonce().equals(reply.request().nonce())
|
||||
|| !request.server().equals(reply.request().server())
|
||||
|| System.nanoTime() - request.startedNanos() > java.util.concurrent.TimeUnit.SECONDS.toNanos(30)) return;
|
||||
var before = session.view("");
|
||||
session.bindProxyFence(reply.fence());
|
||||
if (confirmedTarget(before, session.view(""))) refresh(owner, session);
|
||||
}
|
||||
|
||||
private static boolean confirmedTarget(GuidedVoteSession.View before, GuidedVoteSession.View after) {
|
||||
return before.current() != null && after.current() != null
|
||||
&& before.current().site().key().equals(after.current().site().key())
|
||||
&& before.current().status() != GuidedVoteSession.Status.RECEIVED
|
||||
&& after.current().status() == GuidedVoteSession.Status.RECEIVED;
|
||||
}
|
||||
|
||||
/** Capture a registration's lifecycle before the command framework queues asynchronous execution. */
|
||||
public synchronized java.util.function.Consumer<Player> commandHandler(String action) {
|
||||
long epoch = lifecycle;
|
||||
return player -> BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) { if (epoch == lifecycle) prepare(player, action); }
|
||||
}, () -> { }, () -> { });
|
||||
}
|
||||
|
||||
public void command(Player player, String action) {
|
||||
commandHandler(action).accept(player);
|
||||
}
|
||||
private void prepare(Player player, String action) {
|
||||
if (!player.isOnline() || plugin.getServer().getPlayer(player.getUniqueId()) != player) return;
|
||||
if (!plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)) {
|
||||
player.sendMessage("Guided voting sessions are disabled by this server."); return;
|
||||
}
|
||||
if (!plugin.getCommandLoader().canUseGuidedSession(player)) return;
|
||||
UUID uuid = player.getUniqueId();
|
||||
UUID storageUuid = VotingPluginMain.placeholderStorageUuid(player, plugin.getOptions().isOnlineMode());
|
||||
String name = player.getName();
|
||||
List<VoteSite> visible = new ArrayList<>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) {
|
||||
if (site.isEnabled() && !site.isHidden() && (site.getPermissionToView().isEmpty()
|
||||
|| player.hasPermission(site.getPermissionToView()))) visible.add(site);
|
||||
if (visible.size() >= 100) break;
|
||||
}
|
||||
GuidedVoteSession session;
|
||||
boolean creating;
|
||||
long request;
|
||||
com.bencodez.votingplugin.proxy.VotingPluginWire.SessionFenceRequest proxyRequest;
|
||||
synchronized (this) {
|
||||
long now = System.currentTimeMillis();
|
||||
expire(now);
|
||||
if (action.equals("automatic") && !sessions.containsKey(uuid)) return;
|
||||
if (pending.containsKey(uuid) || outstanding.size() >= 64) {
|
||||
player.sendMessage("Voting status is already being checked or busy. Please try again shortly."); return;
|
||||
}
|
||||
if (!sessions.containsKey(uuid) && sessions.size() >= 2048) {
|
||||
player.sendMessage("Voting sessions are busy. Please try again later."); return;
|
||||
}
|
||||
if (action.equals("restart")) { retireDialog(uuid); automaticRefresh.remove(uuid); sessions.remove(uuid); storageIds.remove(uuid); early.remove(uuid); proxyRequests.remove(uuid); }
|
||||
creating = !sessions.containsKey(uuid);
|
||||
session = sessions.computeIfAbsent(uuid, ignored -> new GuidedVoteSession(now));
|
||||
proxyRequest = null;
|
||||
if (creating && plugin.getBungeeSettings().isUseBungeecoord()) {
|
||||
var fenceRequest = new ProxyRequest(UUID.randomUUID(), plugin.getOptions().getServer(), 0, false);
|
||||
proxyRequests.put(uuid, fenceRequest);
|
||||
proxyRequest = new com.bencodez.votingplugin.proxy.VotingPluginWire.SessionFenceRequest(
|
||||
uuid, fenceRequest.nonce(), fenceRequest.server(), "");
|
||||
}
|
||||
session.bindCandidates(visible.stream().map(site -> new GuidedVoteSession.Site(site.getKey(),
|
||||
site.getDisplayName(), site.getVoteURL(true), false, 0)).toList());
|
||||
playerNames.put(uuid, name);
|
||||
owners.put(uuid, player);
|
||||
request = ++generation;
|
||||
requests.put(uuid, request);
|
||||
pending.put(uuid, request);
|
||||
outstanding.add(request);
|
||||
}
|
||||
final var requestToSend = proxyRequest;
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
synchronized (this) { if (!current(uuid, session, request)) return; }
|
||||
if (requestToSend != null) {
|
||||
var handler = plugin.getBackendProxyHandler();
|
||||
if (handler != null && handler.getGlobalMessageHandler() != null) {
|
||||
synchronized (this) {
|
||||
if (!current(uuid, session, request)) return;
|
||||
var queued = proxyRequests.get(uuid);
|
||||
if (queued == null || !queued.nonce().equals(requestToSend.nonce())) return;
|
||||
// Queue wait is not reply time. Publish before sending so an
|
||||
// immediate transport reply can bind this exact live request.
|
||||
proxyRequests.put(uuid, new ProxyRequest(queued.nonce(), queued.server(), System.nanoTime(), true));
|
||||
}
|
||||
handler.getGlobalMessageHandler().sendMessage(
|
||||
com.bencodez.votingplugin.proxy.VotingPluginWire.sessionFenceRequest(requestToSend));
|
||||
}
|
||||
}
|
||||
var currentSites = new java.util.HashMap<String, VoteSite>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) currentSites.put(site.getKey(), site);
|
||||
var sampledSites = new java.util.HashMap<String, VoteSite>();
|
||||
var user = plugin.getVotingPluginUserManager().getVotingPluginUser(storageUuid, name);
|
||||
String storedName = user.getPlayerName();
|
||||
String voteName = storedName == null ? name : storedName;
|
||||
var lastVotes = new java.util.HashMap<String, Long>();
|
||||
user.getLastVotes().forEach((site, time) -> lastVotes.put(site.getKey(), time));
|
||||
List<GuidedVoteSession.Site> snapshots = new ArrayList<>();
|
||||
for (VoteSite original : visible) {
|
||||
VoteSite site = currentSites.get(original.getKey());
|
||||
if (site == null || !site.isEnabled() || site.isHidden()) continue;
|
||||
sampledSites.put(site.getKey(), site);
|
||||
snapshots.add(new GuidedVoteSession.Site(site.getKey(), site.getDisplayName(),
|
||||
com.bencodez.advancedcore.api.messages.PlaceholderUtils.replacePlaceHolder(site.getVoteURL(true),
|
||||
"player", voteName),
|
||||
user.canVoteSite(site, lastVotes.getOrDefault(site.getKey(), 0L)),
|
||||
plugin.getBungeeSettings().isUseBungeecoord() ? 0 : lastVotes.getOrDefault(site.getKey(), 0L)));
|
||||
}
|
||||
synchronized (this) {
|
||||
if (!current(uuid, session, request)) return;
|
||||
UUID storageId = user.getJavaUUID();
|
||||
storageIds.put(uuid, storageId);
|
||||
var provisional = early.remove(uuid);
|
||||
if (provisional != null) for (var receipts : provisional.values()) for (var receipt : receipts) {
|
||||
if (receipt.storageId().equals(storageId)) {
|
||||
if (receipt.proxy()) session.acceptedProxy(receipt.site(), receipt.occurrence());
|
||||
else if (receipt.observed()) session.acceptedObserved(receipt.site(), receipt.occurrence(), receipt.time());
|
||||
else session.accepted(receipt.site(), receipt.occurrence(), receipt.time(), true, false);
|
||||
}
|
||||
}
|
||||
session.refresh(snapshots);
|
||||
// Storage sampling is complete before an owner callback can render.
|
||||
// A newly credited vote must be able to prepare its successor even
|
||||
// when this callback completes before the worker enters finally.
|
||||
pending.remove(uuid, request); outstanding.remove(request);
|
||||
}
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) { if (!current(uuid, session, request)) return; }
|
||||
if (player.isOnline() && plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)
|
||||
&& plugin.getCommandLoader().canUseGuidedSession(player)) {
|
||||
synchronized (this) {
|
||||
if (!current(uuid, session, request)) return;
|
||||
boolean automatic = automaticRefresh.remove(uuid) || action.equals("automatic");
|
||||
if (action.equals("automatic") && session.view("").finished()) return;
|
||||
// Visibility may change after the worker's storage sample.
|
||||
// Refresh from owner-validated immutable snapshots before
|
||||
// choosing the next target; no storage is accessed here.
|
||||
var ownerVisible = visibleView(player, session.view(""), sampledSites);
|
||||
session.refresh(ownerVisible.entries().stream().map(GuidedVoteSession.Entry::site).toList());
|
||||
if (automatic || action.isEmpty()) session.selectNextAwaiting();
|
||||
var view = session.view(action);
|
||||
render(player, visibleView(player, view, sampledSites));
|
||||
}
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
} catch (RuntimeException failure) {
|
||||
plugin.debug(failure);
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) { if (!current(uuid, session, request)) return; }
|
||||
if (player.isOnline() && plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)
|
||||
&& plugin.getCommandLoader().canUseGuidedSession(player)) player.sendMessage("Could not check voting status. Please try " + sessionCommand("check") + ".");
|
||||
}, () -> { }, () -> { });
|
||||
} finally {
|
||||
synchronized (this) { pending.remove(uuid, request); outstanding.remove(request); }
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException unavailable) {
|
||||
synchronized (this) {
|
||||
pending.remove(uuid, request); outstanding.remove(request);
|
||||
// No worker ran to send the initial fence. Retire only this new guide,
|
||||
// so an ordinary reopen retries creation without resetting existing progress.
|
||||
if (creating && sessions.get(uuid) == session && Long.valueOf(request).equals(requests.get(uuid))) {
|
||||
sessions.remove(uuid); requests.remove(uuid); storageIds.remove(uuid);
|
||||
playerNames.remove(uuid); early.remove(uuid); proxyRequests.remove(uuid);
|
||||
}
|
||||
}
|
||||
player.sendMessage("Voting status is temporarily unavailable. Please try again.");
|
||||
}
|
||||
}
|
||||
/** Expiration releases owner admission, while outstanding work retains its global slot until it finishes. */
|
||||
private synchronized void expire(long now) {
|
||||
int minutes = Math.max(1, Math.min(120, plugin.getConfigFile().getData().getInt("GuidedVotingSession.TimeoutMinutes", 30)));
|
||||
sessions.entrySet().removeIf(e -> {
|
||||
if (now - e.getValue().started() < minutes * 60_000L) return false;
|
||||
retireDialog(e.getKey()); automaticRefresh.remove(e.getKey()); return true;
|
||||
});
|
||||
requests.keySet().retainAll(sessions.keySet());
|
||||
pending.keySet().retainAll(sessions.keySet());
|
||||
storageIds.keySet().retainAll(sessions.keySet());
|
||||
playerNames.keySet().retainAll(sessions.keySet());
|
||||
owners.keySet().retainAll(sessions.keySet());
|
||||
early.keySet().retainAll(sessions.keySet());
|
||||
proxyRequests.keySet().retainAll(sessions.keySet());
|
||||
}
|
||||
private synchronized boolean current(UUID uuid, GuidedVoteSession session, long request) {
|
||||
expire(System.currentTimeMillis());
|
||||
return sessions.get(uuid) == session && Long.valueOf(request).equals(requests.get(uuid));
|
||||
}
|
||||
/** Recheck visibility on the player context, after any asynchronous wait. */
|
||||
private GuidedVoteSession.View visibleView(Player player, GuidedVoteSession.View view, Map<String, VoteSite> sampledSites) {
|
||||
java.util.Set<String> allowed = new java.util.HashSet<>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) {
|
||||
if (sampledSites.get(site.getKey()) == site && site.isEnabled() && !site.isHidden() && (site.getPermissionToView().isEmpty()
|
||||
|| player.hasPermission(site.getPermissionToView()))) allowed.add(site.getKey());
|
||||
}
|
||||
List<GuidedVoteSession.Entry> entries = new ArrayList<>();
|
||||
for (var entry : view.entries()) entries.add(allowed.contains(entry.site().key()) ? entry
|
||||
: new GuidedVoteSession.Entry(new GuidedVoteSession.Site(entry.site().key(), "Unavailable site",
|
||||
null, false, 0), GuidedVoteSession.Status.UNAVAILABLE));
|
||||
return new GuidedVoteSession.View(List.copyOf(entries), view.index(), view.finished());
|
||||
}
|
||||
private void render(Player player, GuidedVoteSession.View view) {
|
||||
retireDialog(player.getUniqueId());
|
||||
try {
|
||||
if (!dialogChecked) { dialogChecked = true; dialogView = dialogFactory.get(); }
|
||||
if (dialogView != null) { renderDialog(player, view); return; }
|
||||
} catch (RuntimeException | LinkageError unsupported) {
|
||||
plugin.debug(unsupported);
|
||||
}
|
||||
player.sendMessage("Native voting dialogs are unavailable on this server. Showing chat guidance instead.");
|
||||
renderChat(player, view);
|
||||
}
|
||||
private void renderDialog(Player player, GuidedVoteSession.View view) {
|
||||
UUID owner = player.getUniqueId();
|
||||
UUID nonce = UUID.randomUUID();
|
||||
GuidedVoteSession session = sessions.get(owner);
|
||||
List<SessionDialogView.Button> buttons = new ArrayList<>();
|
||||
String body = view.received() + "/" + view.entries().size() + " votes received.";
|
||||
if (view.entries().isEmpty()) body += "\nNo eligible, visible voting sites. Restart after your cooldowns end.";
|
||||
else if (view.finished() || view.complete()) body += view.complete()
|
||||
? "\nAll session votes were received. Thank you!" : "\nSession finished. Unconfirmed votes remain unconfirmed.";
|
||||
else {
|
||||
var entry = view.current();
|
||||
body += "\nSite " + (view.index() + 1) + "/" + view.entries().size() + ": " + entry.site().name()
|
||||
+ "\n" + entry.status() + "\nWaiting for VotingPlugin to receive your vote.";
|
||||
String url = GuidedVoteSession.httpUrl(entry.site().url());
|
||||
if (url != null && (entry.status() == GuidedVoteSession.Status.AWAITING || entry.status() == GuidedVoteSession.Status.SKIPPED))
|
||||
buttons.add(new SessionDialogView.Button("Open voting link", "Your client controls opening the browser.", null, url));
|
||||
else if (url == null) body += "\nThis site has no valid HTTP/HTTPS link. Ask an administrator.";
|
||||
}
|
||||
if (session != null && plugin.getBungeeSettings().isUseBungeecoord() && !session.hasProxyFence())
|
||||
body += "\nProxy confirmation is pending or unavailable.";
|
||||
body += "\nOpening a link never grants a vote or reward.";
|
||||
if (!view.entries().isEmpty() && !view.finished() && !view.complete())
|
||||
for (String action : List.of("previous", "next", "skip", "check", "finish"))
|
||||
buttons.add(new SessionDialogView.Button(action, "", action, null));
|
||||
buttons.add(new SessionDialogView.Button("Restart session", "", "restart", null));
|
||||
buttons.add(new SessionDialogView.Button("Close", "Your current session is retained.", "close", null));
|
||||
java.util.concurrent.atomic.AtomicBoolean used = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
Runnable cleanup = dialogView.show(player, "Voting session", body, List.copyOf(buttons), (actor, action) -> {
|
||||
if (!owner.equals(actor)) return;
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) {
|
||||
expire(System.currentTimeMillis());
|
||||
DialogRegistration active = dialogs.get(owner);
|
||||
if (active == null || !nonce.equals(active.nonce()) || sessions.get(owner) != session
|
||||
|| !player.isOnline() || !used.compareAndSet(false, true)) return;
|
||||
retireDialog(owner);
|
||||
if (plugin.getCommandLoader().canUseGuidedSession(player) && !action.equals("close")) prepare(player, action);
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
});
|
||||
try {
|
||||
// One bounded sweep owns all callback deadlines. Replacing a view
|
||||
// does not leave cancelled per-view futures in a shared timer queue.
|
||||
if (dialogCleanup == null) dialogCleanup = plugin.getTimer().scheduleWithFixedDelay(() -> {
|
||||
synchronized (this) { expire(System.currentTimeMillis()); }
|
||||
}, 30, 30, java.util.concurrent.TimeUnit.SECONDS);
|
||||
dialogs.put(owner, new DialogRegistration(nonce, cleanup));
|
||||
} catch (RuntimeException failure) {
|
||||
cleanup.run(); throw failure;
|
||||
}
|
||||
}
|
||||
private void retireDialog(UUID owner) {
|
||||
DialogRegistration registration = dialogs.remove(owner);
|
||||
if (registration == null) return;
|
||||
try { registration.cleanup().run(); }
|
||||
catch (RuntimeException failure) { plugin.debug(failure); }
|
||||
}
|
||||
@EventHandler
|
||||
public synchronized void disconnected(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
UUID owner = event.getPlayer().getUniqueId();
|
||||
retireDialog(owner); requests.remove(owner); pending.remove(owner); automaticRefresh.remove(owner); owners.remove(owner);
|
||||
}
|
||||
private void renderChat(Player player, GuidedVoteSession.View view) {
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord()) {
|
||||
GuidedVoteSession session = sessions.get(player.getUniqueId());
|
||||
if (session != null && !session.hasProxyFence()) player.sendMessage(
|
||||
"Proxy confirmation is pending or unavailable. Only votes after a supporting proxy acknowledges this session can be confirmed.");
|
||||
}
|
||||
player.sendMessage("Voting session: " + view.received() + "/" + view.entries().size() + " votes received.");
|
||||
if (view.entries().isEmpty()) {
|
||||
player.sendMessage("No eligible, visible voting sites. Try " + sessionCommand("restart") + " after your cooldowns end."); return;
|
||||
}
|
||||
if (view.finished() || view.complete()) {
|
||||
player.sendMessage(view.complete() ? "All session votes were received. Thank you!"
|
||||
: "Session finished. Unconfirmed votes remain unconfirmed; normal vote rewards are unchanged.");
|
||||
}
|
||||
for (int i = 0; i < view.entries().size(); i++) {
|
||||
var entry = view.entries().get(i);
|
||||
player.sendMessage((i == view.index() ? "> " : " ") + entry.site().name() + " — " + entry.status());
|
||||
}
|
||||
var entry = view.current();
|
||||
if (!view.finished() && (entry.status() == GuidedVoteSession.Status.AWAITING || entry.status() == GuidedVoteSession.Status.SKIPPED)) {
|
||||
String url = GuidedVoteSession.httpUrl(entry.site().url());
|
||||
if (url != null) button(player, "Open voting link: " + entry.site().name(), ClickEvent.Action.OPEN_URL, url);
|
||||
else player.sendMessage("This site has no valid HTTP/HTTPS link. Ask an administrator.");
|
||||
}
|
||||
for (String action : List.of("previous", "next", "skip", "check", "finish", "restart"))
|
||||
button(player, "[" + action + "]", ClickEvent.Action.RUN_COMMAND, sessionCommand(action));
|
||||
player.sendMessage("A link or click is not vote confirmation. Check again after the vote arrives.");
|
||||
}
|
||||
private String sessionCommand(String action) {
|
||||
return "/" + plugin.getCommandLoader().getVoteCommandName() + " session " + action;
|
||||
}
|
||||
private static void button(Player player, String title, ClickEvent.Action action, String value) {
|
||||
TextComponent component = new TextComponent(title);
|
||||
component.setClickEvent(new ClickEvent(action, value));
|
||||
player.spigot().sendMessage(component);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.bencodez.votingplugin.session;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** A player-owned native view; cleanup releases only this view's action handlers. */
|
||||
interface SessionDialogView {
|
||||
record Button(String label, String tooltip, String action, String url) { }
|
||||
Runnable show(Player player, String title, String body, List<Button> buttons,
|
||||
BiConsumer<UUID, String> action);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import org.bukkit.block.Skull;
|
||||
import org.bukkit.block.data.Directional;
|
||||
import org.bukkit.inventory.meta.SkullMeta;
|
||||
|
||||
import com.bencodez.advancedcore.api.misc.MiscUtils;
|
||||
import com.bencodez.simpleapi.array.ArrayUtils;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
@@ -25,14 +24,14 @@ public class SignHandler {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private String data;
|
||||
private volatile String data;
|
||||
|
||||
/** The lines. */
|
||||
private ArrayList<String> lines;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private Location location;
|
||||
private volatile Location location;
|
||||
|
||||
private String playerName;
|
||||
|
||||
@@ -48,7 +47,7 @@ public class SignHandler {
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
private Location skullLocation;
|
||||
private volatile Location skullLocation;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -56,6 +55,88 @@ public class SignHandler {
|
||||
|
||||
private int votes;
|
||||
|
||||
private record Render(ArrayList<String> lines, String name, UUID identity, int votes) { }
|
||||
private record DisplayUpdate(long generation, Location location, String data) { }
|
||||
private DisplayUpdate preparing;
|
||||
private long skullSequence;
|
||||
private static final int MAX_DISCOVERED_SKULLS = 28;
|
||||
private final java.util.Set<Location> displayedSkulls = new java.util.LinkedHashSet<>();
|
||||
private SkullFrame lastSkullFrame;
|
||||
private record SkullFrame(long sequence, long generation, Location origin, Location selected, String data,
|
||||
String name, UUID identity) { }
|
||||
|
||||
private synchronized SkullFrame skullFrame(String name, UUID identity) {
|
||||
var frame = new SkullFrame(++skullSequence, plugin.getTopVoterDisplayGeneration(), location.clone(),
|
||||
skullLocation == null ? null : skullLocation.clone(), data, name, identity);
|
||||
if (lastSkullFrame == null || lastSkullFrame.generation() != frame.generation()
|
||||
|| !java.util.Objects.equals(lastSkullFrame.origin(), frame.origin())
|
||||
|| !java.util.Objects.equals(lastSkullFrame.selected(), frame.selected())
|
||||
|| !java.util.Objects.equals(lastSkullFrame.data(), frame.data())) displayedSkulls.clear();
|
||||
lastSkullFrame = frame;
|
||||
return frame;
|
||||
}
|
||||
|
||||
/** Remember successful automatic publications, never arbitrary neighboring skulls. */
|
||||
private synchronized void rememberSkull(SkullFrame frame, Location target) {
|
||||
if (lastSkullFrame == frame && frame.selected() == null && displayedSkulls.size() < MAX_DISCOVERED_SKULLS)
|
||||
displayedSkulls.add(target.clone());
|
||||
}
|
||||
|
||||
private synchronized void forgetSkull(SkullFrame frame, Location target) {
|
||||
if (lastSkullFrame == frame) displayedSkulls.remove(target);
|
||||
}
|
||||
|
||||
private synchronized boolean assignedSkull(SkullFrame frame, Location target) {
|
||||
return lastSkullFrame == frame && (java.util.Objects.equals(frame.selected(), target)
|
||||
|| displayedSkulls.contains(target));
|
||||
}
|
||||
|
||||
private synchronized ArrayList<Location> assignedSkulls(SkullFrame frame) {
|
||||
if (lastSkullFrame != frame) return new ArrayList<>();
|
||||
return new ArrayList<>(frame.selected() == null ? displayedSkulls : java.util.List.of(frame.selected()));
|
||||
}
|
||||
|
||||
private boolean current(SkullFrame frame) {
|
||||
if (!plugin.isEnabled() || plugin.getTopVoterDisplayGeneration() != frame.generation()) return false;
|
||||
synchronized (this) {
|
||||
return skullSequence == frame.sequence() && java.util.Objects.equals(location, frame.origin())
|
||||
&& java.util.Objects.equals(skullLocation, frame.selected())
|
||||
&& java.util.Objects.equals(data, frame.data());
|
||||
}
|
||||
}
|
||||
|
||||
/** Only coordinates cross regions; every block access occurs inside its own location task. */
|
||||
private void onOwner(SkullFrame frame, Location target, Runnable action) {
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
if (current(frame)) action.run();
|
||||
}, target);
|
||||
} catch (RuntimeException rejected) { plugin.debug("Unable to schedule skull display"); }
|
||||
}
|
||||
|
||||
private void updateSkull(SkullFrame frame, Location target, Runnable absent) {
|
||||
onOwner(frame, target, () -> {
|
||||
Block block = target.getBlock();
|
||||
if (!(block.getState() instanceof Skull)) {
|
||||
forgetSkull(frame, target);
|
||||
if (absent != null) absent.run();
|
||||
return;
|
||||
}
|
||||
String name = frame.name(); UUID identity = frame.identity();
|
||||
// Queued region work may outlive the evidence used by the sign render.
|
||||
TopVoter period = displayPeriod();
|
||||
if (period != null && position > 0 && plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
var rows = new ArrayList<>(plugin.getTopVoterDisplay(period).keySet());
|
||||
if (rows.size() >= position) {
|
||||
name = rows.get(position - 1).getPlayerName(); identity = rows.get(position - 1).getUuid();
|
||||
} else { name = "No Player"; identity = null; }
|
||||
}
|
||||
if (!current(frame)) return;
|
||||
if (!checkSkull(block, target, name, identity, frame) && absent != null) absent.run();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new SignHandler.
|
||||
*
|
||||
@@ -81,19 +162,28 @@ public class SignHandler {
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private boolean checkSkull(Block block) {
|
||||
if (block.getState() instanceof Skull) {
|
||||
if (!playerName.equals("No Player")) {
|
||||
Skull skull = (Skull) block.getState();
|
||||
private boolean checkSkull(Block block, Location target, String name, UUID identity, SkullFrame frame) {
|
||||
if (block.getState() instanceof Skull skull) {
|
||||
if (name.isEmpty() || name.equalsIgnoreCase("No Player")) {
|
||||
if (plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
if (!current(frame) || !assignedSkull(frame, target)) return true;
|
||||
skull.setOwnerProfile(null);
|
||||
if (current(frame) && skull.update(true, false)) forgetSkull(frame, target);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!name.equals("No Player")) {
|
||||
try {
|
||||
SkullMeta meta = (SkullMeta) plugin.getSkullCacheHandler().getSkull(uuid, playerName).getItemMeta();
|
||||
skull.setOwnerProfile(meta.getOwnerProfile());
|
||||
skull.update(true, false);
|
||||
SkullMeta meta = (SkullMeta) plugin.getSkullCacheHandler().getSkull(identity, name).getItemMeta();
|
||||
var profile = java.util.Objects.requireNonNull(meta.getOwnerProfile(), "Skull profile unavailable");
|
||||
if (!current(frame)) return true;
|
||||
skull.setOwnerProfile(profile);
|
||||
if (current(frame) && skull.update(true, false)) rememberSkull(frame, target);
|
||||
} catch (Exception e) {
|
||||
plugin.debug("Failed to set skull for " + playerName);
|
||||
plugin.debug("Failed to set skull for " + name);
|
||||
plugin.debug(e);
|
||||
skull.setOwner(playerName);
|
||||
skull.update(true, false);
|
||||
if (!current(frame)) return true;
|
||||
if (skull.setOwner(name) && current(frame) && skull.update(true, false)) rememberSkull(frame, target);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -106,18 +196,24 @@ public class SignHandler {
|
||||
* Check skulls.
|
||||
*/
|
||||
public void checkSkulls() {
|
||||
if (noNameAvailable()) {
|
||||
return;
|
||||
}
|
||||
if (skullLocation != null) {
|
||||
checkSkull(skullLocation.getBlock());
|
||||
return;
|
||||
}
|
||||
Render rendered = currentRender();
|
||||
checkSkulls(rendered.name(), rendered.identity());
|
||||
}
|
||||
|
||||
Location loc = location;
|
||||
private void checkSkulls(String name, UUID identity) {
|
||||
if ((name.equalsIgnoreCase("No Player") || name.isEmpty())
|
||||
&& !plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
return;
|
||||
}
|
||||
SkullFrame frame = skullFrame(name, identity);
|
||||
if (frame.selected() != null) {
|
||||
updateSkull(frame, frame.selected(), null);
|
||||
return;
|
||||
}
|
||||
Location loc = frame.origin();
|
||||
Location loc1 = new Location(loc.getWorld(), loc.getBlockX() - 1, loc.getBlockY() - 1, loc.getBlockZ() - 1);
|
||||
Location loc2 = new Location(loc.getWorld(), loc.getBlockX() + 1, loc.getBlockY() + 1, loc.getBlockZ() + 1);
|
||||
updateSkulls(loc1, loc2);
|
||||
updateSkulls(loc1, loc2, frame);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +286,12 @@ public class SignHandler {
|
||||
/**
|
||||
* Store sign.
|
||||
*/
|
||||
/** Capture platform state on the caller before shutdown transfers publication ownership. */
|
||||
public com.bencodez.votingplugin.data.ServerData.SignSnapshot captureShutdownSnapshot() {
|
||||
return com.bencodez.votingplugin.data.ServerData.captureSignSnapshot(
|
||||
sign, location, skullLocation, data, position);
|
||||
}
|
||||
|
||||
public void storeSign() {
|
||||
plugin.getServerData().setSign(sign, location, skullLocation, data, position);
|
||||
}
|
||||
@@ -198,95 +300,131 @@ public class SignHandler {
|
||||
* Update lines.
|
||||
*/
|
||||
public void updateLines() {
|
||||
lines = new ArrayList<>();
|
||||
checkValidSign();
|
||||
if (position != 0) {
|
||||
String line1 = plugin.getConfigFile().getFormatSignTopVoterSignLine1();
|
||||
String line2 = plugin.getConfigFile().getFormatSignTopVoterSignLine2();
|
||||
String line3 = plugin.getConfigFile().getFormatSignTopVoterSignLine3();
|
||||
String line4 = plugin.getConfigFile().getFormatSignTopVoterSignLine4();
|
||||
lines.add(line1);
|
||||
lines.add(line2);
|
||||
lines.add(line3);
|
||||
lines.add(line4);
|
||||
|
||||
ArrayList<TopVoterPlayer> users = null;
|
||||
if (data.equalsIgnoreCase("all")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.AllTime).keySet());
|
||||
} else if (data.equalsIgnoreCase("monthly")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.Monthly).keySet());
|
||||
} else if (data.equalsIgnoreCase("weekly")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.Weekly).keySet());
|
||||
} else if (data.equalsIgnoreCase("daily")) {
|
||||
users = new ArrayList<>(plugin.getTopVoter(TopVoter.Daily).keySet());
|
||||
}
|
||||
|
||||
if (users != null && users.size() >= position) {
|
||||
TopVoterPlayer user = users.get(position - 1);
|
||||
playerName = user.getPlayerName();
|
||||
uuid = user.getUuid();
|
||||
|
||||
votes = 0;
|
||||
if (data.equalsIgnoreCase("all")) {
|
||||
votes = plugin.getTopVoter(TopVoter.AllTime).get(user);
|
||||
} else if (data.equalsIgnoreCase("monthly")) {
|
||||
votes = plugin.getTopVoter(TopVoter.Monthly).get(user);
|
||||
} else if (data.equalsIgnoreCase("weekly")) {
|
||||
votes = plugin.getTopVoter(TopVoter.Weekly).get(user);
|
||||
} else if (data.equalsIgnoreCase("daily")) {
|
||||
votes = plugin.getTopVoter(TopVoter.Daily).get(user);
|
||||
}
|
||||
|
||||
for (int j = 0; j < lines.size(); j++) {
|
||||
lines.set(j, lines.get(j).replace("%votes%", "" + votes).replace("%player%", playerName));
|
||||
}
|
||||
} else {
|
||||
playerName = "No Player";
|
||||
votes = 0;
|
||||
for (int j = 0; j < lines.size(); j++) {
|
||||
lines.set(j, lines.get(j).replace("%votes%", "" + votes).replace("%player%", playerName));
|
||||
}
|
||||
}
|
||||
|
||||
for (int j = 0; j < lines.size(); j++) {
|
||||
lines.set(j, lines.get(j).replace("%SiteName%", data).replace("%position%", "" + position));
|
||||
}
|
||||
|
||||
lines = ArrayUtils.colorize(lines);
|
||||
|
||||
}
|
||||
|
||||
Render rendered = renderLines();
|
||||
synchronized (this) { applyRender(rendered); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sign.
|
||||
*
|
||||
* @param delay the delay
|
||||
*/
|
||||
private TopVoter displayPeriod() {
|
||||
if ("all".equalsIgnoreCase(data)) return TopVoter.AllTime;
|
||||
if ("monthly".equalsIgnoreCase(data)) return TopVoter.Monthly;
|
||||
if ("weekly".equalsIgnoreCase(data)) return TopVoter.Weekly;
|
||||
if ("daily".equalsIgnoreCase(data)) return TopVoter.Daily;
|
||||
return null;
|
||||
}
|
||||
|
||||
private Render renderLines() {
|
||||
var rendered = new ArrayList<String>();
|
||||
checkValidSign();
|
||||
if (position == 0) {
|
||||
synchronized (this) { return new Render(rendered, playerName, uuid, votes); }
|
||||
}
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine1());
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine2());
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine3());
|
||||
rendered.add(plugin.getConfigFile().getFormatSignTopVoterSignLine4());
|
||||
TopVoter period = displayPeriod();
|
||||
java.util.Map<TopVoterPlayer, Integer> board = period == null ? java.util.Map.of()
|
||||
: plugin.getTopVoterDisplay(period);
|
||||
var users = new ArrayList<>(board.keySet());
|
||||
String name = "No Player";
|
||||
UUID identity = null;
|
||||
int amount = 0;
|
||||
if (users.size() >= position) {
|
||||
TopVoterPlayer row = users.get(position - 1);
|
||||
name = row.getPlayerName(); identity = row.getUuid(); amount = board.get(row);
|
||||
}
|
||||
for (int index = 0; index < rendered.size(); index++) {
|
||||
rendered.set(index, rendered.get(index).replace("%votes%", Integer.toString(amount))
|
||||
.replace("%player%", name).replace("%SiteName%", data)
|
||||
.replace("%position%", Integer.toString(position)));
|
||||
}
|
||||
return new Render(ArrayUtils.colorize(rendered), name, identity, amount);
|
||||
}
|
||||
|
||||
/** Called under this monitor; never call storage/provider APIs from this scope. */
|
||||
private void applyRender(Render rendered) {
|
||||
lines = rendered.lines(); playerName = rendered.name(); uuid = rendered.identity(); votes = rendered.votes();
|
||||
}
|
||||
|
||||
private Render currentRender() {
|
||||
synchronized (this) { return new Render(new ArrayList<>(lines), playerName, uuid, votes); }
|
||||
}
|
||||
|
||||
/** Update the sign on its owning scheduler, waiting once for admitted presence evidence when enabled. */
|
||||
public void updateSign(int delay) {
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, new Runnable() {
|
||||
TopVoter period = displayPeriod();
|
||||
if (position != 0 && period != null && plugin.getConfigFile() != null
|
||||
&& plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore()) {
|
||||
prepareDisplay(period, delay);
|
||||
return;
|
||||
}
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, () -> writeSign(currentRender(), getLocation()),
|
||||
delay, getLocation());
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public void run() {
|
||||
private void prepareDisplay(TopVoter period, int delay) {
|
||||
long generation = plugin.getTopVoterDisplayGeneration();
|
||||
Location target = getLocation().clone();
|
||||
DisplayUpdate update;
|
||||
synchronized (this) {
|
||||
if (preparing != null && preparing.generation() == generation
|
||||
&& preparing.location().equals(target) && java.util.Objects.equals(preparing.data(), data)) return;
|
||||
update = new DisplayUpdate(generation, target, data);
|
||||
preparing = update;
|
||||
}
|
||||
try {
|
||||
var ready = plugin.getTopVoterDisplayAsync(period);
|
||||
if (ready == null) schedulePreparedDisplay(update, delay);
|
||||
else ready.whenComplete((ignored, failure) -> schedulePreparedDisplay(update, delay));
|
||||
} catch (RuntimeException unavailable) {
|
||||
schedulePreparedDisplay(update, delay);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean current(DisplayUpdate update) {
|
||||
return plugin.isEnabled() && plugin.getTopVoterDisplayGeneration() == update.generation()
|
||||
&& java.util.Objects.equals(update.location(), getLocation())
|
||||
&& java.util.Objects.equals(update.data(), data);
|
||||
}
|
||||
|
||||
private void schedulePreparedDisplay(DisplayUpdate update, int delay) {
|
||||
synchronized (this) { if (preparing != update) return; }
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTaskLater(plugin, () -> {
|
||||
try {
|
||||
BlockState state = getLocation().getBlock().getState();
|
||||
if (state instanceof Sign) {
|
||||
Sign s = (Sign) state;
|
||||
|
||||
for (int j = 0; j < lines.size() && j < 4; j++) {
|
||||
s.setLine(j, lines.get(j));
|
||||
}
|
||||
s.update();
|
||||
checkSkulls();
|
||||
synchronized (SignHandler.this) { if (preparing != update) return; }
|
||||
if (!current(update)) return;
|
||||
// Resolve current evidence outside the sign monitor. Completion may reenter from the SDK worker.
|
||||
Render rendered = renderLines();
|
||||
if (!current(update)) return;
|
||||
synchronized (SignHandler.this) {
|
||||
if (preparing != update) return;
|
||||
applyRender(rendered);
|
||||
}
|
||||
|
||||
} catch (Exception ex) {
|
||||
plugin.debug("Failed to update sign at " + getLocation().toString());
|
||||
VotingPluginMain.plugin.debug(ex);
|
||||
writeSign(rendered, update.location());
|
||||
} finally {
|
||||
synchronized (SignHandler.this) { if (preparing == update) preparing = null; }
|
||||
}
|
||||
}, delay, update.location());
|
||||
} catch (RuntimeException rejected) {
|
||||
synchronized (this) { if (preparing == update) preparing = null; }
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private void writeSign(Render rendered, Location target) {
|
||||
try {
|
||||
BlockState state = target.getBlock().getState();
|
||||
if (state instanceof Sign signState) {
|
||||
for (int index = 0; index < rendered.lines().size() && index < 4; index++)
|
||||
signState.setLine(index, rendered.lines().get(index));
|
||||
signState.update();
|
||||
checkSkulls(rendered.name(), rendered.identity());
|
||||
}
|
||||
}, delay, getLocation());
|
||||
} catch (Exception failure) {
|
||||
plugin.debug("Failed to update sign display");
|
||||
plugin.debug(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,19 +434,38 @@ public class SignHandler {
|
||||
* @param loc2 the second location
|
||||
*/
|
||||
public void updateSkulls(Location loc1, Location loc2) {
|
||||
BlockState state = getLocation().getBlock().getState();
|
||||
if (state instanceof Sign && state.getBlockData() instanceof Directional) {
|
||||
Directional s = (Directional) state.getBlockData();
|
||||
Block b = location.getBlock().getRelative(s.getFacing());
|
||||
Block above = b.getRelative(BlockFace.UP);
|
||||
if (checkSkull(above)) {
|
||||
return;
|
||||
}
|
||||
Render rendered = currentRender();
|
||||
updateSkulls(loc1, loc2, rendered.name(), rendered.identity());
|
||||
}
|
||||
|
||||
}
|
||||
private void updateSkulls(Location loc1, Location loc2, String name, UUID identity) {
|
||||
updateSkulls(loc1, loc2, skullFrame(name, identity));
|
||||
}
|
||||
|
||||
for (Block block : MiscUtils.getInstance().getRegionBlocks(location.getWorld(), loc1, loc2)) {
|
||||
checkSkull(block);
|
||||
private void updateSkulls(Location loc1, Location loc2, SkullFrame frame) {
|
||||
if (frame.name().isEmpty() || frame.name().equalsIgnoreCase("No Player")) {
|
||||
if (plugin.getConfigFile().isHideTopVoterIfNotPlayedBefore())
|
||||
for (Location target : assignedSkulls(frame)) updateSkull(frame, target, null);
|
||||
return;
|
||||
}
|
||||
Runnable nearby = () -> {
|
||||
for (int x = Math.min(loc1.getBlockX(), loc2.getBlockX()); x <= Math.max(loc1.getBlockX(), loc2.getBlockX()); x++)
|
||||
for (int y = Math.min(loc1.getBlockY(), loc2.getBlockY()); y <= Math.max(loc1.getBlockY(), loc2.getBlockY()); y++)
|
||||
for (int z = Math.min(loc1.getBlockZ(), loc2.getBlockZ()); z <= Math.max(loc1.getBlockZ(), loc2.getBlockZ()); z++) {
|
||||
if (!current(frame)) return;
|
||||
updateSkull(frame, new Location(frame.origin().getWorld(), x, y, z), null);
|
||||
}
|
||||
};
|
||||
onOwner(frame, frame.origin(), () -> {
|
||||
BlockState state = frame.origin().getBlock().getState();
|
||||
if (state instanceof Sign && state.getBlockData() instanceof Directional direction) {
|
||||
BlockFace facing = direction.getFacing();
|
||||
Location preferred = new Location(frame.origin().getWorld(),
|
||||
frame.origin().getBlockX() + facing.getModX(),
|
||||
frame.origin().getBlockY() + facing.getModY() + 1,
|
||||
frame.origin().getBlockZ() + facing.getModZ());
|
||||
updateSkull(frame, preferred, nearby);
|
||||
} else nearby.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,69 @@ public class Signs {
|
||||
}
|
||||
}
|
||||
|
||||
private final Object shutdownLock = new Object();
|
||||
private ShutdownSigns shutdownSigns;
|
||||
private java.util.concurrent.CompletableFuture<Void> shutdownAttempt;
|
||||
|
||||
private record ShutdownSigns(com.bencodez.votingplugin.data.ServerData data,
|
||||
java.util.List<com.bencodez.votingplugin.data.ServerData.SignSnapshot> snapshot,
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.Lease lease) { }
|
||||
|
||||
/** No YAML lock or disk wait on the lifecycle caller; failures retain the original payload. */
|
||||
public java.util.concurrent.CompletionStage<Void> storeSignsForShutdown() {
|
||||
synchronized (shutdownLock) {
|
||||
if (shutdownAttempt != null && !shutdownAttempt.isCompletedExceptionally())
|
||||
return shutdownAttempt.minimalCompletionStage();
|
||||
if (shutdownSigns == null) {
|
||||
var snapshot = new java.util.ArrayList<com.bencodez.votingplugin.data.ServerData.SignSnapshot>();
|
||||
for (var sign : java.util.List.copyOf(getSigns())) snapshot.add(sign.captureShutdownSnapshot());
|
||||
if (snapshot.isEmpty()) {
|
||||
shutdownAttempt = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
return shutdownAttempt.minimalCompletionStage();
|
||||
}
|
||||
var data = java.util.Objects.requireNonNull(plugin.getServerData());
|
||||
var fence = java.util.Objects.requireNonNull(plugin.getServerDataPublicationFence());
|
||||
shutdownSigns = new ShutdownSigns(data, java.util.List.copyOf(snapshot), fence.acquire());
|
||||
}
|
||||
var publication = shutdownSigns;
|
||||
var attempt = new java.util.concurrent.CompletableFuture<Void>();
|
||||
shutdownAttempt = attempt;
|
||||
try {
|
||||
startShutdownWorker(() -> {
|
||||
try {
|
||||
java.util.Objects.requireNonNull(publication.data().publishShutdownSigns(publication.snapshot()))
|
||||
.whenComplete((ignored, failure) -> finishShutdownSigns(publication, attempt, failure));
|
||||
} catch (Throwable failure) { finishShutdownSigns(publication, attempt, failure); }
|
||||
});
|
||||
} catch (RuntimeException | Error failure) {
|
||||
finishShutdownSigns(publication, attempt, failure);
|
||||
throw failure;
|
||||
}
|
||||
return attempt.minimalCompletionStage();
|
||||
}
|
||||
}
|
||||
|
||||
private void finishShutdownSigns(ShutdownSigns publication,
|
||||
java.util.concurrent.CompletableFuture<Void> attempt, Throwable failure) {
|
||||
if (failure == null) {
|
||||
try { publication.lease().close(); attempt.complete(null); }
|
||||
catch (Throwable releaseFailure) { attempt.completeExceptionally(releaseFailure); }
|
||||
} else {
|
||||
// Neither rejected admission nor failed I/O proves durability or writer drain.
|
||||
// Keep the same detached payload and lease for an explicit repeated-unload attempt.
|
||||
try { plugin.getLogger().warning("Sign shutdown persistence failed; its payload remains owned. Recovery is not confirmed."); }
|
||||
catch (RuntimeException | Error ignored) { }
|
||||
attempt.completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** Admission seam for deterministic rejection/cancellation tests. */
|
||||
protected void startShutdownWorker(Runnable task) {
|
||||
var worker = new Thread(task, "VotingPlugin-sign-shutdown");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update signs.
|
||||
*/
|
||||
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
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
@@ -0,0 +1,94 @@
|
||||
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
@@ -0,0 +1,548 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -146,8 +146,9 @@ public class VoteParty implements Listener {
|
||||
public void commandVoteParty(CommandSender sender) {
|
||||
if (plugin.getSpecialRewardsConfig().isVotePartyEnabled()) {
|
||||
ArrayList<String> msg = plugin.getConfigFile().getFormatCommandsVoteParty();
|
||||
int votesRequired = getVotesRequired();
|
||||
int votes = getTotalVotes();
|
||||
var display = plugin.getServerData().getVotePartyDisplay();
|
||||
int votesRequired = display.required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired());
|
||||
int votes = display.total();
|
||||
int neededVotes = votesRequired - votes;
|
||||
HashMap<String, String> placeholders = new HashMap<>();
|
||||
placeholders.put("votesrequired", "" + votesRequired);
|
||||
@@ -243,7 +244,16 @@ public class VoteParty implements Listener {
|
||||
*/
|
||||
public void giveRewards(VotingPluginUser orgUser, boolean forceBungee) {
|
||||
rewardHandler.giveRewards(orgUser, forceBungee);
|
||||
reset(false);
|
||||
// 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+104
-10
@@ -3,6 +3,8 @@ 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;
|
||||
@@ -11,6 +13,7 @@ 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;
|
||||
@@ -37,10 +40,15 @@ 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", "" + !plugin.getServerData().isLastVotePartySameDay())
|
||||
.withPlaceHolder("FirstVotePartyToday", "" + firstVotePartyToday)
|
||||
.setServer(useBungee).send(user);
|
||||
}
|
||||
|
||||
@@ -82,13 +90,14 @@ 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()) {
|
||||
voteParty.giveReward(user, forceBungee);
|
||||
giveRewardWhenLoaded(user, forceBungee, player);
|
||||
}
|
||||
alreadyGiven.add(player.getUniqueId().toString());
|
||||
}
|
||||
@@ -99,30 +108,115 @@ public final class VotePartyRewardHandler {
|
||||
}
|
||||
VotingPluginUser user = resolveUser(triggeringUser, UUID.fromString(uuid), null);
|
||||
if (!plugin.getSpecialRewardsConfig().isVotePartyGiveOnlinePlayersOnly() || user.isOnline()) {
|
||||
voteParty.giveReward(user, forceBungee);
|
||||
giveRewardWhenLoaded(user, forceBungee, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()) {
|
||||
voteParty.giveReward(user, forceBungee);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a recipient without forcing an uncached SQL read for shared storage. */
|
||||
private VotingPluginUser resolveUser(VotingPluginUser triggeringUser, UUID uuid, Player onlinePlayer) {
|
||||
if (triggeringUser != null && triggeringUser.getJavaUUID().equals(uuid)) {
|
||||
return triggeringUser;
|
||||
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);
|
||||
}
|
||||
VotingPluginUser user = onlinePlayer == null
|
||||
? plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true)
|
||||
: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -24,8 +24,7 @@ public final class VotePartyState {
|
||||
}
|
||||
|
||||
public void setTotalVotes(int value) {
|
||||
plugin.getServerData().getData().set(TOTAL_PATH, value);
|
||||
plugin.getServerData().saveData();
|
||||
plugin.getServerData().setInt(TOTAL_PATH, value);
|
||||
}
|
||||
|
||||
public List<String> getVotedUsers() {
|
||||
@@ -34,8 +33,7 @@ public final class VotePartyState {
|
||||
}
|
||||
|
||||
public void setVotedUsers(List<String> users) {
|
||||
plugin.getServerData().getData().set(VOTED_PATH, users);
|
||||
plugin.getServerData().saveData();
|
||||
plugin.getServerData().setStringList(VOTED_PATH, users);
|
||||
}
|
||||
|
||||
public int getVotesRequired() {
|
||||
|
||||
+288
-43
@@ -3,10 +3,15 @@ package com.bencodez.votingplugin.timequeue;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.event.EventHandler;
|
||||
@@ -24,11 +29,24 @@ import lombok.Getter;
|
||||
* The TimeQueueHandler class manages time-based vote queue processing.
|
||||
*/
|
||||
public class TimeQueueHandler implements Listener {
|
||||
private static final long TICKS_PER_SECOND = 20L;
|
||||
@Getter
|
||||
private Queue<VoteTimeQueue> timeChangeQueue = new ConcurrentLinkedQueue<>();
|
||||
|
||||
private VotingPluginMain plugin;
|
||||
private final Object persistenceLock = new Object();
|
||||
private volatile boolean stopping;
|
||||
private final AtomicReference<FutureTask<Void>> shutdownSave = new AtomicReference<>();
|
||||
// A failed task is retryable, but cannot release its unsaved suffix to a successor.
|
||||
private final AtomicReference<ShutdownPublication> shutdownPublicationOwner = new AtomicReference<>();
|
||||
private record ShutdownPublication(com.bencodez.votingplugin.util.ServerDataPublicationFence.Lease lease,
|
||||
java.util.concurrent.ScheduledExecutorService producers,
|
||||
java.util.concurrent.ScheduledExecutorService[] coreWriters) { }
|
||||
private final AtomicBoolean processing = new AtomicBoolean();
|
||||
private VoteTimeQueue dispatchingVote;
|
||||
private UUID dispatchId;
|
||||
private boolean dispatchEnded;
|
||||
private boolean dispatchStarted;
|
||||
private int persistedShutdownVotes;
|
||||
private final AtomicBoolean retryPending = new AtomicBoolean();
|
||||
private final AtomicInteger retryAttempts = new AtomicInteger();
|
||||
|
||||
@@ -48,17 +66,23 @@ 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) { addVote(voteUsername, voteSiteName, 0L); }
|
||||
|
||||
/** Retain original ingress order only for this process; legacy callers have unknown order. */
|
||||
public void addVote(String voteUsername, String voteSiteName, long observationOrder) {
|
||||
VoteTimeQueue vote = new VoteTimeQueue(voteUsername, voteSiteName,
|
||||
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
|
||||
vote.setBackendObservationOrder(observationOrder);
|
||||
timeChangeQueue.add(vote);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads cached votes from server data and schedules queue processing.
|
||||
@@ -66,8 +90,29 @@ public class TimeQueueHandler implements Listener {
|
||||
public void load() {
|
||||
for (String str : plugin.getServerData().getTimedVoteCacheKeys()) {
|
||||
ConfigurationSection data = plugin.getServerData().getTimedVoteCacheSection(str);
|
||||
timeChangeQueue
|
||||
.add(new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time")));
|
||||
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);
|
||||
}
|
||||
scheduleQueueProcessing(120, TimeUnit.SECONDS);
|
||||
}
|
||||
@@ -83,17 +128,11 @@ public class TimeQueueHandler implements Listener {
|
||||
}
|
||||
|
||||
private void scheduleQueueProcessing(long delay, TimeUnit unit) {
|
||||
boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), () -> {
|
||||
// Clear only after the bounded executor has admitted the task. If it is
|
||||
// rejected, shutdown persistence can still recover the in-memory queue.
|
||||
plugin.getServerData().clearTimedVoteCache();
|
||||
processQueue();
|
||||
}, delay, unit);
|
||||
synchronized (persistenceLock) { if (stopping) return; }
|
||||
boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), this::processQueue, delay, unit);
|
||||
if (!admitted) {
|
||||
plugin.getLogger().warning("Unable to schedule time-queue processing because vote processing is busy; queued votes were retained.");
|
||||
scheduleRetry();
|
||||
} else {
|
||||
retryAttempts.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,15 +140,14 @@ public class TimeQueueHandler implements Listener {
|
||||
if (timeChangeQueue.isEmpty() || !retryPending.compareAndSet(false, true)) return;
|
||||
int attempt = Math.min(6, retryAttempts.getAndIncrement());
|
||||
long delaySeconds = Math.min(60L, 1L << attempt);
|
||||
long delayTicks = delaySeconds * TICKS_PER_SECOND;
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTaskLaterAsynchronously(plugin, () -> {
|
||||
retryPending.set(false);
|
||||
if (!timeChangeQueue.isEmpty()) scheduleQueueProcessing(0, TimeUnit.SECONDS);
|
||||
}, delayTicks);
|
||||
}, delaySeconds);
|
||||
} catch (RuntimeException rejected) {
|
||||
retryPending.set(false);
|
||||
plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain persisted for recovery.");
|
||||
plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain owned for recovery.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,34 +155,241 @@ public class TimeQueueHandler implements Listener {
|
||||
* Processes all votes in the queue.
|
||||
*/
|
||||
public void processQueue() {
|
||||
while (getTimeChangeQueue().size() > 0) {
|
||||
VoteTimeQueue vote = getTimeChangeQueue().remove();
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(),
|
||||
vote.getService(), true);
|
||||
voteEvent.setTime(vote.getTime());
|
||||
voteEvent.setTimeQueuedLocalSessionDelivery(true);
|
||||
voteEvent.setBackendObservationOrder(vote.getBackendObservationOrder());
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
|
||||
if (voteEvent.isCancelled()) {
|
||||
plugin.debug("Vote cancelled");
|
||||
continue;
|
||||
if (!processing.compareAndSet(false, true)) return;
|
||||
boolean retry = false;
|
||||
try {
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping) return;
|
||||
if (dispatchingVote != null) {
|
||||
if (!dispatchEnded) return;
|
||||
retainUncertainDispatch();
|
||||
clearDispatch();
|
||||
}
|
||||
persistedShutdownVotes = 0;
|
||||
}
|
||||
while (true) {
|
||||
VoteTimeQueue vote;
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping || dispatchingVote != null) return;
|
||||
vote = timeChangeQueue.peek();
|
||||
}
|
||||
if (vote == null) return;
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(),
|
||||
vote.getService(), true);
|
||||
voteEvent.setTime(vote.getTime());
|
||||
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 (!timeChangeQueue.isEmpty()) {
|
||||
int num = 0;
|
||||
for (VoteTimeQueue vote : timeChangeQueue) {
|
||||
plugin.getServerData().addTimeVoted(num, vote);
|
||||
num++;
|
||||
if (stopping) {
|
||||
saveForShutdown();
|
||||
return;
|
||||
}
|
||||
synchronized (persistenceLock) {
|
||||
if (stopping) persistPendingVotes();
|
||||
else checkpointPendingVotes();
|
||||
}
|
||||
}
|
||||
|
||||
/** Seal processing and retain one persistence owner without an unbounded lifecycle wait. */
|
||||
public void saveForShutdown() {
|
||||
stopping = true;
|
||||
FutureTask<Void> save;
|
||||
while (true) {
|
||||
save = shutdownSave.get();
|
||||
if (save != null) {
|
||||
if (!save.isDone()) break;
|
||||
shutdownSave.compareAndSet(save, null);
|
||||
continue;
|
||||
}
|
||||
FutureTask<Void> candidate = new FutureTask<Void>(() -> {
|
||||
try {
|
||||
var publication = java.util.Objects.requireNonNull(shutdownPublicationOwner.get());
|
||||
try { synchronized (persistenceLock) { persistPendingVotes(); } }
|
||||
finally {
|
||||
// Publication failure does not terminate previously admitted producers.
|
||||
awaitRetiringProducers(publication.producers());
|
||||
if (publication.coreWriters() != null)
|
||||
for (var writer : publication.coreWriters()) awaitWriterTermination(writer);
|
||||
}
|
||||
synchronized (persistenceLock) { persistPendingVotes(); }
|
||||
if (publication.lease() != null) publication.lease().close();
|
||||
shutdownPublicationOwner.compareAndSet(publication, null);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try {
|
||||
plugin.getLogger().warning("Timed vote shutdown persistence failed; pending votes remain owned for retry. Recovery is not confirmed.");
|
||||
} catch (RuntimeException | Error ignored) { }
|
||||
throw failure;
|
||||
}
|
||||
return null;
|
||||
}) {
|
||||
@Override protected void done() {
|
||||
// Late admission failures must be retryable after this owner completes.
|
||||
shutdownSave.compareAndSet(this, null);
|
||||
}
|
||||
};
|
||||
FutureTask<Void> existing = shutdownSave.compareAndExchange(null, candidate);
|
||||
if (existing == null) {
|
||||
save = candidate;
|
||||
try {
|
||||
if (shutdownPublicationOwner.get() == null) {
|
||||
// Unload can clear these fields even while their original work survives.
|
||||
// Keep the snapshot with its lease through every rejected or failed attempt.
|
||||
var producers = plugin.getVoteTimer();
|
||||
var coreWriters = plugin.getServerDataWriterExecutors();
|
||||
var retainedWriters = coreWriters == null ? null : coreWriters.clone();
|
||||
var fence = plugin.getServerDataPublicationFence();
|
||||
shutdownPublicationOwner.set(new ShutdownPublication(
|
||||
fence == null ? null : fence.acquire(), producers, retainedWriters));
|
||||
}
|
||||
startShutdownWorker(candidate);
|
||||
}
|
||||
catch (RuntimeException | Error failure) {
|
||||
// Even a rejected worker retains the queue owner for an explicit retry.
|
||||
candidate.cancel(false);
|
||||
shutdownSave.compareAndSet(candidate, null);
|
||||
throw failure;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
save.get(1, TimeUnit.SECONDS);
|
||||
shutdownSave.compareAndSet(save, null);
|
||||
} catch (TimeoutException pending) {
|
||||
plugin.getLogger().warning("Timed vote shutdown persistence is still pending; its worker retains ownership. Recovery is not yet confirmed.");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
plugin.getLogger().warning("Timed vote shutdown persistence wait was interrupted; its worker retains ownership.");
|
||||
} catch (ExecutionException failure) {
|
||||
shutdownSave.compareAndSet(save, null);
|
||||
Throwable cause = failure.getCause();
|
||||
if (cause instanceof Error error) throw error;
|
||||
if (cause instanceof RuntimeException runtime) throw runtime;
|
||||
throw new IllegalStateException("Timed vote shutdown persistence failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
/** Kept separate so worker-admission failure can be exercised without replacing persistence. */
|
||||
protected void startShutdownWorker(Runnable task) {
|
||||
Thread worker = new Thread(task, "VotingPlugin-timed-vote-shutdown");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void awaitRetiringProducers(java.util.concurrent.ScheduledExecutorService producers) {
|
||||
if (producers == null || !producers.isShutdown()) return;
|
||||
awaitWriterTermination(producers);
|
||||
}
|
||||
|
||||
private void awaitWriterTermination(java.util.concurrent.ScheduledExecutorService producers) {
|
||||
if (producers == null) return;
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
while (!producers.isTerminated()) {
|
||||
try { producers.awaitTermination(100, TimeUnit.MILLISECONDS); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
} finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
}
|
||||
|
||||
/** Remove only a successfully saved prefix; failures retain the same next slot for retry. */
|
||||
private void persistPendingVotes() {
|
||||
VoteTimeQueue vote;
|
||||
while ((vote = timeChangeQueue.peek()) != null) {
|
||||
if (vote == dispatchingVote) {
|
||||
if (dispatchStarted) retainUncertainDispatch();
|
||||
else {
|
||||
var pending = timeChangeQueue.stream().toList();
|
||||
plugin.getServerData().abortTimedVoteDispatch(dispatchId, pending);
|
||||
timeChangeQueue.removeAll(pending);
|
||||
persistedShutdownVotes = pending.size();
|
||||
clearDispatch();
|
||||
}
|
||||
} else {
|
||||
plugin.getServerData().addTimeVoted(persistedShutdownVotes, vote);
|
||||
persistedShutdownVotes++;
|
||||
timeChangeQueue.remove(vote);
|
||||
}
|
||||
}
|
||||
timeChangeQueue.clear();
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,13 @@ public class VoteTimeQueue {
|
||||
@Getter
|
||||
@Setter
|
||||
private long time;
|
||||
/** Process-local original ingress order; intentionally excluded from persisted cache rows. */
|
||||
@Getter
|
||||
@Setter
|
||||
private transient long backendObservationOrder;
|
||||
@Getter
|
||||
@Setter
|
||||
private UUID voteId;
|
||||
/** 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,6 +51,19 @@ 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
@@ -0,0 +1,269 @@
|
||||
package com.bencodez.votingplugin.topvoter;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.LongSupplier;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
/** Bounded display snapshots only; raw rankings and award inputs stay untouched. */
|
||||
public final class TopVoterDisplayFilter implements AutoCloseable, Listener {
|
||||
private static final int MAX_PENDING = 128;
|
||||
private static final int MAX_CACHED = 8192;
|
||||
private static final long MAX_AGE = java.util.concurrent.TimeUnit.SECONDS.toNanos(30);
|
||||
private record Identity(UUID uuid, String name) { }
|
||||
private static final class Evidence {
|
||||
private PlayerPresence value = PlayerPresence.UNKNOWN;
|
||||
private long completed;
|
||||
private boolean pending = true;
|
||||
private Set<UUID> identities = Set.of();
|
||||
private long joinGeneration;
|
||||
private TopVoterPlayer snapshot;
|
||||
private List<UUID> aliases;
|
||||
private final CompletableFuture<Void> settled = new CompletableFuture<>();
|
||||
}
|
||||
private final Function<TopVoterPlayer, List<UUID>> identities;
|
||||
private final BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup;
|
||||
private final LongSupplier clock;
|
||||
private final LongSupplier rankingRowBudget;
|
||||
private volatile int cacheLimit = MAX_CACHED;
|
||||
private final LinkedHashMap<Identity, Evidence> cache = new LinkedHashMap<>(16, 0.75f, true);
|
||||
private int pending;
|
||||
private boolean closed;
|
||||
private long joinGeneration;
|
||||
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, CompletionStage<PlayerPresence>> lookup) {
|
||||
this(lookup, System::nanoTime);
|
||||
}
|
||||
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, CompletionStage<PlayerPresence>> lookup, LongSupplier clock) {
|
||||
this(player -> List.of(player.getUuid()), (player, aliases) -> lookup.apply(player), clock);
|
||||
Objects.requireNonNull(lookup);
|
||||
}
|
||||
|
||||
/** Check the captured cache-only scope asynchronously; revalidate it before using negative evidence. */
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, List<UUID>> identities,
|
||||
BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup) {
|
||||
this(identities, lookup, System::nanoTime);
|
||||
}
|
||||
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, List<UUID>> identities,
|
||||
BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup, LongSupplier clock) {
|
||||
this(identities, lookup, clock, () -> 0L);
|
||||
}
|
||||
|
||||
/** The owner supplies the row count already retained in current and historical ranking maps. */
|
||||
public TopVoterDisplayFilter(Function<TopVoterPlayer, List<UUID>> identities,
|
||||
BiFunction<TopVoterPlayer, List<UUID>, CompletionStage<PlayerPresence>> lookup, LongSupplier clock,
|
||||
LongSupplier rankingRowBudget) {
|
||||
this.identities = Objects.requireNonNull(identities);
|
||||
this.lookup = Objects.requireNonNull(lookup);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.rankingRowBudget = Objects.requireNonNull(rankingRowBudget);
|
||||
}
|
||||
|
||||
/** Pending, failed, missing or stale evidence remains visible until a checked result arrives. */
|
||||
public LinkedHashMap<TopVoterPlayer, Integer> project(Map<TopVoterPlayer, Integer> rankings, boolean enabled) {
|
||||
if (enabled) reserveRankingRows(rankings.size());
|
||||
var visible = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
for (var entry : rankings.entrySet()) {
|
||||
if (!enabled || evidence(entry.getKey()) != PlayerPresence.NEVER_JOINED)
|
||||
visible.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private void reserveRankingRows(int rows) {
|
||||
long requested = rows;
|
||||
// Read owner state outside the evidence monitor. A racing replacement may leave
|
||||
// a temporary unknown budget; keep the previous bound until the next refresh.
|
||||
try { requested = Math.max(requested, rankingRowBudget.getAsLong()); }
|
||||
catch (RuntimeException unavailable) { requested = Math.max(requested, cacheLimit); }
|
||||
synchronized (cache) {
|
||||
cacheLimit = (int) Math.min(Integer.MAX_VALUE, Math.max(MAX_CACHED, requested));
|
||||
trimCompletedEvidence(cacheLimit);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called under cache; in-flight requests retain their bounded admission until completion. */
|
||||
private void trimCompletedEvidence(int limit) {
|
||||
var oldest = cache.entrySet().iterator();
|
||||
while (cache.size() > limit && oldest.hasNext()) {
|
||||
if (!oldest.next().getValue().pending) oldest.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Await admitted checks without blocking their worker; unavailable rows stay visible. */
|
||||
public CompletionStage<LinkedHashMap<TopVoterPlayer, Integer>> projectAsync(
|
||||
Map<TopVoterPlayer, Integer> rankings, boolean enabled) {
|
||||
var snapshot = new LinkedHashMap<>(rankings);
|
||||
if (!enabled) return CompletableFuture.completedFuture(snapshot);
|
||||
project(snapshot, true);
|
||||
var captured = new LinkedHashMap<Identity, Evidence>();
|
||||
var waiting = new java.util.ArrayList<CompletableFuture<Void>>();
|
||||
synchronized (cache) {
|
||||
for (TopVoterPlayer player : snapshot.keySet()) {
|
||||
if (player == null || player.getUuid() == null) continue;
|
||||
Identity key = new Identity(player.getUuid(), player.getPlayerName());
|
||||
Evidence record = cache.get(key);
|
||||
if (record != null) {
|
||||
captured.put(key, record);
|
||||
if (record.pending) waiting.add(record.settled);
|
||||
}
|
||||
}
|
||||
}
|
||||
return CompletableFuture.allOf(waiting.toArray(CompletableFuture[]::new))
|
||||
.completeOnTimeout(null, 5, java.util.concurrent.TimeUnit.SECONDS).thenApply(ignored -> {
|
||||
var visible = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
for (var entry : snapshot.entrySet()) {
|
||||
TopVoterPlayer player = entry.getKey();
|
||||
Identity key = player == null ? null : new Identity(player.getUuid(), player.getPlayerName());
|
||||
Evidence record = captured.get(key);
|
||||
if (record == null || cachedValue(key, record) != PlayerPresence.NEVER_JOINED)
|
||||
visible.put(player, entry.getValue());
|
||||
}
|
||||
return visible;
|
||||
});
|
||||
}
|
||||
|
||||
private PlayerPresence evidence(TopVoterPlayer player) {
|
||||
if (player == null || player.getUuid() == null) return PlayerPresence.UNKNOWN;
|
||||
Identity key = new Identity(player.getUuid(), player.getPlayerName());
|
||||
Evidence record;
|
||||
boolean reuse;
|
||||
synchronized (cache) {
|
||||
if (closed) return PlayerPresence.UNKNOWN;
|
||||
record = cache.get(key);
|
||||
reuse = record != null && (record.pending || clock.getAsLong() - record.completed < MAX_AGE);
|
||||
if (!reuse) {
|
||||
if (pending >= MAX_PENDING) return PlayerPresence.UNKNOWN;
|
||||
if (record != null) cache.remove(key);
|
||||
if (cache.size() >= cacheLimit) trimCompletedEvidence(cacheLimit - 1);
|
||||
if (cache.size() >= cacheLimit) return PlayerPresence.UNKNOWN;
|
||||
record = new Evidence(); cache.put(key, record); pending++;
|
||||
record.joinGeneration = joinGeneration;
|
||||
record.snapshot = new TopVoterPlayer(key.uuid(), key.name(), player.getLastVoteTime());
|
||||
}
|
||||
}
|
||||
if (reuse) return cachedValue(key, record);
|
||||
Evidence admitted = record;
|
||||
try {
|
||||
List<UUID> aliases = resolveScope(admitted.snapshot);
|
||||
if (aliases == null) {
|
||||
finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
return PlayerPresence.UNKNOWN;
|
||||
}
|
||||
var checked = new java.util.HashSet<UUID>();
|
||||
for (UUID alias : aliases) if (alias != null) checked.add(alias);
|
||||
boolean valid;
|
||||
synchronized (cache) {
|
||||
valid = !closed && cache.get(key) == admitted && admitted.joinGeneration == joinGeneration;
|
||||
if (valid) {
|
||||
admitted.identities = Set.copyOf(checked);
|
||||
admitted.aliases = aliases;
|
||||
} else cache.remove(key, admitted);
|
||||
}
|
||||
if (!valid) {
|
||||
finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
return PlayerPresence.UNKNOWN;
|
||||
}
|
||||
CompletionStage<PlayerPresence> request = lookup.apply(admitted.snapshot, aliases);
|
||||
if (request == null) finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
else request.whenComplete((result, failure) -> finish(key, admitted,
|
||||
failure == null && result != null ? result : PlayerPresence.UNKNOWN));
|
||||
} catch (RuntimeException unavailable) {
|
||||
finish(key, admitted, PlayerPresence.UNKNOWN);
|
||||
}
|
||||
return cachedValue(key, admitted);
|
||||
}
|
||||
|
||||
private List<UUID> resolveScope(TopVoterPlayer snapshot) {
|
||||
List<UUID> resolved = identities.apply(snapshot);
|
||||
if (resolved == null || resolved.isEmpty() || resolved.size() > 8) return null;
|
||||
// Preserve null uncertainty markers and detach the provider from mutable cache data.
|
||||
return java.util.Collections.unmodifiableList(new java.util.ArrayList<>(resolved));
|
||||
}
|
||||
|
||||
private boolean matchesScope(Evidence record) {
|
||||
try {
|
||||
return record.aliases != null && record.aliases.equals(resolveScope(record.snapshot));
|
||||
} catch (RuntimeException unavailable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerPresence cachedValue(Identity key, Evidence record) {
|
||||
PlayerPresence value;
|
||||
synchronized (cache) {
|
||||
if (closed || cache.get(key) != record || record.pending
|
||||
|| clock.getAsLong() - record.completed >= MAX_AGE) return PlayerPresence.UNKNOWN;
|
||||
value = record.value;
|
||||
}
|
||||
// The resolver is cache-only, but must never run under the evidence-cache monitor.
|
||||
if (value == PlayerPresence.NEVER_JOINED && !matchesScope(record)) {
|
||||
synchronized (cache) { cache.remove(key, record); }
|
||||
return PlayerPresence.UNKNOWN;
|
||||
}
|
||||
synchronized (cache) {
|
||||
return !closed && cache.get(key) == record && !record.pending
|
||||
&& clock.getAsLong() - record.completed < MAX_AGE ? record.value : PlayerPresence.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(Identity key, Evidence record, PlayerPresence result) {
|
||||
boolean currentScope = result != PlayerPresence.NEVER_JOINED || matchesScope(record);
|
||||
synchronized (cache) {
|
||||
if (!record.pending) return;
|
||||
record.pending = false; pending--;
|
||||
if (!closed && cache.get(key) == record) {
|
||||
if (!currentScope) cache.remove(key, record);
|
||||
else { record.value = result; record.completed = clock.getAsLong(); }
|
||||
}
|
||||
trimCompletedEvidence(cacheLimit);
|
||||
}
|
||||
// Completion may invoke projection callbacks, which resolve aliases outside this monitor.
|
||||
record.settled.complete(null);
|
||||
}
|
||||
|
||||
/** A newly observed join revokes matching negative snapshots and pending stale completions. */
|
||||
public void joined(UUID uuid, String name) {
|
||||
var invalidated = new java.util.ArrayList<Evidence>();
|
||||
synchronized (cache) {
|
||||
joinGeneration++;
|
||||
cache.entrySet().removeIf(entry -> {
|
||||
boolean matches = Objects.equals(entry.getKey().uuid(), uuid)
|
||||
|| (uuid != null && entry.getValue().identities.contains(uuid))
|
||||
|| (name != null && name.equalsIgnoreCase(entry.getKey().name()));
|
||||
if (matches) invalidated.add(entry.getValue());
|
||||
return matches;
|
||||
});
|
||||
}
|
||||
for (Evidence record : invalidated) record.settled.complete(null);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
joined(event.getPlayer().getUniqueId(), event.getPlayer().getName());
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
List<Evidence> retiring;
|
||||
synchronized (cache) {
|
||||
closed = true;
|
||||
retiring = new java.util.ArrayList<>(cache.values());
|
||||
cache.clear();
|
||||
}
|
||||
for (Evidence record : retiring) record.settled.complete(null);
|
||||
}
|
||||
}
|
||||
+28
-18
@@ -113,11 +113,12 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] getTopVotersWeekly() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(plugin.getTopVoter(TopVoter.Weekly).keySet());
|
||||
var board = plugin.getTopVoterDisplay(TopVoter.Weekly);
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(board.keySet());
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine().replace("%num%", "" + (i + 1))
|
||||
.replace("%player%", users.get(i).getPlayerName())
|
||||
.replace("%votes%", "" + plugin.getTopVoter(TopVoter.Weekly).get(users.get(i)));
|
||||
.replace("%votes%", "" + board.get(users.get(i)));
|
||||
msg.add(line);
|
||||
}
|
||||
msg = ArrayUtils.colorize(msg);
|
||||
@@ -638,11 +639,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return the string[]
|
||||
*/
|
||||
public String[] topVoterAllTime(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.AllTime).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -662,7 +664,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterAllTime());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -676,11 +679,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterDaily(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Daily).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Daily).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -700,7 +704,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterDaily());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -714,11 +719,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterMonthly(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Monthly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -738,7 +744,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterMonthly());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
@@ -752,7 +759,7 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersAllTime() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoter(TopVoter.AllTime).entrySet());
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoterDisplay(TopVoter.AllTime).entrySet());
|
||||
int i = 0;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : list) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
@@ -778,7 +785,8 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersDaily() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(plugin.getTopVoter(TopVoter.Daily).keySet());
|
||||
var board = plugin.getTopVoterDisplay(TopVoter.Daily);
|
||||
ArrayList<TopVoterPlayer> users = new ArrayList<>(board.keySet());
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
line = line.replace("%num%", "" + (i + 1));
|
||||
@@ -787,7 +795,7 @@ public class TopVoterHandler implements Listener {
|
||||
} catch (Exception ex) {
|
||||
VotingPluginMain.plugin.debug(ex);
|
||||
}
|
||||
line = line.replace("%votes%", "" + plugin.getTopVoter(TopVoter.Monthly).get(users.get(i)));
|
||||
line = line.replace("%votes%", "" + board.get(users.get(i)));
|
||||
msg.add(line);
|
||||
}
|
||||
|
||||
@@ -801,7 +809,7 @@ public class TopVoterHandler implements Listener {
|
||||
*/
|
||||
public String[] topVotersMonthly() {
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoter(TopVoter.Monthly).entrySet());
|
||||
List<Entry<TopVoterPlayer, Integer>> list = new LinkedList<>(plugin.getTopVoterDisplay(TopVoter.Monthly).entrySet());
|
||||
int i = 0;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : list) {
|
||||
String line = "%num%: %player%, %votes%";
|
||||
@@ -827,11 +835,12 @@ public class TopVoterHandler implements Listener {
|
||||
* @return array of formatted top voter lines
|
||||
*/
|
||||
public String[] topVoterWeekly(int page) {
|
||||
int pagesize = plugin.getConfigFile().getFormatPageSize();
|
||||
page = Math.max(1, page);
|
||||
int pagesize = Math.max(1, plugin.getConfigFile().getFormatPageSize());
|
||||
ArrayList<String> msg = new ArrayList<>();
|
||||
ArrayList<String> topVoters = new ArrayList<>();
|
||||
int count = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoter(TopVoter.Weekly).entrySet()) {
|
||||
for (Entry<TopVoterPlayer, Integer> entry : plugin.getTopVoterDisplay(TopVoter.Weekly).entrySet()) {
|
||||
String line = plugin.getConfigFile().getFormatCommandsVoteTopLine();
|
||||
line = line.replace("%num%", "" + count);
|
||||
line = line.replace("%player%", entry.getKey().getPlayerName());
|
||||
@@ -851,7 +860,8 @@ public class TopVoterHandler implements Listener {
|
||||
title = title.replace("%Top%", plugin.getConfigFile().getFormatTopVoterWeekly());
|
||||
msg.add(MessageAPI.colorize(title));
|
||||
|
||||
for (int i = (page - 1) * pagesize; (i < topVoters.size()) && (i < (((page - 1) * pagesize) + 10)); i++) {
|
||||
long start = (long) (page - 1) * pagesize;
|
||||
for (int i = (int) Math.min(start, topVoters.size()); i < topVoters.size() && i < start + pagesize; i++) {
|
||||
msg.add(topVoters.get(i));
|
||||
}
|
||||
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.bencodez.votingplugin.topvoter;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** UUID-only evidence scopes; never resolves a profile or loads plugin/server storage. */
|
||||
public final class TopVoterPresenceIdentity {
|
||||
private TopVoterPresenceIdentity() { }
|
||||
|
||||
/** A null alias explicitly prevents uncertain migration evidence becoming negative. */
|
||||
public static List<UUID> identities(TopVoterPlayer row, boolean onlineMode, Function<String, String> cachedUuid) {
|
||||
var identities = new ArrayList<UUID>();
|
||||
if (row == null || row.getUuid() == null) { identities.add(null); return identities; }
|
||||
identities.add(row.getUuid());
|
||||
String name = row.getPlayerName();
|
||||
if (name == null || name.isBlank() || name.length() > 64 || !name.equals(name.trim())) {
|
||||
identities.add(null); return identities;
|
||||
}
|
||||
UUID offline = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
|
||||
if (!identities.contains(offline)) identities.add(offline);
|
||||
UUID current = null;
|
||||
try {
|
||||
String known = cachedUuid.apply(name);
|
||||
if (known != null && !known.isBlank()) {
|
||||
current = UUID.fromString(known);
|
||||
if (!current.toString().equalsIgnoreCase(known)) { identities.add(null); return identities; }
|
||||
}
|
||||
} catch (RuntimeException unavailable) { identities.add(null); return identities; }
|
||||
if (current != null && !identities.contains(current)) identities.add(current);
|
||||
// This validates known aliases; online historical completeness is checked
|
||||
// separately against retained server history by the asynchronous provider.
|
||||
boolean resolvable = onlineMode ? row.getUuid().version() == 4 && row.getUuid().variant() == 2
|
||||
&& (current == null || current.equals(row.getUuid()))
|
||||
: offline.equals(row.getUuid()) || offline.equals(current);
|
||||
if (!resolvable) identities.add(null);
|
||||
return identities;
|
||||
}
|
||||
}
|
||||
+38
-4
@@ -93,10 +93,20 @@ public final class SharedMysqlCacheReconciler {
|
||||
public static void invalidateAndRefresh(VotingPluginMain plugin, String uuid, String... columns) {
|
||||
invalidate(plugin, uuid, columns);
|
||||
if (plugin == null || uuid == null) return;
|
||||
String pointColumn = plugin.getBungeeSettings().isPerServerPoints()
|
||||
? plugin.getBungeeSettings().getServerNameStorage() + "_Points" : "Points";
|
||||
boolean affectsPoints = columns != null && java.util.Arrays.asList(columns).contains(pointColumn);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
java.util.function.Consumer<Boolean> completion = affectsPoints && placeholders != null
|
||||
? placeholders.sharedPointRefillCompletion(UUID.fromString(uuid)) : null;
|
||||
try {
|
||||
plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuid), false).cacheAsync();
|
||||
plugin.getBukkitScheduler().runTaskAsynchronously(plugin,
|
||||
() -> tryInvalidateAndRefreshOnWorker(plugin, uuid, columns));
|
||||
} catch (RuntimeException refreshFailure) {
|
||||
plugin.debug(refreshFailure);
|
||||
} finally {
|
||||
// This admission is not storage evidence. Only the actual worker can recover rendering.
|
||||
if (completion != null) completion.accept(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,13 +116,37 @@ public final class SharedMysqlCacheReconciler {
|
||||
* A failed refill leaves the snapshot explicitly unavailable, not false zero.
|
||||
*/
|
||||
public static void invalidateAndRefreshOnWorker(VotingPluginMain plugin, String uuid, String... columns) {
|
||||
invalidate(plugin, uuid, columns);
|
||||
if (plugin == null || uuid == null) return;
|
||||
tryInvalidateAndRefreshOnWorker(plugin, uuid, columns);
|
||||
}
|
||||
|
||||
static boolean tryInvalidateAndRefreshOnWorker(VotingPluginMain plugin, String uuid, String... columns) {
|
||||
if (plugin == null || uuid == null) return false;
|
||||
UUID playerUuid = UUID.fromString(uuid);
|
||||
String pointColumn = plugin.getBungeeSettings().isPerServerPoints()
|
||||
? plugin.getBungeeSettings().getServerNameStorage() + "_Points" : "Points";
|
||||
boolean affectsPoints = columns != null && java.util.Arrays.asList(columns).contains(pointColumn);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
java.util.function.Consumer<Boolean> completion = affectsPoints && placeholders != null
|
||||
? placeholders.sharedPointRefillCompletion(playerUuid) : null;
|
||||
boolean verified = false;
|
||||
try {
|
||||
plugin.getUserManager().getDataManager().cacheUser(UUID.fromString(uuid), null);
|
||||
invalidate(plugin, uuid, columns);
|
||||
var manager = plugin.getUserManager().getDataManager();
|
||||
manager.cacheUser(playerUuid, null);
|
||||
var cache = manager.getUserDataCache().get(playerUuid);
|
||||
if (cache != null) synchronized (cache) {
|
||||
// A complete published row may legitimately omit the selected points key.
|
||||
// Rendering separately rejects present malformed/unsupported values.
|
||||
verified = manager.getUserDataCache().get(playerUuid) == cache
|
||||
&& cache.hasPublishedStorageSnapshot();
|
||||
}
|
||||
return true;
|
||||
} catch (RuntimeException refreshFailure) {
|
||||
plugin.getLogger().warning("Shared MySQL cache refresh is unavailable after mutation; stale data was not published.");
|
||||
plugin.debug(refreshFailure);
|
||||
return false;
|
||||
} finally {
|
||||
if (completion != null) completion.accept(verified);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+326
-159
@@ -21,6 +21,7 @@ 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. */
|
||||
@@ -124,7 +125,7 @@ final class SharedMysqlPointMutator {
|
||||
int previousTotal = cachedPoints(user, pointsColumn);
|
||||
int predictedTotal = previousTotal + amount;
|
||||
cachePredictedPoints(user, predictedTotal, pointsColumn);
|
||||
if (!run(() -> update(user, amount, false, pointsColumn), true)) {
|
||||
if (!runPointMutation(user, () -> update(user, amount, false, pointsColumn), true)) {
|
||||
discardOptimisticPoints(user, pointsColumn);
|
||||
if (failOnRejectedAdmission) throw new IllegalStateException("Point mutation storage worker is unavailable");
|
||||
return previousTotal;
|
||||
@@ -185,16 +186,29 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) {
|
||||
if (operationId == null || operationId.isEmpty()) return addCommittedToColumn(user, amount, pointsColumn);
|
||||
drainCache(user, pointsColumn);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
UUID uuid = UUID.fromString(user.getUUID());
|
||||
if (placeholders != null) placeholders.beginSharedPointDebit(uuid);
|
||||
boolean confirmed = false;
|
||||
try {
|
||||
drainCache(user, pointsColumn);
|
||||
SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql())
|
||||
.add(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis());
|
||||
confirmed = true;
|
||||
return new AddResult(true, result.total());
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
return new AddResult(MutationOutcome.INDETERMINATE, 0);
|
||||
} finally {
|
||||
discardPointsCache(user, pointsColumn);
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), pointsColumn);
|
||||
if (placeholders != null) {
|
||||
var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(uuid);
|
||||
boolean available = false;
|
||||
if (cache != null) synchronized (cache) {
|
||||
available = refreshed && confirmed && cache.hasPublishedStorageSnapshot();
|
||||
}
|
||||
placeholders.finishSharedPointDebit(uuid, confirmed, available);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,18 +223,36 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
AddResult removeCommittedFromColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) {
|
||||
if (operationId == null || operationId.isEmpty()) return new AddResult(false, 0);
|
||||
drainCache(user, pointsColumn);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
UUID uuid = UUID.fromString(user.getUUID());
|
||||
if (placeholders != null) placeholders.beginSharedPointDebit(uuid);
|
||||
AddResult outcome = new AddResult(MutationOutcome.INDETERMINATE, 0);
|
||||
boolean refreshed;
|
||||
try {
|
||||
drainCache(user, pointsColumn);
|
||||
SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql())
|
||||
.subtract(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis());
|
||||
return new AddResult(true, result.total());
|
||||
outcome = new AddResult(true, result.total());
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
return new AddResult(failure instanceof SharedPointAdditionJournal.DebitRejectedException
|
||||
? MutationOutcome.REJECTED : MutationOutcome.INDETERMINATE, 0);
|
||||
if (failure instanceof SharedPointAdditionJournal.DebitRejectedException) {
|
||||
outcome = new AddResult(MutationOutcome.REJECTED, 0);
|
||||
} else {
|
||||
logFailure(failure);
|
||||
outcome = new AddResult(MutationOutcome.INDETERMINATE, 0);
|
||||
}
|
||||
} finally {
|
||||
discardPointsCache(user, pointsColumn);
|
||||
refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), pointsColumn);
|
||||
if (placeholders != null) {
|
||||
var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(uuid);
|
||||
boolean available = false;
|
||||
if (cache != null) synchronized (cache) {
|
||||
available = refreshed && outcome.outcome() != MutationOutcome.INDETERMINATE
|
||||
&& cache.hasPublishedStorageSnapshot();
|
||||
}
|
||||
placeholders.finishSharedPointDebit(uuid, outcome.outcome() != MutationOutcome.INDETERMINATE, available);
|
||||
}
|
||||
}
|
||||
return new AddResult(outcome.outcome(), outcome.total(), refreshed);
|
||||
}
|
||||
|
||||
/** Looks up a completed retry before its Bukkit receive event is dispatched. */
|
||||
@@ -326,7 +358,20 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
boolean set(VotingPluginUser user, int value, boolean async) {
|
||||
String pointsColumn = user.getPointsPath();
|
||||
return run(() -> setAbsolute(user, value, pointsColumn), async);
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
Consumer<Boolean> admission = placeholders == null ? null
|
||||
: placeholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID()));
|
||||
boolean accepted = run(() -> {
|
||||
boolean settled = false;
|
||||
try {
|
||||
setAbsolute(user, value, pointsColumn);
|
||||
settled = true;
|
||||
} finally {
|
||||
if (admission != null) admission.accept(settled);
|
||||
}
|
||||
}, async);
|
||||
if (!accepted && admission != null) admission.accept(false);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
boolean setCommitted(VotingPluginUser user, int value) {
|
||||
@@ -339,7 +384,7 @@ final class SharedMysqlPointMutator {
|
||||
|
||||
void cap(VotingPluginUser user, int maximum, boolean async) {
|
||||
String pointsColumn = user.getPointsPath();
|
||||
run(() -> capAt(user, maximum, pointsColumn), async);
|
||||
runPointMutation(user, () -> capAt(user, maximum, pointsColumn), async);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,7 +402,7 @@ final class SharedMysqlPointMutator {
|
||||
int predictedTotal = (int) Math.max(Integer.MIN_VALUE,
|
||||
Math.min((long) previousTotal + amount, maximum));
|
||||
cachePredictedPoints(user, predictedTotal, pointsColumn);
|
||||
if (!run(() -> addAndCapAt(user, amount, maximum, pointsColumn), true)) {
|
||||
if (!runPointMutation(user, () -> addAndCapAt(user, amount, maximum, pointsColumn), true)) {
|
||||
discardOptimisticPoints(user, pointsColumn);
|
||||
}
|
||||
}
|
||||
@@ -370,7 +415,7 @@ final class SharedMysqlPointMutator {
|
||||
if (!async) return remove(user, amount);
|
||||
String pointsColumn = user.getPointsPath();
|
||||
boolean predictedSuccess = cachedPoints(user, pointsColumn) >= amount;
|
||||
boolean submitted = run(() -> update(user, -amount, true, pointsColumn), true);
|
||||
boolean submitted = runPointMutation(user, () -> 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;
|
||||
@@ -498,41 +543,40 @@ final class SharedMysqlPointMutator {
|
||||
org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : sourcePlayer;
|
||||
String sourcePoints = source.getPointsPath();
|
||||
String targetPoints = target.getPointsPath();
|
||||
var reservationPublication = beginPointPublication(source);
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
drainCache(source);
|
||||
drainCache(target);
|
||||
} catch (RuntimeException cacheFailure) {
|
||||
plugin.debug(cacheFailure);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
return;
|
||||
}
|
||||
MySQL table = plugin.getMysql();
|
||||
String transferId = UUID.randomUUID().toString();
|
||||
String owner = UUID.randomUUID().toString();
|
||||
SharedPointTransferJournal journal;
|
||||
SharedPointTransferJournal prepared = null;
|
||||
boolean known = true;
|
||||
boolean reserved = false;
|
||||
PointTransferResult failed = PointTransferResult.UNAVAILABLE;
|
||||
try {
|
||||
journal = SharedPointTransferJournal.forTable(table);
|
||||
recoverTransfers(plugin, journal);
|
||||
if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(),
|
||||
debitAmount, System.currentTimeMillis())) {
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.INSUFFICIENT_POINTS);
|
||||
return;
|
||||
}
|
||||
// The source cache may have been recreated while the reservation was being
|
||||
// committed. Invalidate its points after the durable debit, before any later
|
||||
// dump can restore the pre-debit balance.
|
||||
discardPointsCache(source, sourcePoints);
|
||||
drainCache(source);
|
||||
drainCache(target);
|
||||
prepared = SharedPointTransferJournal.forTable(table);
|
||||
recoverTransfers(plugin, prepared);
|
||||
known = false;
|
||||
reserved = prepared.reserve(transferId, source.getUUID(), sourcePoints, debitAmount,
|
||||
target.getUUID(), debitAmount, System.currentTimeMillis());
|
||||
known = true;
|
||||
failed = PointTransferResult.INSUFFICIENT_POINTS;
|
||||
} catch (SQLException failure) {
|
||||
// An acknowledgement/confirmation failure can follow a committed
|
||||
// reservation debit. Never allow a cache recreated during the unknown
|
||||
// outcome to flush the pre-debit source balance over it.
|
||||
discardPointsCache(source, sourcePoints);
|
||||
known |= failure instanceof SharedPointTransferJournal.PreparationFailure;
|
||||
logFailure(failure);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
} catch (RuntimeException cacheFailure) {
|
||||
plugin.debug(cacheFailure);
|
||||
} finally {
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
|
||||
finishPointPublication(reservationPublication, source, sourcePoints, known, refreshed);
|
||||
}
|
||||
if (!reserved) {
|
||||
completeOnBukkit(sourcePlayer, completion, failed);
|
||||
return;
|
||||
}
|
||||
SharedPointTransferJournal journal = prepared;
|
||||
|
||||
/*
|
||||
* Do not claim the reservation until the Bukkit approval task has actually
|
||||
@@ -562,6 +606,7 @@ final class SharedMysqlPointMutator {
|
||||
// No reservation exists when the initial persistence task is rejected.
|
||||
// Still complete the command contract on the source entity lane.
|
||||
plugin.debug(schedulingFailure);
|
||||
finishPointPublication(reservationPublication, source, sourcePoints, true, false);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -574,8 +619,7 @@ final class SharedMysqlPointMutator {
|
||||
System.currentTimeMillis());
|
||||
if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) {
|
||||
try {
|
||||
journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
discardPointsCache(source, sourcePoints);
|
||||
refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
}
|
||||
@@ -713,10 +757,7 @@ final class SharedMysqlPointMutator {
|
||||
} catch (SQLException markerFailure) {
|
||||
boolean refunded = false;
|
||||
try {
|
||||
refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
if (refunded) {
|
||||
discardPointsCache(source, sourcePoints);
|
||||
}
|
||||
refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
}
|
||||
@@ -736,17 +777,24 @@ final class SharedMysqlPointMutator {
|
||||
int debitAmount,
|
||||
Consumer<PointTransferResult> completion, SharedPointTransferJournal journal, String transferId, String owner,
|
||||
String sourcePoints, String targetPoints, Integer approvedAmount) {
|
||||
var sourcePublication = beginPointPublication(source);
|
||||
var targetPublication = beginPointPublication(target);
|
||||
boolean known = false;
|
||||
PointTransferResult result;
|
||||
try {
|
||||
// The hook may have recreated either cache while it ran on Bukkit.
|
||||
drainCache(target);
|
||||
|
||||
SharedPointTransferJournal.SettlementOutcome outcome;
|
||||
try {
|
||||
drainCache(target);
|
||||
outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints,
|
||||
target.getUUID(), targetPoints, debitAmount, approvedAmount);
|
||||
known = outcome != SharedPointTransferJournal.SettlementOutcome.INDETERMINATE;
|
||||
} finally {
|
||||
discardPointsCache(source, sourcePoints);
|
||||
discardPointsCache(target, targetPoints);
|
||||
boolean sourceRefreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
|
||||
boolean targetRefreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, target.getUUID(), targetPoints);
|
||||
finishPointPublication(sourcePublication, source, sourcePoints, known, sourceRefreshed);
|
||||
finishPointPublication(targetPublication, target, targetPoints, known, targetRefreshed);
|
||||
}
|
||||
result = outcome == SharedPointTransferJournal.SettlementOutcome.COMPLETED ? PointTransferResult.SUCCESS
|
||||
: outcome == SharedPointTransferJournal.SettlementOutcome.REFUNDED ? PointTransferResult.CANCELLED
|
||||
@@ -760,14 +808,29 @@ final class SharedMysqlPointMutator {
|
||||
completeOnBukkit(sourcePlayer, completion, result);
|
||||
}
|
||||
|
||||
private boolean refundReservedWithPublication(VotingPluginUser source, SharedPointTransferJournal journal,
|
||||
String transferId, String sourcePoints, int debitAmount) throws SQLException {
|
||||
var publication = beginPointPublication(source);
|
||||
boolean known = false;
|
||||
try {
|
||||
boolean refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
known = true;
|
||||
return refunded;
|
||||
} catch (SQLException failure) {
|
||||
known = failure instanceof SharedPointTransferJournal.PreparationFailure;
|
||||
throw failure;
|
||||
} finally {
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
|
||||
finishPointPublication(publication, source, sourcePoints, known, refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
private void refundReservedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer,
|
||||
Consumer<PointTransferResult> completion,
|
||||
SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount,
|
||||
RuntimeException failure) {
|
||||
try {
|
||||
if (journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount)) {
|
||||
discardPointsCache(source, sourcePoints);
|
||||
}
|
||||
refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
}
|
||||
@@ -783,24 +846,23 @@ final class SharedMysqlPointMutator {
|
||||
// HOOK_STARTED is safe to compensate because the approval task has not
|
||||
// been submitted yet. A RESERVED row is handled by its normal refund.
|
||||
if (journal.markCompensating(transferId)) {
|
||||
refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} else {
|
||||
refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
}
|
||||
} catch (SQLException markerFailure) {
|
||||
// A lost marker acknowledgement may still have committed. Both refund
|
||||
// operations are idempotent and cover either durable pre-hook state.
|
||||
try {
|
||||
refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refunded = refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
if (!refunded) {
|
||||
refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refunded = refundReservedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
}
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
}
|
||||
logFailure(markerFailure);
|
||||
}
|
||||
if (refunded) discardPointsCache(source, sourcePoints);
|
||||
if (!refunded) {
|
||||
rememberPendingCompensationMarker(plugin, transferId);
|
||||
logIndeterminateClaim(transferId);
|
||||
@@ -815,19 +877,32 @@ final class SharedMysqlPointMutator {
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
}
|
||||
|
||||
private boolean refundClaimedWithPublication(VotingPluginUser source, SharedPointTransferJournal journal,
|
||||
String transferId, String sourcePoints, int debitAmount) throws SQLException {
|
||||
var publication = beginPointPublication(source);
|
||||
boolean known = false;
|
||||
try {
|
||||
boolean refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
known = true;
|
||||
return refunded;
|
||||
} catch (SQLException refundFailure) {
|
||||
known = refundFailure instanceof SharedPointTransferJournal.PreparationFailure;
|
||||
throw refundFailure;
|
||||
} finally {
|
||||
// A later guarded fallback or refill cannot erase an earlier ambiguous write.
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, source.getUUID(), sourcePoints);
|
||||
finishPointPublication(publication, source, sourcePoints, known, refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
void refundClaimedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer,
|
||||
Consumer<PointTransferResult> completion,
|
||||
SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount,
|
||||
RuntimeException failure) {
|
||||
try {
|
||||
journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount);
|
||||
refundClaimedWithPublication(source, journal, transferId, sourcePoints, debitAmount);
|
||||
} catch (SQLException refundFailure) {
|
||||
logFailure(refundFailure);
|
||||
} finally {
|
||||
// The refund may have committed even when its acknowledgement and
|
||||
// confirmation read both failed. Invalidate a cache recreated after the
|
||||
// original debit so it cannot later overwrite either durable outcome.
|
||||
discardPointsCache(source, sourcePoints);
|
||||
}
|
||||
plugin.debug(failure);
|
||||
completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE);
|
||||
@@ -890,6 +965,23 @@ 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 {
|
||||
@@ -910,25 +1002,34 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private boolean update(VotingPluginUser user, int delta, boolean requireNonnegative, String points) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} finally {
|
||||
discardPointsCache(user, points);
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,53 +1047,74 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount, String points) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
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);
|
||||
}
|
||||
}
|
||||
} 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());
|
||||
} finally {
|
||||
discardPointsCache(user, points);
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
}
|
||||
// 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) {
|
||||
record AddResult(MutationOutcome outcome, int total, boolean sourceRefreshed) {
|
||||
AddResult(MutationOutcome outcome, int total) {
|
||||
this(outcome, total, false);
|
||||
}
|
||||
AddResult(boolean success, int total) {
|
||||
this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total);
|
||||
this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total, false);
|
||||
}
|
||||
boolean success() { return outcome == MutationOutcome.CONFIRMED; }
|
||||
}
|
||||
@@ -1002,21 +1124,29 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private boolean setAbsolute(VotingPluginUser user, int value, String points) {
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points)
|
||||
+ " = ? WHERE " + table.qi("uuid")
|
||||
+ (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, value);
|
||||
statement.setString(2, user.getUUID());
|
||||
return statement.executeUpdate() == 1;
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
return false;
|
||||
var publication = beginPointPublication(user);
|
||||
boolean known = true;
|
||||
try {
|
||||
drainCache(user, points);
|
||||
MySQL table = plugin.getMysql();
|
||||
String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points)
|
||||
+ " = ? WHERE " + table.qi("uuid")
|
||||
+ (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?");
|
||||
try (Connection connection = requireConnection(table);
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setInt(1, value);
|
||||
statement.setString(2, user.getUUID());
|
||||
known = false;
|
||||
int affected = statement.executeUpdate();
|
||||
known = true;
|
||||
return affected == 1;
|
||||
} catch (SQLException failure) {
|
||||
logFailure(failure);
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
discardPointsCache(user, points);
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,20 +1155,28 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private void capAt(VotingPluginUser user, int maximum, String points) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
discardPointsCache(user, points);
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,21 +1185,29 @@ final class SharedMysqlPointMutator {
|
||||
}
|
||||
|
||||
private void addAndCapAt(VotingPluginUser user, int amount, int maximum, String points) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
discardPointsCache(user, points);
|
||||
boolean refreshed = SharedMysqlCacheReconciler.tryInvalidateAndRefreshOnWorker(plugin, user.getUUID(), points);
|
||||
finishPointPublication(publication, user, points, known, refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,6 +1247,27 @@ final class SharedMysqlPointMutator {
|
||||
* deliberately not discarded here because it is a distinct, later write and
|
||||
* the generic absolute-value API cannot provide cross-server atomic semantics.
|
||||
*/
|
||||
private com.bencodez.votingplugin.placeholders.PlaceHolders beginPointPublication(VotingPluginUser user) {
|
||||
var placeholders = plugin.getPlaceholders();
|
||||
if (placeholders != null) placeholders.beginSharedPointDebit(UUID.fromString(user.getUUID()));
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
private void finishPointPublication(com.bencodez.votingplugin.placeholders.PlaceHolders placeholders,
|
||||
VotingPluginUser user, String column, boolean known, boolean refreshed) {
|
||||
if (placeholders == null) return;
|
||||
UUID uuid = UUID.fromString(user.getUUID());
|
||||
var caches = plugin.getUserManager().getDataManager().getUserDataCache();
|
||||
var cache = caches.get(uuid);
|
||||
boolean available = false;
|
||||
if (cache != null) synchronized (cache) {
|
||||
available = known && refreshed && caches.get(uuid) == cache && cache.hasPublishedStorageSnapshot()
|
||||
&& column.equals(user.getPointsPath())
|
||||
&& plugin.isEnabled() && plugin.getPlaceholders() == placeholders;
|
||||
}
|
||||
placeholders.finishSharedPointDebit(uuid, known, available);
|
||||
}
|
||||
|
||||
private void discardPointsCache(VotingPluginUser user) {
|
||||
discardPointsCache(user, user.getPointsPath());
|
||||
}
|
||||
|
||||
+101
-74
@@ -28,6 +28,14 @@ import com.bencodez.simpleapi.sql.mysql.DbType;
|
||||
* settlement is a second short, idempotent transaction.</p>
|
||||
*/
|
||||
final class SharedPointTransferJournal {
|
||||
/** Positive proof that this invocation did not attempt a points write. */
|
||||
static final class PreparationFailure extends SQLException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
PreparationFailure(SQLException cause) {
|
||||
super(cause.getMessage(), cause.getSQLState(), cause.getErrorCode(), cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String RESERVED = "RESERVED";
|
||||
private static final String HOOK_STARTED = "HOOK_STARTED";
|
||||
/*
|
||||
@@ -144,51 +152,59 @@ final class SharedPointTransferJournal {
|
||||
*/
|
||||
boolean reserve(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints, String targetUuid,
|
||||
int requestedCreditPoints, long now) throws SQLException {
|
||||
TransferRow existing = find(transferId);
|
||||
if (existing != null) {
|
||||
return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints)
|
||||
&& !REFUNDED.equals(existing.state);
|
||||
}
|
||||
|
||||
String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid")
|
||||
+ ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points")
|
||||
+ ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at")
|
||||
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
String points = qi(sourcePointsColumn);
|
||||
String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
|
||||
+ " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(insert);
|
||||
PreparedStatement debitStatement = connection.prepareStatement(debit)) {
|
||||
insertStatement.setString(1, transferId);
|
||||
insertStatement.setString(2, sourceUuid);
|
||||
insertStatement.setString(3, sourcePointsColumn);
|
||||
insertStatement.setString(4, targetUuid);
|
||||
insertStatement.setInt(5, debitPoints);
|
||||
insertStatement.setInt(6, requestedCreditPoints);
|
||||
insertStatement.setString(7, RESERVED);
|
||||
insertStatement.setLong(8, now);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
debitStatement.setInt(1, debitPoints);
|
||||
debitStatement.setString(2, sourceUuid);
|
||||
debitStatement.setInt(3, debitPoints);
|
||||
if (debitStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, RESERVED);
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
if (isDuplicate(failure)) {
|
||||
closeQuietly(connection);
|
||||
TransferRow duplicate = find(transferId);
|
||||
return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints,
|
||||
requestedCreditPoints) && !REFUNDED.equals(duplicate.state);
|
||||
}
|
||||
throw failure;
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
TransferRow existing = find(transferId);
|
||||
if (existing != null) {
|
||||
return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints)
|
||||
&& !REFUNDED.equals(existing.state);
|
||||
}
|
||||
|
||||
String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid")
|
||||
+ ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points")
|
||||
+ ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at")
|
||||
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
String points = qi(sourcePointsColumn);
|
||||
String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
|
||||
+ " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(insert);
|
||||
PreparedStatement debitStatement = connection.prepareStatement(debit)) {
|
||||
insertStatement.setString(1, transferId);
|
||||
insertStatement.setString(2, sourceUuid);
|
||||
insertStatement.setString(3, sourcePointsColumn);
|
||||
insertStatement.setString(4, targetUuid);
|
||||
insertStatement.setInt(5, debitPoints);
|
||||
insertStatement.setInt(6, requestedCreditPoints);
|
||||
insertStatement.setString(7, RESERVED);
|
||||
insertStatement.setLong(8, now);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
debitStatement.setInt(1, debitPoints);
|
||||
debitStatement.setString(2, sourceUuid);
|
||||
debitStatement.setInt(3, debitPoints);
|
||||
pointWriteAttempted = true;
|
||||
if (debitStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, RESERVED);
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
if (isDuplicate(failure)) {
|
||||
closeQuietly(connection);
|
||||
TransferRow duplicate = find(transferId);
|
||||
return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints,
|
||||
requestedCreditPoints) && !REFUNDED.equals(duplicate.state);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,42 +302,50 @@ final class SharedPointTransferJournal {
|
||||
*/
|
||||
boolean refundReserved(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints)
|
||||
throws SQLException {
|
||||
if (!isSafeColumn(sourcePointsColumn)) return false;
|
||||
String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id")
|
||||
+ " = ? FOR UPDATE";
|
||||
String points = qi(sourcePointsColumn);
|
||||
String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
|
||||
+ " + ? WHERE " + qi("uuid") + uuidCast();
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id")
|
||||
+ " = ?";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
selectStatement.setString(1, transferId);
|
||||
try (ResultSet result = selectStatement.executeQuery()) {
|
||||
if (!result.next() || !RESERVED.equals(result.getString(1))) {
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
if (!isSafeColumn(sourcePointsColumn)) return false;
|
||||
String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id")
|
||||
+ " = ? FOR UPDATE";
|
||||
String points = qi(sourcePointsColumn);
|
||||
String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points
|
||||
+ " + ? WHERE " + qi("uuid") + uuidCast();
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id")
|
||||
+ " = ?";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
selectStatement.setString(1, transferId);
|
||||
try (ResultSet result = selectStatement.executeQuery()) {
|
||||
if (!result.next() || !RESERVED.equals(result.getString(1))) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
try (PreparedStatement refundStatement = connection.prepareStatement(refund)) {
|
||||
refundStatement.setInt(1, debitPoints);
|
||||
refundStatement.setString(2, sourceUuid);
|
||||
pointWriteAttempted = true;
|
||||
if (refundStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
try (PreparedStatement refundStatement = connection.prepareStatement(refund)) {
|
||||
refundStatement.setInt(1, debitPoints);
|
||||
refundStatement.setString(2, sourceUuid);
|
||||
if (refundStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
|
||||
updateStatement.setString(1, REFUNDED);
|
||||
updateStatement.setString(2, transferId);
|
||||
if (updateStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, REFUNDED);
|
||||
}
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
|
||||
updateStatement.setString(1, REFUNDED);
|
||||
updateStatement.setString(2, transferId);
|
||||
if (updateStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, REFUNDED);
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +364,7 @@ final class SharedPointTransferJournal {
|
||||
+ " + ? WHERE " + qi("uuid") + uuidCast();
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id")
|
||||
+ " = ?";
|
||||
boolean pointWriteAttempted = false;
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
@@ -363,6 +388,7 @@ final class SharedPointTransferJournal {
|
||||
try (PreparedStatement refundStatement = connection.prepareStatement(refund)) {
|
||||
refundStatement.setInt(1, debitPoints);
|
||||
refundStatement.setString(2, sourceUuid);
|
||||
pointWriteAttempted = true;
|
||||
if (refundStatement.executeUpdate() != 1) {
|
||||
connection.rollback();
|
||||
return false;
|
||||
@@ -378,6 +404,7 @@ final class SharedPointTransferJournal {
|
||||
}
|
||||
return commitAndConfirm(connection, transferId, REFUNDED);
|
||||
} catch (SQLException failure) {
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,18 @@ public class UserManager {
|
||||
* Gets a VotingPluginUser by UUID.
|
||||
* @param uuid the player UUID
|
||||
* @return the VotingPluginUser
|
||||
* @deprecated UUID-only lookups can be ambiguous in offline mode because Minecraft
|
||||
* preserves name capitalization, while VotingPlugin derives its
|
||||
* offline UUID from the player name lowercased with Locale.ROOT.
|
||||
* Prefer {@link #getVotingPluginUser(UUID, String)} when both UUID
|
||||
* and player name are available; supplying the name allows the
|
||||
* existing offline UUID normalization. Use
|
||||
* {@link #getVotingPluginUser(Player)} for an online player,
|
||||
* {@link #getVotingPluginUser(OfflinePlayer)} when an offline
|
||||
* player object with a known name is available, or
|
||||
* {@link #getVotingPluginUser(String)} when only the name is known.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public VotingPluginUser getVotingPluginUser(UUID uuid) {
|
||||
LiveIdentity identity = offlineLiveIdentity(uuid);
|
||||
@@ -280,7 +291,21 @@ public class UserManager {
|
||||
* @param uuid the player UUID
|
||||
* @param loadName whether to load the player name
|
||||
* @return the VotingPluginUser
|
||||
* @deprecated UUID-only lookups can be ambiguous in offline mode because Minecraft
|
||||
* preserves name capitalization, while VotingPlugin derives its
|
||||
* offline UUID from the player name lowercased with Locale.ROOT.
|
||||
* Prefer {@link #getVotingPluginUser(UUID, String)} when both UUID
|
||||
* and player name are available; supplying the name allows the
|
||||
* existing offline UUID normalization. Use
|
||||
* {@link #getVotingPluginUser(Player)} for an online player,
|
||||
* {@link #getVotingPluginUser(OfflinePlayer)} when an offline
|
||||
* player object with a known name is available, or
|
||||
* {@link #getVotingPluginUser(String)} when only the name is known.
|
||||
* Retain this overload when the exact stored UUID and explicit
|
||||
* name-loading behavior are required; name-aware alternatives
|
||||
* may normalize the supplied identity in offline mode.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public VotingPluginUser getVotingPluginUser(UUID uuid, boolean loadName) {
|
||||
return new VotingPluginUser(plugin, uuid, loadName);
|
||||
|
||||
+2175
-2003
File diff suppressed because it is too large.
Load diff
+13
@@ -18,6 +18,19 @@ 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
@@ -0,0 +1,217 @@
|
||||
package com.bencodez.votingplugin.util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/** Memory-only admission for participating plugin generations sharing one server-data path in a JVM. */
|
||||
public final class ServerDataPublicationFence {
|
||||
private static final String PREFIX = "com.bencodez.votingplugin.ServerDataPublicationFence.owner.";
|
||||
private static final int MAX_OWNERS = 1024;
|
||||
private final Properties registry;
|
||||
private final String key;
|
||||
private final String generation;
|
||||
private final AtomicBoolean retirementStarted = new AtomicBoolean();
|
||||
private final java.util.concurrent.CompletableFuture<Void> retirementCompleted = new java.util.concurrent.CompletableFuture<>();
|
||||
|
||||
private final CompletableFuture<Long> processShutdownStarted = new CompletableFuture<>();
|
||||
private final CompletableFuture<Void> processExitCompletion = new CompletableFuture<>();
|
||||
private final AtomicBoolean processHookRemoved = new AtomicBoolean();
|
||||
private volatile Runtime processRuntime;
|
||||
private volatile Thread processHook;
|
||||
|
||||
/** Opt in during startup, before the JVM can begin invoking shutdown hooks. */
|
||||
public synchronized void retainForProcessExit() {
|
||||
if (processHook != null) return;
|
||||
var runtime = Runtime.getRuntime();
|
||||
var hook = new Thread(this::awaitProcessExit, "VotingPlugin-ServerData-Exit");
|
||||
runtime.addShutdownHook(hook);
|
||||
processRuntime = runtime;
|
||||
processHook = hook;
|
||||
processExitCompletion.whenComplete((ignored, failure) -> removeProcessHook());
|
||||
}
|
||||
|
||||
/** The first disable entry fixes the deadline; retries cannot renew it. No caller wait. */
|
||||
public void beginShutdown(long startedNanos) {
|
||||
if (processShutdownStarted.complete(startedNanos) && processHook != null) {
|
||||
CompletableFuture.delayedExecutor(remainingProcessWait(startedNanos), TimeUnit.NANOSECONDS)
|
||||
.execute(this::removeProcessHook);
|
||||
}
|
||||
}
|
||||
|
||||
private static long remainingProcessWait(long startedNanos) {
|
||||
long elapsed = Math.max(0L, System.nanoTime() - startedNanos);
|
||||
return Math.max(0L, TimeUnit.SECONDS.toNanos(15) - elapsed);
|
||||
}
|
||||
|
||||
private void awaitProcessExit() {
|
||||
long exitStarted = System.nanoTime();
|
||||
try {
|
||||
long disableStarted = processShutdownStarted.get(remainingProcessWait(exitStarted), TimeUnit.NANOSECONDS);
|
||||
long remaining = Math.min(remainingProcessWait(exitStarted), remainingProcessWait(disableStarted));
|
||||
if (remaining > 0) processExitCompletion.get(remaining, TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ExecutionException | TimeoutException pendingOrExpired) {
|
||||
// Process exit is bounded. Timeout does not acknowledge publication or release a lease.
|
||||
}
|
||||
}
|
||||
|
||||
private void removeProcessHook() {
|
||||
var hook = processHook;
|
||||
if (hook == null || !processHookRemoved.compareAndSet(false, true)) return;
|
||||
try { processRuntime.removeShutdownHook(hook); }
|
||||
catch (IllegalStateException stopping) { /* The JVM is already invoking its hooks. */ }
|
||||
}
|
||||
|
||||
private ServerDataPublicationFence(Properties registry, String key, String generation) {
|
||||
this.registry = registry;
|
||||
this.key = key;
|
||||
this.generation = generation;
|
||||
}
|
||||
|
||||
/** Runs before platform initialization and performs no filesystem operation or wait for storage. */
|
||||
public static ServerDataPublicationFence beginGeneration(Path file) {
|
||||
String identity = file.toAbsolutePath().normalize().toString();
|
||||
if (DurableFiles.isWindowsName(System.getProperty("os.name", ""))) identity = identity.toLowerCase(Locale.ROOT);
|
||||
String key;
|
||||
try {
|
||||
key = PREFIX + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(identity.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException unavailable) { throw new IllegalStateException(unavailable); }
|
||||
Properties registry = System.getProperties();
|
||||
synchronized (registry) {
|
||||
Object value = registry.get(key);
|
||||
if (value != null) {
|
||||
State previous = state(value);
|
||||
if (previous.users != 0 || !previous.retiring)
|
||||
throw new IllegalStateException("Previous server-data generation still owns storage work; replacement was not started");
|
||||
} else if (registry.keySet().stream().filter(k -> k instanceof String s && s.startsWith(PREFIX)).count() >= MAX_OWNERS) {
|
||||
throw new IllegalStateException("Server-data publication owner capacity is unavailable");
|
||||
}
|
||||
String generation = UUID.randomUUID().toString();
|
||||
registry.setProperty(key, generation + ":0:false");
|
||||
return new ServerDataPublicationFence(registry, key, generation);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hold through the entire operation, including a blocked write or admitted shutdown handoff. */
|
||||
public Lease acquire() {
|
||||
synchronized (registry) {
|
||||
State current = current();
|
||||
if (current.users >= MAX_OWNERS) throw new IllegalStateException("Server-data publication admission is unavailable");
|
||||
registry.setProperty(key, generation + ":" + (current.users + 1) + ":" + current.retiring);
|
||||
return new Lease(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing operations/retries retain ownership; a successor requires zero outstanding leases. */
|
||||
public void retire() {
|
||||
boolean drained = false;
|
||||
synchronized (registry) {
|
||||
State current = state(registry.get(key));
|
||||
if (generation.equals(current.generation)) {
|
||||
registry.setProperty(key, generation + ":" + current.users + ":true");
|
||||
drained = current.users == 0;
|
||||
}
|
||||
}
|
||||
// Completion callbacks may remove a JVM hook; never run them under the registry monitor.
|
||||
if (drained) processExitCompletion.complete(null);
|
||||
}
|
||||
|
||||
/** Reserve before asynchronous admission; a failed stage never proves drain. */
|
||||
public void retainUntilSuccessfulPublication(java.util.concurrent.CompletionStage<Void> publication) {
|
||||
java.util.Objects.requireNonNull(publication);
|
||||
Lease lease = acquire();
|
||||
publication.whenComplete((ignored, failure) -> {
|
||||
if (failure == null) lease.close();
|
||||
});
|
||||
}
|
||||
|
||||
/** Reserve before disable and release only after existing SDK writers actually terminate. */
|
||||
public java.util.concurrent.CompletionStage<Void> retainUntilTermination(java.util.concurrent.ScheduledExecutorService[] writers) {
|
||||
var snapshot = writers.clone();
|
||||
if (!retirementStarted.compareAndSet(false, true)) return retirementCompleted.minimalCompletionStage();
|
||||
Lease lease;
|
||||
try { lease = acquire(); }
|
||||
catch (RuntimeException | Error failure) { retirementStarted.set(false); throw failure; }
|
||||
try {
|
||||
Thread guardian = new Thread(() -> {
|
||||
boolean interrupted = false;
|
||||
try (lease) {
|
||||
for (var writer : snapshot) {
|
||||
if (writer == null) continue;
|
||||
while (!writer.isTerminated()) {
|
||||
try { writer.awaitTermination(100, java.util.concurrent.TimeUnit.MILLISECONDS); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
retirementCompleted.completeExceptionally(failure);
|
||||
return;
|
||||
} finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
retirementCompleted.complete(null);
|
||||
}, "VotingPlugin-server-data-retirement");
|
||||
guardian.setDaemon(true);
|
||||
guardian.start();
|
||||
} catch (RuntimeException | Error failure) {
|
||||
try { lease.close(); }
|
||||
catch (RuntimeException | Error cleanupFailure) { if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); }
|
||||
finally { retirementStarted.set(false); }
|
||||
throw failure;
|
||||
}
|
||||
return retirementCompleted.minimalCompletionStage();
|
||||
}
|
||||
|
||||
private State current() {
|
||||
State current = state(registry.get(key));
|
||||
if (registry != System.getProperties() || !generation.equals(current.generation))
|
||||
throw new IllegalStateException("Server-data publication belongs to another generation");
|
||||
return current;
|
||||
}
|
||||
|
||||
private static State state(Object value) {
|
||||
if (!(value instanceof String text) || text.length() > 64)
|
||||
throw new IllegalStateException("Server-data publication ownership is invalid");
|
||||
String[] fields = text.split(":", -1);
|
||||
try {
|
||||
if (fields.length != 3 || !UUID.fromString(fields[0]).toString().equals(fields[0])
|
||||
|| !(fields[2].equals("true") || fields[2].equals("false"))) throw new IllegalArgumentException();
|
||||
int users = Integer.parseInt(fields[1]);
|
||||
if (users < 0 || users > MAX_OWNERS || !Integer.toString(users).equals(fields[1])) throw new IllegalArgumentException();
|
||||
return new State(fields[0], users, Boolean.parseBoolean(fields[2]));
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IllegalStateException("Server-data publication ownership is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private record State(String generation, int users, boolean retiring) { }
|
||||
|
||||
public static final class Lease implements AutoCloseable {
|
||||
private final ServerDataPublicationFence owner;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private Lease(ServerDataPublicationFence owner) { this.owner = owner; }
|
||||
@Override public void close() {
|
||||
boolean drained;
|
||||
synchronized (owner.registry) {
|
||||
if (closed.get()) return;
|
||||
State current = owner.current();
|
||||
if (current.users <= 0) throw new IllegalStateException("Server-data publication ownership is invalid");
|
||||
owner.registry.setProperty(owner.key, owner.generation + ":" + (current.users - 1) + ":" + current.retiring);
|
||||
closed.set(true);
|
||||
drained = current.retiring && current.users == 1;
|
||||
}
|
||||
if (drained) owner.processExitCompletion.complete(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,12 @@ public final class VoteTaskAdmission {
|
||||
/**
|
||||
* Attempts to submit a one-shot vote task without running it on the caller.
|
||||
*
|
||||
* @param executor bounded vote executor
|
||||
* @param executor bounded vote executor, or null before startup/after shutdown
|
||||
* @param task task to submit
|
||||
* @return true when the task was admitted
|
||||
*/
|
||||
public static boolean trySubmit(ScheduledExecutorService executor, Runnable task) {
|
||||
if (executor == null) return false;
|
||||
try {
|
||||
executor.submit(task);
|
||||
return true;
|
||||
@@ -29,13 +30,14 @@ public final class VoteTaskAdmission {
|
||||
/**
|
||||
* Attempts to schedule a one-shot vote task without blocking the caller.
|
||||
*
|
||||
* @param executor bounded vote executor
|
||||
* @param executor bounded vote executor, or null before startup/after shutdown
|
||||
* @param task task to schedule
|
||||
* @param delay delay before execution
|
||||
* @param unit delay unit
|
||||
* @return true when the task was admitted
|
||||
*/
|
||||
public static boolean trySchedule(ScheduledExecutorService executor, Runnable task, long delay, TimeUnit unit) {
|
||||
if (executor == null) return false;
|
||||
try {
|
||||
executor.schedule(task, delay, unit);
|
||||
return true;
|
||||
|
||||
+153
-121
@@ -29,6 +29,14 @@ import com.bencodez.simpleapi.sql.mysql.DbType;
|
||||
* arbitrary, non-idempotent side effects.</p>
|
||||
*/
|
||||
final class SharedMysqlPurchaseJournal {
|
||||
/** Positive proof that this invocation did not attempt a points write. */
|
||||
static final class PreparationFailure extends SQLException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
PreparationFailure(SQLException cause) {
|
||||
super(cause.getMessage(), cause.getSQLState(), cause.getErrorCode(), cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String PENDING = "PENDING";
|
||||
private static final String HOOK_STARTED = "HOOK_STARTED";
|
||||
private static final String COMPENSATING = "COMPENSATING";
|
||||
@@ -119,60 +127,68 @@ final class SharedMysqlPurchaseJournal {
|
||||
/** Atomically records a pending purchase and conditionally charges it. */
|
||||
boolean reserve(String purchaseId, String uuid, String pointsColumn, String limitColumn, int cost, int limit,
|
||||
String limitGeneration, long limitGenerationExpiresAt, long now) throws SQLException {
|
||||
String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid")
|
||||
+ ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", "
|
||||
+ qi("limit_value") + ", " + qi("limit_generation") + ", "
|
||||
+ qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state")
|
||||
+ ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
String points = qi(pointsColumn);
|
||||
StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
|
||||
.append(points).append(" = COALESCE(").append(points).append(", 0) - ?");
|
||||
if (limitColumn != null) {
|
||||
debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn))
|
||||
.append(", 0) + 1");
|
||||
}
|
||||
debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND COALESCE(").append(points)
|
||||
.append(", 0) >= ?");
|
||||
if (limitColumn != null) {
|
||||
debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?");
|
||||
}
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn);
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(insert);
|
||||
PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) {
|
||||
insertStatement.setString(1, purchaseId);
|
||||
insertStatement.setString(2, uuid);
|
||||
insertStatement.setString(3, pointsColumn);
|
||||
insertStatement.setString(4, limitColumn);
|
||||
insertStatement.setInt(5, cost);
|
||||
if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER);
|
||||
else insertStatement.setInt(6, limit);
|
||||
if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR);
|
||||
else insertStatement.setString(7, limitGeneration);
|
||||
if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT);
|
||||
else insertStatement.setLong(8, limitGenerationExpiresAt);
|
||||
if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT);
|
||||
else insertStatement.setLong(9, limitEpoch.longValue());
|
||||
insertStatement.setString(10, PENDING);
|
||||
insertStatement.setLong(11, now);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
debitStatement.setInt(1, cost);
|
||||
debitStatement.setString(2, uuid);
|
||||
debitStatement.setInt(3, cost);
|
||||
if (limitColumn != null) debitStatement.setInt(4, limit);
|
||||
if (debitStatement.executeUpdate() != 1) {
|
||||
rollback(connection);
|
||||
return false;
|
||||
}
|
||||
return commitAndConfirm(connection, purchaseId, PENDING);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
throw failure;
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid")
|
||||
+ ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", "
|
||||
+ qi("limit_value") + ", " + qi("limit_generation") + ", "
|
||||
+ qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state")
|
||||
+ ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
String points = qi(pointsColumn);
|
||||
StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
|
||||
.append(points).append(" = COALESCE(").append(points).append(", 0) - ?");
|
||||
if (limitColumn != null) {
|
||||
debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn))
|
||||
.append(", 0) + 1");
|
||||
}
|
||||
debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND COALESCE(").append(points)
|
||||
.append(", 0) >= ?");
|
||||
if (limitColumn != null) {
|
||||
debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?");
|
||||
}
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn);
|
||||
try (PreparedStatement insertStatement = connection.prepareStatement(insert);
|
||||
PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) {
|
||||
insertStatement.setString(1, purchaseId);
|
||||
insertStatement.setString(2, uuid);
|
||||
insertStatement.setString(3, pointsColumn);
|
||||
insertStatement.setString(4, limitColumn);
|
||||
insertStatement.setInt(5, cost);
|
||||
if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER);
|
||||
else insertStatement.setInt(6, limit);
|
||||
if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR);
|
||||
else insertStatement.setString(7, limitGeneration);
|
||||
if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT);
|
||||
else insertStatement.setLong(8, limitGenerationExpiresAt);
|
||||
if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT);
|
||||
else insertStatement.setLong(9, limitEpoch.longValue());
|
||||
insertStatement.setString(10, PENDING);
|
||||
insertStatement.setLong(11, now);
|
||||
insertStatement.executeUpdate();
|
||||
|
||||
debitStatement.setInt(1, cost);
|
||||
debitStatement.setString(2, uuid);
|
||||
debitStatement.setInt(3, cost);
|
||||
if (limitColumn != null) debitStatement.setInt(4, limit);
|
||||
pointWriteAttempted = true;
|
||||
if (debitStatement.executeUpdate() != 1) {
|
||||
rollback(connection);
|
||||
return false;
|
||||
}
|
||||
return commitAndConfirm(connection, purchaseId, PENDING);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
rollback(connection);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,65 +386,73 @@ final class SharedMysqlPurchaseJournal {
|
||||
private RefundedPurchase setTerminalDetails(String purchaseId, String terminalState, long now,
|
||||
String... refundableStates)
|
||||
throws SQLException {
|
||||
boolean refund = REFUNDED.equals(terminalState);
|
||||
String refundedUuid = null;
|
||||
String refundedPointsColumn = null;
|
||||
String refundedLimitColumn = null;
|
||||
String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column")
|
||||
+ ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", "
|
||||
+ qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE "
|
||||
+ qi("purchase_id") + " = ? FOR UPDATE";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
selectStatement.setString(1, purchaseId);
|
||||
try (ResultSet result = selectStatement.executeQuery()) {
|
||||
if (!result.next()) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
String state = result.getString(1);
|
||||
if (COMPLETED.equals(state) || REFUNDED.equals(state)) {
|
||||
rollback(connection);
|
||||
return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null;
|
||||
}
|
||||
if (refund && !isRefundableState(state, refundableStates)) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
if (!refund && !HOOK_STARTED.equals(state)) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
String uuid = result.getString(2);
|
||||
String pointsColumn = result.getString(3);
|
||||
String limitColumn = result.getString(4);
|
||||
refundedUuid = uuid;
|
||||
refundedPointsColumn = pointsColumn;
|
||||
refundedLimitColumn = limitColumn;
|
||||
int cost = result.getInt(5);
|
||||
String limitGeneration = result.getString(6);
|
||||
Long limitEpoch = nullableLong(result, 8);
|
||||
if (refund) {
|
||||
refund(connection, uuid, pointsColumn, limitColumn, cost,
|
||||
shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch));
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
boolean refund = REFUNDED.equals(terminalState);
|
||||
String refundedUuid = null;
|
||||
String refundedPointsColumn = null;
|
||||
String refundedLimitColumn = null;
|
||||
String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column")
|
||||
+ ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", "
|
||||
+ qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE "
|
||||
+ qi("purchase_id") + " = ? FOR UPDATE";
|
||||
try (Connection connection = connection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
|
||||
selectStatement.setString(1, purchaseId);
|
||||
try (ResultSet result = selectStatement.executeQuery()) {
|
||||
if (!result.next()) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
String state = result.getString(1);
|
||||
if (COMPLETED.equals(state) || REFUNDED.equals(state)) {
|
||||
rollback(connection);
|
||||
return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null;
|
||||
}
|
||||
if (refund && !isRefundableState(state, refundableStates)) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
if (!refund && !HOOK_STARTED.equals(state)) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
String uuid = result.getString(2);
|
||||
String pointsColumn = result.getString(3);
|
||||
String limitColumn = result.getString(4);
|
||||
refundedUuid = uuid;
|
||||
refundedPointsColumn = pointsColumn;
|
||||
refundedLimitColumn = limitColumn;
|
||||
int cost = result.getInt(5);
|
||||
String limitGeneration = result.getString(6);
|
||||
Long limitEpoch = nullableLong(result, 8);
|
||||
if (refund) {
|
||||
boolean refundLimit = shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch);
|
||||
pointWriteAttempted = true;
|
||||
refund(connection, uuid, pointsColumn, limitColumn, cost, refundLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id")
|
||||
+ " = ?";
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
|
||||
updateStatement.setString(1, terminalState);
|
||||
updateStatement.setString(2, purchaseId);
|
||||
if (updateStatement.executeUpdate() != 1) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id")
|
||||
+ " = ?";
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
|
||||
updateStatement.setString(1, terminalState);
|
||||
updateStatement.setString(2, purchaseId);
|
||||
if (updateStatement.executeUpdate() != 1) {
|
||||
rollback(connection);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!commitAndConfirm(connection, purchaseId, terminalState)) return null;
|
||||
return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn)
|
||||
: new RefundedPurchase(null, null, null);
|
||||
} catch (SQLException failure) {
|
||||
throw failure;
|
||||
}
|
||||
if (!commitAndConfirm(connection, purchaseId, terminalState)) return null;
|
||||
return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn)
|
||||
: new RefundedPurchase(null, null, null);
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
@@ -442,20 +466,28 @@ final class SharedMysqlPurchaseJournal {
|
||||
|
||||
private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost,
|
||||
boolean refundLimit) throws SQLException {
|
||||
if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) {
|
||||
throw new SQLException("Unsafe durable purchase column");
|
||||
}
|
||||
StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
|
||||
.append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?");
|
||||
if (refundLimit) {
|
||||
refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn))
|
||||
.append(", 0) - 1, 0)");
|
||||
}
|
||||
refund.append(" WHERE ").append(qi("uuid")).append(uuidCast());
|
||||
try (PreparedStatement statement = connection.prepareStatement(refund.toString())) {
|
||||
statement.setInt(1, cost);
|
||||
statement.setString(2, uuid);
|
||||
if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing");
|
||||
boolean pointWriteAttempted = false;
|
||||
try {
|
||||
if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) {
|
||||
throw new SQLException("Unsafe durable purchase column");
|
||||
}
|
||||
StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
|
||||
.append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?");
|
||||
if (refundLimit) {
|
||||
refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn))
|
||||
.append(", 0) - 1, 0)");
|
||||
}
|
||||
refund.append(" WHERE ").append(qi("uuid")).append(uuidCast());
|
||||
try (PreparedStatement statement = connection.prepareStatement(refund.toString())) {
|
||||
statement.setInt(1, cost);
|
||||
statement.setString(2, uuid);
|
||||
pointWriteAttempted = true;
|
||||
if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing");
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
if (failure instanceof PreparationFailure) throw failure;
|
||||
if (!pointWriteAttempted) throw new PreparationFailure(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-36
@@ -186,15 +186,24 @@ public class VoteShopPurchaseService {
|
||||
// or entity task runs could pair an old debit with a newly loaded reward.
|
||||
FileConfiguration shopData = plugin.getShopFile().getData();
|
||||
HashMap<String, String> placeholders = purchasePlaceholders(item);
|
||||
var pointPlaceholders = plugin.getPlaceholders();
|
||||
Consumer<Boolean> admission = pointPlaceholders == null ? null
|
||||
: pointPlaceholders.sharedPointAdmissionCompletion(UUID.fromString(user.getUUID()));
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
SharedPurchaseDebit debit;
|
||||
synchronized (purchaseLock(user.getUUID())) {
|
||||
// Sample the reset window beside the conditional debit. A queued
|
||||
// persistence task may otherwise cross into a new limit period.
|
||||
debit = reserveSharedMysqlPurchase(user, item,
|
||||
limitGeneration(item, System.currentTimeMillis()));
|
||||
boolean settled = false;
|
||||
try {
|
||||
synchronized (purchaseLock(user.getUUID())) {
|
||||
// Sample the reset window beside the conditional debit. A queued
|
||||
// persistence task may otherwise cross into a new limit period.
|
||||
debit = reserveSharedMysqlPurchase(user, item,
|
||||
limitGeneration(item, System.currentTimeMillis()));
|
||||
}
|
||||
settled = true;
|
||||
} finally {
|
||||
if (admission != null) admission.accept(settled);
|
||||
}
|
||||
if (debit.result() != VoteShopPurchaseResult.SUCCESS) {
|
||||
completePurchaseResult(player, completion, debit.result());
|
||||
@@ -207,6 +216,7 @@ public class VoteShopPurchaseService {
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException persistenceRejected) {
|
||||
if (admission != null) admission.accept(false);
|
||||
plugin.debug(persistenceRejected);
|
||||
completeFailedPurchase(player, completion);
|
||||
}
|
||||
@@ -479,20 +489,29 @@ public class VoteShopPurchaseService {
|
||||
}
|
||||
|
||||
private void refundCompensatingMysqlDebit(VotingPluginUser user, SharedPurchaseDebit debit) {
|
||||
var publication = plugin.getPlaceholders();
|
||||
UUID uuid = publication == null ? null : UUID.fromString(user.getUUID());
|
||||
if (publication != null) publication.beginSharedPointDebit(uuid);
|
||||
boolean known = false;
|
||||
try {
|
||||
if (debit.journal().refundCompensatingReward(debit.purchaseId())) {
|
||||
refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
|
||||
}
|
||||
debit.journal().refundCompensatingReward(debit.purchaseId());
|
||||
known = true;
|
||||
} catch (SQLException failure) {
|
||||
known = failure instanceof SharedMysqlPurchaseJournal.PreparationFailure;
|
||||
// A commit/confirmation failure is indeterminate: the refund transaction
|
||||
// may have committed even though this worker could not observe its terminal
|
||||
// journal state. Drop the affected snapshots before any later cache dump so
|
||||
// a stale debit cannot overwrite a durable refund. Recovery will reconcile
|
||||
// the journal state if the transaction did not commit.
|
||||
refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
|
||||
plugin.getLogger().severe("Unable to refund an incomplete vote shop purchase: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
plugin.debug(failure);
|
||||
} finally {
|
||||
try {
|
||||
refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
|
||||
} finally {
|
||||
if (publication != null) publication.finishSharedPointDebit(uuid, known, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,37 +757,50 @@ public class VoteShopPurchaseService {
|
||||
|
||||
private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, VoteShopItem item,
|
||||
LimitGeneration limitGeneration) {
|
||||
MySQL table = plugin.getMysql();
|
||||
String pointsColumn = user.getPointsPath();
|
||||
String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null;
|
||||
ensurePerServerPointsColumn(table, pointsColumn);
|
||||
drainPurchaseCache(user, pointsColumn);
|
||||
if (limitColumn != null) {
|
||||
table.checkColumn(limitColumn, DataType.INTEGER);
|
||||
}
|
||||
var publication = plugin.getPlaceholders();
|
||||
UUID pointUuid = UUID.fromString(user.getUUID());
|
||||
if (publication != null) publication.beginSharedPointDebit(pointUuid);
|
||||
boolean known = true;
|
||||
try {
|
||||
SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table);
|
||||
String purchaseId = UUID.randomUUID().toString();
|
||||
if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(),
|
||||
limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis())) {
|
||||
// reserve() returns only after its transaction and connection are closed;
|
||||
// NO_CACHE reads must not contend with its one-connection pool handle.
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn,
|
||||
limitColumn);
|
||||
MySQL table = plugin.getMysql();
|
||||
String pointsColumn = user.getPointsPath();
|
||||
String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null;
|
||||
ensurePerServerPointsColumn(table, pointsColumn);
|
||||
drainPurchaseCache(user, pointsColumn);
|
||||
if (limitColumn != null) {
|
||||
table.checkColumn(limitColumn, DataType.INTEGER);
|
||||
}
|
||||
try {
|
||||
SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table);
|
||||
String purchaseId = UUID.randomUUID().toString();
|
||||
known = false;
|
||||
boolean reserved = journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(),
|
||||
limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis());
|
||||
known = true;
|
||||
if (reserved) {
|
||||
// reserve() returns only after its transaction and connection are closed;
|
||||
// NO_CACHE reads must not contend with its one-connection pool handle.
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn,
|
||||
limitColumn);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
known |= failure instanceof SharedMysqlPurchaseJournal.PreparationFailure;
|
||||
// reserve() can throw after its commit acknowledgement and confirmation
|
||||
// both fail. The debit may therefore be durable even though this caller
|
||||
// reports FAILED; drop snapshots recreated during that transaction so a
|
||||
// later cache dump cannot restore the pre-reservation values.
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
plugin.getLogger().severe("Unable to atomically debit vote shop points: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
plugin.debug(failure);
|
||||
return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null);
|
||||
}
|
||||
} catch (SQLException failure) {
|
||||
// reserve() can throw after its commit acknowledgement and confirmation
|
||||
// both fail. The debit may therefore be durable even though this caller
|
||||
// reports FAILED; drop snapshots recreated during that transaction so a
|
||||
// later cache dump cannot restore the pre-reservation values.
|
||||
refreshPurchaseCache(user, pointsColumn, limitColumn);
|
||||
plugin.getLogger().severe("Unable to atomically debit vote shop points: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
plugin.debug(failure);
|
||||
return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null);
|
||||
return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null);
|
||||
} finally {
|
||||
if (publication != null) publication.finishSharedPointDebit(pointUuid, known, false);
|
||||
}
|
||||
return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null);
|
||||
}
|
||||
|
||||
/** The legacy cache path created server-suffixed points columns before direct SQL used them. */
|
||||
|
||||
@@ -451,6 +451,14 @@ VoteLogging:
|
||||
# Tracks and displays top voters by time period
|
||||
###########################################
|
||||
|
||||
# Hide only confirmed never-joined identities from top voter displays.
|
||||
# Missing, unreadable or ambiguous history stays visible. Awards are unchanged.
|
||||
# Presence checks are asynchronous; pending snapshots stay visible until refreshed.
|
||||
# Evidence expires after 30 seconds. GUI, chat and placeholder views can show
|
||||
# previously hidden rows while fresh checks are pending; stale negatives are not reused.
|
||||
# At most 128 checks run at once, so a large expired board may need several refreshes.
|
||||
HideTopVoterIfNotPlayedBefore: false
|
||||
|
||||
# Top voter blacklist
|
||||
# Hide these names from top voter lists
|
||||
BlackList:
|
||||
@@ -1265,9 +1273,3 @@ DisableUpdateChecking: false
|
||||
# Disable checking permissions on tab complete
|
||||
# Could increase performance slightly, but probably not
|
||||
DisableAdvancedTab: false
|
||||
|
||||
# Observation-only chat guide; normal /vote and reward processing remain unchanged.
|
||||
# Progress is local, expires after this interval, and resets on reload/restart.
|
||||
GuidedVotingSession:
|
||||
Enabled: false
|
||||
TimeoutMinutes: 30
|
||||
@@ -397,3 +397,45 @@ 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%!'
|
||||
@@ -195,10 +195,10 @@ permissions:
|
||||
VotingPlugin.Login.RemindVotes.All:
|
||||
description: Remind if player can vote on all votesites, rather than any
|
||||
default: false
|
||||
VotingPlugin.Commands.Vote.Session:
|
||||
description: Start or navigate a guided voting session
|
||||
default: false
|
||||
|
||||
VotingPlugin.Commands.Vote.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
@@ -0,0 +1,58 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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
@@ -0,0 +1,173 @@
|
||||
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
@@ -0,0 +1,477 @@
|
||||
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
@@ -0,0 +1,126 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class DeferredServerDataOwnerPublicationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void deferredNativePublicationSerializesCheckpointsAndRetainsItsActualAcknowledgement() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var ownerWorker = Executors.newSingleThreadExecutor();
|
||||
var checkpointWorker = Executors.newSingleThreadExecutor();
|
||||
var mutationEntered = new CountDownLatch(1);
|
||||
var releaseMutation = new CountDownLatch(1);
|
||||
var nativePublished = new CountDownLatch(1);
|
||||
var releaseAcknowledgement = new CountDownLatch(1);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public CompletionStage<Void> mutateFinalData(Consumer<FileConfiguration> mutation) {
|
||||
return CompletableFuture.supplyAsync(() -> super.mutateFinalData(mutation), ownerWorker)
|
||||
.thenCompose(stage -> stage.thenRun(() -> {
|
||||
assertFalse(Thread.holdsLock(this), "acknowledgement must run outside YAML ownership");
|
||||
nativePublished.countDown();
|
||||
await(releaseAcknowledgement);
|
||||
}));
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("Adjacent.Value", "preserved");
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var inner = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, inner, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
data.getData();
|
||||
var checkpointThread = new AtomicReference<Thread>();
|
||||
var checkpointStarted = new CountDownLatch(1);
|
||||
try {
|
||||
// This returns a genuinely deferred owner stage, not a mocked future.
|
||||
var observation = facade.mutateFinalData(snapshot -> {
|
||||
assertTrue(Thread.holdsLock(backing));
|
||||
assertEquals("preserved", snapshot.getString("Adjacent.Value"));
|
||||
mutationEntered.countDown();
|
||||
await(releaseMutation);
|
||||
snapshot.set("Final.Value", "confirmed");
|
||||
}).toCompletableFuture();
|
||||
assertTrue(mutationEntered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(observation.isDone());
|
||||
var checkpoint = checkpointWorker.submit(() -> {
|
||||
checkpointThread.set(Thread.currentThread());
|
||||
checkpointStarted.countDown();
|
||||
data.setInt("VoteParty.Total", 7);
|
||||
});
|
||||
assertTrue(checkpointStarted.await(5, TimeUnit.SECONDS));
|
||||
var threads = ManagementFactory.getThreadMXBean();
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
boolean blockedByOwner = false;
|
||||
while (!checkpoint.isDone() && System.nanoTime() < deadline) {
|
||||
var info = threads.getThreadInfo(checkpointThread.get().threadId());
|
||||
if (info != null && info.getThreadState() == Thread.State.BLOCKED
|
||||
&& info.getLockInfo() != null
|
||||
&& info.getLockInfo().getIdentityHashCode() == System.identityHashCode(backing)) {
|
||||
blockedByOwner = true;
|
||||
break;
|
||||
}
|
||||
Thread.yield();
|
||||
}
|
||||
assertTrue(blockedByOwner, "ordinary checkpoints must wait for the deferred native YAML owner");
|
||||
releaseMutation.countDown();
|
||||
assertTrue(nativePublished.await(5, TimeUnit.SECONDS));
|
||||
checkpoint.get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals("preserved", disk.getString("Adjacent.Value"));
|
||||
assertEquals(7, disk.getInt("VotingPlugin.VoteParty.Total"));
|
||||
fence.retire();
|
||||
assertFalse(observation.isDone(), "visible native bytes alone do not settle the owner's stage");
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
releaseAcknowledgement.countDown();
|
||||
observation.get(5, TimeUnit.SECONDS);
|
||||
var successor = ServerDataPublicationFence.beginGeneration(target);
|
||||
successor.retire();
|
||||
} finally {
|
||||
releaseMutation.countDown();
|
||||
releaseAcknowledgement.countDown();
|
||||
ownerWorker.shutdownNow();
|
||||
checkpointWorker.shutdownNow();
|
||||
assertTrue(ownerWorker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(checkpointWorker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try { assertTrue(latch.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import com.bencodez.advancedcore.api.user.presence.PlayerPresence;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.streaks.VoteStreaksMenu;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterPlayer;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DisplayUnloadIntegrationTest {
|
||||
private void field(Object target, String name, Object value) throws Exception {
|
||||
var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(target, value);
|
||||
}
|
||||
private ScheduledExecutorService stoppedExecutor() throws Exception {
|
||||
var executor = mock(ScheduledExecutorService.class);
|
||||
when(executor.awaitTermination(anyLong(), any(TimeUnit.class))).thenReturn(true);
|
||||
return executor;
|
||||
}
|
||||
@Test void unloadSettlesPendingLeaderboardEvidenceAndRejectsLaterStreakMenuOpens() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
field(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class);
|
||||
field(plugin, "discordHandler", discord);
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
var generation = new AtomicLong();
|
||||
field(plugin, "topVoterDisplayGeneration", generation);
|
||||
field(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
field(plugin, "backendHostedControlLifecycle", stoppedExecutor());
|
||||
field(plugin, "backendHostedControlManagers", new HashSet<>());
|
||||
field(plugin, "backendControlConnectorLifecycle", stoppedExecutor());
|
||||
field(plugin, "voteTimer", stoppedExecutor());
|
||||
var signs = mock(Signs.class); doReturn(signs).when(plugin).getSigns();
|
||||
when(signs.storeSignsForShutdown()).thenReturn(CompletableFuture.completedFuture(null));
|
||||
field(plugin, "signs", signs);
|
||||
var pending = new CompletableFuture<PlayerPresence>();
|
||||
var filter = new TopVoterDisplayFilter(row -> pending);
|
||||
field(plugin, "topVoterDisplayFilter", filter);
|
||||
var menu = new VoteStreaksMenu(plugin); field(plugin, "voteStreaksMenu", menu);
|
||||
var rankings = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
rankings.put(new TopVoterPlayer(UUID.randomUUID(), "Example", 1L), 10);
|
||||
var display = filter.projectAsync(rankings, true).toCompletableFuture();
|
||||
assertFalse(display.isDone());
|
||||
var player = mock(Player.class);
|
||||
try (var handlers = mockStatic(HandlerList.class); var scheduler = mockStatic(BukkitCompletionScheduler.class)) {
|
||||
scheduler.when(() -> BukkitCompletionScheduler.run(eq(plugin), eq(player), any(Runnable.class), any(Runnable.class), any(Runnable.class)))
|
||||
.thenAnswer(call -> { call.getArgument(2, Runnable.class).run(); return null; });
|
||||
plugin.onUnLoad();
|
||||
verify(discord).close();
|
||||
verify(discord).retireLeaderboardPublications();
|
||||
assertTrue(display.isDone(), "Unload must settle the retired filter's pending display wait");
|
||||
assertEquals(rankings, display.getNow(null));
|
||||
pending.complete(PlayerPresence.NEVER_JOINED);
|
||||
assertEquals(rankings, filter.project(rankings, true), "Late history results cannot hide rows after retirement");
|
||||
assertEquals(1, generation.get());
|
||||
menu.open(player);
|
||||
verifyNoInteractions(player);
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class GenerationFencedServerDataFinalPublicationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void ordinaryServiceProducerCannotRaceTheActualSDKListSnapshot() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var snapshotEntered = new java.util.concurrent.CountDownLatch(1);
|
||||
var releaseSnapshot = new java.util.concurrent.CountDownLatch(1);
|
||||
var producerStarted = new java.util.concurrent.CountDownLatch(1);
|
||||
var mutatedLiveList = new java.util.concurrent.CountDownLatch(1);
|
||||
var firstSnapshot = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var sites = new java.util.ArrayList<String>() {
|
||||
@Override public boolean add(String value) {
|
||||
boolean added = super.add(value);
|
||||
if (value.equals("SyntheticLateSite")) mutatedLiveList.countDown();
|
||||
return added;
|
||||
}
|
||||
@Override public java.util.stream.Stream<String> stream() {
|
||||
var stream = super.stream();
|
||||
if (!firstSnapshot.compareAndSet(true, false)) return stream;
|
||||
return stream.peek(value -> {
|
||||
snapshotEntered.countDown();
|
||||
try { assertTrue(releaseSnapshot.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
}
|
||||
};
|
||||
sites.add("SyntheticExistingSite");
|
||||
backing.getData().set("VotingPlugin.GottenServiceSites", sites);
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
var workers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var publication = workers.submit(() -> facade.mutateFinalData(snapshot ->
|
||||
snapshot.set("Final.Value", "confirmed")).toCompletableFuture().join());
|
||||
assertTrue(snapshotEntered.await(5, TimeUnit.SECONDS));
|
||||
var producer = workers.submit(() -> {
|
||||
producerStarted.countDown();
|
||||
data.addServiceSite("SyntheticLateSite");
|
||||
});
|
||||
assertTrue(producerStarted.await(5, TimeUnit.SECONDS));
|
||||
boolean overlapped = mutatedLiveList.await(200, TimeUnit.MILLISECONDS);
|
||||
releaseSnapshot.countDown();
|
||||
assertDoesNotThrow(() -> publication.get(5, TimeUnit.SECONDS),
|
||||
"ordinary producers must not invalidate the real SDK final snapshot");
|
||||
producer.get(5, TimeUnit.SECONDS);
|
||||
assertFalse(overlapped, "live service history changed while the SDK owned its snapshot");
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals(java.util.Set.of("SyntheticExistingSite", "SyntheticLateSite"),
|
||||
new java.util.HashSet<>(disk.getStringList("VotingPlugin.GottenServiceSites")));
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
} finally {
|
||||
releaseSnapshot.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryWriterFamiliesMutateOnlyUnderTheActualYamlOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
var yaml = new YamlConfiguration() {
|
||||
@Override public void set(String path, Object value) {
|
||||
assertTrue(Thread.holdsLock(backing), "YAML mutation escaped its actual owner: " + path);
|
||||
super.set(path, value);
|
||||
}
|
||||
};
|
||||
backing.setData(yaml);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
try {
|
||||
data.addServiceSite("SyntheticSite");
|
||||
data.addAutoCachedPlaceholder("SyntheticPlaceholder");
|
||||
data.addVoteShopPurchase("SyntheticItem");
|
||||
data.setBungeeVotePartyCurrent(3);
|
||||
data.setBungeeVotePartyRequired(9);
|
||||
data.setVotePartyExtraRequired(2);
|
||||
data.setShopConverted(true);
|
||||
data.saveDisabledReminders(new java.util.ArrayList<>());
|
||||
data.updatePlaceholders();
|
||||
var party = new com.bencodez.votingplugin.specialrewards.voteparty.VotePartyState(plugin);
|
||||
party.setTotalVotes(4);
|
||||
var users = new java.util.ArrayList<>(java.util.List.of("SyntheticParticipant"));
|
||||
party.setVotedUsers(users);
|
||||
users.add("UnpublishedParticipant");
|
||||
assertEquals(java.util.List.of("SyntheticParticipant"), party.getVotedUsers());
|
||||
// Suppress web refresh only; exercise the real handler and its actual YAML writer.
|
||||
var services = new com.bencodez.votingplugin.servicesites.ServiceSiteHandler(plugin) {
|
||||
@Override public void refreshFromWebOnce(boolean logMore) { }
|
||||
};
|
||||
services.saveToServerData();
|
||||
var world = mock(org.bukkit.World.class);
|
||||
when(world.getName()).thenAnswer(call -> {
|
||||
assertFalse(Thread.holdsLock(backing), "platform lookup ran under YAML ownership");
|
||||
return "SyntheticWorld";
|
||||
});
|
||||
var location = new org.bukkit.Location(world, -1.5, 64, 2.5);
|
||||
data.setSign("0", location, location, "SyntheticSign", 1);
|
||||
data.setSkullLocation("0", location);
|
||||
assertEquals(-1, data.getData().getInt("Signs.0.X"), "retain legacy truncation");
|
||||
data.removeSign("0");
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertEquals(4, disk.getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertEquals(java.util.List.of("SyntheticParticipant"), disk.getStringList("VotingPlugin.VoteParty.Voted"));
|
||||
assertEquals(1, disk.getInt("VotingPlugin.VoteShopPurchases.SyntheticItem"));
|
||||
assertFalse(disk.contains("VotingPlugin.Signs.0"));
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryMutationFamiliesWaitBeforeChangingAnOwnedSnapshot() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
data.getData(); // Section already exists: test mutation, not lazy section creation.
|
||||
var party = new com.bencodez.votingplugin.specialrewards.voteparty.VotePartyState(plugin);
|
||||
var services = new com.bencodez.votingplugin.servicesites.ServiceSiteHandler(plugin) {
|
||||
@Override public void refreshFromWebOnce(boolean logMore) { }
|
||||
};
|
||||
var operations = java.util.List.<Runnable>of(
|
||||
() -> data.setBungeeVotePartyCurrent(3),
|
||||
() -> data.addServiceSite("SyntheticSite"),
|
||||
() -> data.setServiceSites(new java.util.ArrayList<>(java.util.List.of("SyntheticReplacement"))),
|
||||
() -> data.addVoteShopPurchase("SyntheticItem"),
|
||||
() -> data.addAutoCachedPlaceholder("SyntheticPlaceholder"),
|
||||
() -> data.updatePlaceholders(),
|
||||
() -> data.removeSign("0"),
|
||||
() -> party.setTotalVotes(4),
|
||||
() -> party.setVotedUsers(java.util.List.of("SyntheticParticipant")),
|
||||
services::saveToServerData);
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
for (Runnable operation : operations) {
|
||||
var started = new java.util.concurrent.CountDownLatch(1);
|
||||
java.util.concurrent.Future<?> future;
|
||||
synchronized (backing) {
|
||||
String before = backing.getData().saveToString();
|
||||
future = worker.submit(() -> { started.countDown(); operation.run(); });
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
assertThrows(java.util.concurrent.TimeoutException.class,
|
||||
() -> future.get(100, TimeUnit.MILLISECONDS));
|
||||
assertEquals(before, backing.getData().saveToString(),
|
||||
"ordinary mutation changed YAML before acquiring the actual snapshot owner");
|
||||
}
|
||||
future.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void serviceListInputsAndOutputsCannotMutatePublishedYamlByAlias() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
try {
|
||||
var supplied = new java.util.ArrayList<>(java.util.List.of("SyntheticExistingSite"));
|
||||
data.setServiceSites(supplied);
|
||||
supplied.add("SyntheticInputAlias");
|
||||
data.getServiceSites().add("SyntheticOutputAlias");
|
||||
assertEquals(java.util.List.of("SyntheticExistingSite"), data.getServiceSitesReadOnly());
|
||||
data.saveData();
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertEquals(java.util.List.of("SyntheticExistingSite"), disk.getStringList("VotingPlugin.GottenServiceSites"));
|
||||
data.setServiceSites(null);
|
||||
assertTrue(data.getServiceSites().isEmpty(), "retain legacy null-as-clear setter behavior");
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void finalMutationConfirmsTheActualSDKOwnersPublication() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
checkFinalMutation();
|
||||
}
|
||||
}
|
||||
|
||||
private void checkFinalMutation() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("Adjacent.Value", "preserved");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
try {
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
facade.mutateFinalData(data -> data.set("Final.Value", "confirmed"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals("preserved", disk.getString("Adjacent.Value"));
|
||||
} finally {
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void asynchronousOwnerRetainsAdmissionUntilItsActualCompletion() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
var published = new CompletableFuture<Void>();
|
||||
when(backing.mutateFinalData(any())).thenReturn(published);
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var observation = facade.mutateFinalData(data -> {}).toCompletableFuture();
|
||||
fence.retire();
|
||||
assertFalse(observation.isDone());
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
observation.cancel(false);
|
||||
assertFalse(published.isCancelled());
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
published.complete(null);
|
||||
var successor = ServerDataPublicationFence.beginGeneration(target);
|
||||
successor.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedOrCancelledPublicationCannotReleaseAdmission() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
for (boolean cancelled : new boolean[] { false, true }) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
var published = new CompletableFuture<Void>();
|
||||
when(backing.mutateFinalData(any())).thenReturn(published);
|
||||
var target = directory.resolve(cancelled ? "Cancelled.yml" : "Failed.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var observation = facade.mutateFinalData(data -> {}).toCompletableFuture();
|
||||
fence.retire();
|
||||
if (cancelled) published.cancel(false);
|
||||
else published.completeExceptionally(new java.io.IOException("synthetic publication failure"));
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> observation.get(5, TimeUnit.SECONDS));
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ordinaryMutatorsAndCheckedTransitionsUseTheSameDelegateOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
try {
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
assertSame(backing, facade.publicationMonitor());
|
||||
facade.setPrevDay(12);
|
||||
facade.setPrevMonth("OCTOBER");
|
||||
facade.setPrevWeekDay(41);
|
||||
facade.setIgnoreTime(true);
|
||||
facade.setData("Adjacent.Value", "ordinary");
|
||||
var transition = facade.beginTimeChangeTransition(
|
||||
com.bencodez.advancedcore.api.time.TimeType.DAY, "synthetic-day", "13");
|
||||
assertEquals(transition, backing.getPendingTimeChangeTransition(
|
||||
com.bencodez.advancedcore.api.time.TimeType.DAY));
|
||||
facade.mutateFinalData(data -> data.set("Final.Value", "confirmed"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
facade.completeTimeChangeTransition(transition);
|
||||
assertFalse(backing.isPendingTimeChangeTransition(transition));
|
||||
assertEquals(13, backing.getPrevDay());
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("ordinary", disk.getString("Adjacent.Value"));
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals(13, disk.getInt("PrevDay"));
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void timedCheckpointCannotOvertakeTheNativeFinalSnapshot() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var checkpointStarted = new java.util.concurrent.CountDownLatch(1);
|
||||
var checkpointFinished = new java.util.concurrent.CountDownLatch(1);
|
||||
var workers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var publication = workers.submit(() -> facade.mutateFinalData(snapshot -> {
|
||||
snapshot.set("Final.Value", "confirmed");
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}).toCompletableFuture().join());
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var checkpoint = workers.submit(() -> {
|
||||
checkpointStarted.countDown();
|
||||
data.addTimeVoted(0, new com.bencodez.votingplugin.timequeue.VoteTimeQueue(
|
||||
"Synthetic", "site", 123L));
|
||||
checkpointFinished.countDown();
|
||||
});
|
||||
assertTrue(checkpointStarted.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(checkpointFinished.await(150, TimeUnit.MILLISECONDS));
|
||||
release.countDown();
|
||||
publication.get(5, TimeUnit.SECONDS);
|
||||
checkpoint.get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("confirmed", disk.getString("Final.Value"));
|
||||
assertEquals("Synthetic", disk.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals(123L, disk.getLong("VotingPlugin.TimedVoteCache.0.Time"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class GenerationFencedShutdownStageTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private VotingPluginMain plugin(ServerDataPublicationFence fence) throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
// Mockito bypasses constructors; initialize the real SDK lifecycle lock.
|
||||
var lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock");
|
||||
lock.setAccessible(true);
|
||||
lock.set(plugin, new Object());
|
||||
var owner = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence");
|
||||
owner.setAccessible(true);
|
||||
owner.set(plugin, fence);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
@Test void disableReservesBeforeTheSDKAdmitsItsFinalStageAndRepeatedDisableDoesNotReleaseIt() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var finalFiles = new CompletableFuture<Void>();
|
||||
// This fixture controls only SDK hook admission; it does not claim a
|
||||
// full runtime cleanup. Adapter tests exercise the real file publisher.
|
||||
try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class)) {
|
||||
plugin.onDisable();
|
||||
fence.retainUntilTermination(plugin.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
plugin.onShutdownComplete(finalFiles);
|
||||
plugin.onDisable();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
finalFiles.complete(null);
|
||||
var successor = ServerDataPublicationFence.beginGeneration(target);
|
||||
successor.retire();
|
||||
plugin.onDisable();
|
||||
assertThrows(IllegalStateException.class, fence::acquire);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disableStartsTheProcessDrainBeforeSdkCleanupAndNeverRenewsIt() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var plugin = plugin(fence);
|
||||
var startedField = ServerDataPublicationFence.class.getDeclaredField("processShutdownStarted");
|
||||
startedField.setAccessible(true);
|
||||
var started = (CompletableFuture<?>) startedField.get(fence);
|
||||
try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
assertTrue(started.isDone(), "The consumer deadline must precede SDK cleanup");
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
plugin.onDisable();
|
||||
var original = started.get(5, TimeUnit.SECONDS);
|
||||
plugin.onDisable();
|
||||
assertEquals(original, started.get());
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedFinalStageRetainsTheGenerationAfterAllCapturedExecutorsTerminate() throws Exception {
|
||||
checkFailure(false);
|
||||
}
|
||||
|
||||
@Test void cancelledFinalStageRetainsTheGenerationAfterAllCapturedExecutorsTerminate() throws Exception {
|
||||
checkFailure(true);
|
||||
}
|
||||
|
||||
private void checkFailure(boolean cancelled) throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var finalFiles = new CompletableFuture<Void>();
|
||||
try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class)) {
|
||||
plugin.onDisable();
|
||||
fence.retainUntilTermination(plugin.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
plugin.onShutdownComplete(finalFiles);
|
||||
if (cancelled) finalFiles.cancel(false);
|
||||
else finalFiles.completeExceptionally(new java.io.IOException("synthetic final-stage failure"));
|
||||
plugin.onDisable();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
// A later duplicate hook cannot replace the failed outcome.
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
-236
@@ -1,236 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
import com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache;
|
||||
import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync;
|
||||
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter;
|
||||
import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager;
|
||||
import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync;
|
||||
import com.bencodez.votingplugin.config.BungeeSettings;
|
||||
import com.bencodez.votingplugin.core.vote.SharedVotePolicy;
|
||||
import com.bencodez.votingplugin.core.vote.SharedVoteProcessor;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.proxy.BungeeMethod;
|
||||
import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginProxy;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler;
|
||||
import com.bencodez.votingplugin.proxy.multiproxy.MultiProxyHandler;
|
||||
import com.bencodez.votingplugin.user.UserManager;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import com.bencodez.votingplugin.votesites.VoteSiteManager;
|
||||
|
||||
class MultiProxyCooldownCompatibilityTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> reliableForwardingKeepsNormalCooldownTimeLocalToReceiver() {
|
||||
return Stream.of(100L, System.currentTimeMillis() + 86_400_000L)
|
||||
.map(senderTime -> DynamicTest.dynamicTest("sender time " + senderTime,
|
||||
() -> assertReceiverCooldownTime(senderTime)));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void sourceFenceRepliesOnlyToConfiguredOriginAndOriginRelaysOnlyItsOwnSession() throws Exception {
|
||||
VotingPluginProxyTestImpl proxy = spy(new VotingPluginProxyTestImpl());
|
||||
proxy.setDataFolder(temporaryDirectory.toFile());
|
||||
when(proxy.getConfig().getProxyServerName()).thenReturn("Receiver");
|
||||
var backendReplies = new java.util.ArrayList<JsonEnvelope>();
|
||||
proxy.setGlobalMessageProxyHandlerForTest(new GlobalMessageProxyHandler() {
|
||||
@Override public void sendMessage(String server, int delay, JsonEnvelope envelope) {
|
||||
assertEquals("server1", server); backendReplies.add(envelope);
|
||||
}
|
||||
});
|
||||
proxy.loadMultiProxySupport();
|
||||
MultiProxyHandler handler = spy(proxy.getMultiProxyHandler());
|
||||
doReturn(com.bencodez.votingplugin.proxy.multiproxy.MultiProxyMethod.REDIS).when(handler).getMultiProxyMethod();
|
||||
doReturn(java.util.List.of("Origin")).when(handler).getProxyServers();
|
||||
doReturn("Receiver").when(handler).getMultiProxyServerName();
|
||||
var outgoing = new java.util.ArrayList<JsonEnvelope>();
|
||||
org.mockito.Mockito.doAnswer(i -> {
|
||||
assertEquals(java.util.List.of("Origin"), i.getArgument(1));
|
||||
outgoing.add(i.getArgument(0)); return true;
|
||||
}).when(handler).sendMultiProxyEnvelopeAccepted(any(JsonEnvelope.class), any(java.util.Collection.class));
|
||||
Method receive = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class);
|
||||
receive.setAccessible(true);
|
||||
var request = new VotingPluginWire.SessionFenceRequest(UUID.randomUUID(), UUID.randomUUID(), "server1", "Origin");
|
||||
receive.invoke(handler, VotingPluginWire.sessionFenceRequest(request));
|
||||
assertEquals(1, outgoing.size());
|
||||
var reply = VotingPluginWire.readSessionFenceReply(outgoing.get(0)); assertNotNull(reply);
|
||||
assertEquals(request, reply.request());
|
||||
assertTrue(reply.fence().newer(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId()));
|
||||
receive.invoke(handler, VotingPluginWire.sessionFenceRequest(new VotingPluginWire.SessionFenceRequest(
|
||||
request.player(), request.nonce(), request.server(), "Unknown")));
|
||||
assertEquals(1, outgoing.size(), "unknown origin must not receive a fence");
|
||||
receive.invoke(handler, outgoing.get(0));
|
||||
assertEquals(0, backendReplies.size(), "reply for another proxy must not be relayed");
|
||||
var local = new VotingPluginWire.SessionFenceRequest(request.player(), request.nonce(), request.server(), "Receiver");
|
||||
receive.invoke(handler, VotingPluginWire.sessionFenceReply(local, reply.fence()));
|
||||
assertEquals(1, backendReplies.size());
|
||||
assertEquals(local, VotingPluginWire.readSessionFenceReply(backendReplies.get(0)).request());
|
||||
}
|
||||
|
||||
private void assertReceiverCooldownTime(long senderTime) throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
UUID voteId = UUID.randomUUID();
|
||||
VotingPluginProxyTestImpl proxy = spy(new VotingPluginProxyTestImpl());
|
||||
proxy.setDataFolder(temporaryDirectory.toFile());
|
||||
VoteCacheHandler voteCache = mock(VoteCacheHandler.class);
|
||||
when(voteCache.markMultiProxyVoteCompletedDurably(any())).thenReturn(true);
|
||||
when(voteCache.getTimeChangeQueue()).thenReturn(new ConcurrentLinkedQueue<>());
|
||||
doReturn(voteCache).when(proxy).getVoteCacheHandler();
|
||||
doNothing().when(proxy).addVoteParty();
|
||||
proxy.setMethod(BungeeMethod.PLUGINMESSAGING);
|
||||
proxy.setGlobalMessageProxyHandlerForTest(new GlobalMessageProxyHandler() {
|
||||
@Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { }
|
||||
});
|
||||
|
||||
// Install the production callback while transport startup is disabled. Only
|
||||
// the transport acknowledgement is stubbed; decoding and vote production run.
|
||||
proxy.loadMultiProxySupport();
|
||||
MultiProxyHandler handler = spy(proxy.getMultiProxyHandler());
|
||||
doNothing().when(handler).acknowledgeMultiProxyVote(any(), anyString());
|
||||
proxy.setMultiProxyHandler(handler);
|
||||
when(proxy.getConfig().getMultiProxySupport()).thenReturn(true);
|
||||
when(proxy.getConfig().getPrimaryServer()).thenReturn(false);
|
||||
when(proxy.getConfig().getOnlineMode()).thenReturn(true);
|
||||
when(proxy.getConfig().getBungeeManageTotals()).thenReturn(true);
|
||||
when(proxy.getConfig().getSendVotesToAllServers()).thenReturn(false);
|
||||
Field legacyServers = VotingPluginProxy.class.getDeclaredField("legacyVoteDeliveryServers");
|
||||
legacyServers.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Set<String> legacy = (java.util.Set<String>) legacyServers.get(proxy);
|
||||
legacy.add("server1");
|
||||
legacy.add("server2");
|
||||
|
||||
Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class);
|
||||
handleEnvelope.setAccessible(true);
|
||||
long beforeReceive = System.currentTimeMillis();
|
||||
handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVote("Player", playerId.toString(),
|
||||
"Service", senderTime, true, true, "", voteId, false, false, 1, 1, "Primary", true));
|
||||
long afterReceive = System.currentTimeMillis();
|
||||
|
||||
JsonEnvelope emitted = proxy.getLastVoteEnvelope();
|
||||
assertNotNull(emitted, "the actual receiver must produce a backend vote");
|
||||
VotingPluginWire.Vote forwarded = VotingPluginWire.readVote(emitted);
|
||||
assertTrue(forwarded.time >= beforeReceive && forwarded.time <= afterReceive,
|
||||
"ordinary backend LastVotes time must use the receiver clock");
|
||||
assertEquals(voteId, forwarded.voteId);
|
||||
assertEquals(playerId.toString(), forwarded.uuid);
|
||||
assertTrue(forwarded.delayValidationKnown);
|
||||
assertTrue(forwarded.delayValidated);
|
||||
assertTrue(forwarded.queuedDeliveryKnown);
|
||||
|
||||
// Exercise the real backend router and event adapter. No guide manager is
|
||||
// installed: provenance must not change ordinary vote/cooldown accounting.
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
UserManager users = mock(UserManager.class);
|
||||
VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
Field userPlugin = VotingPluginUser.class.getDeclaredField("plugin");
|
||||
userPlugin.setAccessible(true);
|
||||
userPlugin.set(user, plugin);
|
||||
when(user.getPlayerName()).thenReturn("Player");
|
||||
doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(anyString(), anyLong(),
|
||||
any(VoteTotalsSnapshot.class), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(),
|
||||
anyBoolean(), anyBoolean(), anyBoolean(), any(UUID.class), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
BungeeSettings settings = mock(BungeeSettings.class);
|
||||
when(settings.isUseBungeecoord()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings()).thenReturn(settings);
|
||||
when(plugin.getVotingPluginUserManager()).thenReturn(users);
|
||||
when(users.getVotingPluginUser(playerId, "Player")).thenReturn(user);
|
||||
ServerData serverData = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(serverData);
|
||||
VoteSiteManager sites = mock(VoteSiteManager.class);
|
||||
VoteSite site = mock(VoteSite.class);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);
|
||||
when(sites.getVoteSite("Service", true)).thenReturn(site);
|
||||
Server server = mock(Server.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
ProcessedVoteCache processed = mock(ProcessedVoteCache.class);
|
||||
when(processed.reserveWithOutcome(voteId)).thenReturn(ProcessedVoteCache.Reservation.RESERVED);
|
||||
when(processed.complete(voteId)).thenReturn(true);
|
||||
BackendProxyMessageRouter router = new BackendProxyMessageRouter(plugin,
|
||||
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
|
||||
mock(BackendVotePartySync.class), processed);
|
||||
// Check completion afterwards so a fixture error retains its original
|
||||
// exception instead of being replaced by the quarantine callback assertion.
|
||||
AtomicReference<BackendProxyMessageRouter.OrderedVoteOutcome> outcome = new AtomicReference<>();
|
||||
router.handleOrderedVote(emitted, outcome::set);
|
||||
assertEquals(BackendProxyMessageRouter.OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
ArgumentCaptor<PlayerVoteEvent> event = ArgumentCaptor.forClass(PlayerVoteEvent.class);
|
||||
verify(events).callEvent(event.capture());
|
||||
assertEquals(forwarded.time, event.getValue().getTime());
|
||||
assertEquals(voteId, event.getValue().getProxyVoteId());
|
||||
assertEquals(!forwarded.manageTotals, event.getValue().isAddTotals());
|
||||
assertTrue(event.getValue().isUnconfirmedProxySessionDelivery());
|
||||
assertTrue(event.getValue().isProxyDelayValidationKnown());
|
||||
assertTrue(event.getValue().isProxyQueueClassificationKnown());
|
||||
assertEquals(forwarded.queuedDelivery, event.getValue().isQueuedProxyVote());
|
||||
|
||||
assertNormalAccountingTime(event.getValue(), site, user);
|
||||
}
|
||||
|
||||
private void assertNormalAccountingTime(PlayerVoteEvent event, VoteSite site, VotingPluginUser user) {
|
||||
@SuppressWarnings("unchecked")
|
||||
SharedVoteProcessor.Operations<VoteSite, VotingPluginUser> operations = mock(SharedVoteProcessor.Operations.class);
|
||||
when(operations.enabled()).thenReturn(true);
|
||||
when(operations.incomingName()).thenReturn("Player");
|
||||
when(operations.properName("Player")).thenReturn("Player");
|
||||
when(operations.validate("Player", false)).thenReturn(
|
||||
new SharedVoteProcessor.Validation(true, "Player", "CACHE", "ok", false));
|
||||
when(operations.resolveSite()).thenReturn(site);
|
||||
when(operations.siteEnabled(site)).thenReturn(true);
|
||||
when(operations.resolveUser("Player")).thenReturn(user);
|
||||
when(operations.proxyVote()).thenReturn(event.isBungee());
|
||||
when(operations.forceProxyRouting()).thenReturn(event.isForceBungee());
|
||||
when(operations.wasOnline()).thenReturn(event.isWasOnline());
|
||||
when(operations.realVote()).thenReturn(event.isRealVote());
|
||||
when(operations.addTotals()).thenReturn(event.isAddTotals());
|
||||
when(operations.proxyVoteId()).thenReturn(event.getProxyVoteId());
|
||||
when(operations.proxyQueueClassificationKnown()).thenReturn(event.isProxyQueueClassificationKnown());
|
||||
when(operations.proxyDelayValidationKnown()).thenReturn(event.isProxyDelayValidationKnown());
|
||||
when(operations.incomingTime()).thenReturn(event.getTime());
|
||||
when(operations.countingPolicy()).thenReturn(new SharedVotePolicy(false, true, false, false, false));
|
||||
SharedVoteProcessor.process(operations);
|
||||
verify(operations).setTime(user, site, event.getTime());
|
||||
verify(operations).cooldown(user, site);
|
||||
}
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.api.item.FullInventoryHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class SDKServerDataOwnerLockTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void nativeInventoryOwnerExcludesOrdinaryPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(false, false);
|
||||
}
|
||||
|
||||
@Test void nativeInventoryOwnerExcludesFinalPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(false, true);
|
||||
}
|
||||
|
||||
@Test void wrappedInventoryOwnerExcludesOrdinaryPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(true, false);
|
||||
}
|
||||
|
||||
@Test void wrappedInventoryOwnerExcludesFinalPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(true, true);
|
||||
}
|
||||
|
||||
@Test void composedInventoryOwnerExcludesOrdinaryPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(2, false);
|
||||
}
|
||||
|
||||
@Test void composedInventoryOwnerExcludesFinalPublication() throws Exception {
|
||||
assertInventoryReplacementIsAtomic(2, true);
|
||||
}
|
||||
|
||||
@Test void composedFacadeAdvertisesTheInnermostYamlOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
try {
|
||||
var inner = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
var outer = new GenerationFencedServerDataFile(plugin, inner, fence);
|
||||
assertSame(backing, outer.publicationMonitor(), "the delegate can itself be an adapter");
|
||||
} finally { fence.retire(); }
|
||||
}
|
||||
}
|
||||
|
||||
private void assertInventoryReplacementIsAtomic(boolean wrapped, boolean finalPublication) throws Exception {
|
||||
assertInventoryReplacementIsAtomic(wrapped ? 1 : 0, finalPublication);
|
||||
}
|
||||
|
||||
private void assertInventoryReplacementIsAtomic(int facadeDepth, boolean finalPublication) throws Exception {
|
||||
org.bukkit.configuration.serialization.ConfigurationSerialization.registerClass(PendingItem.class);
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(Logger.class));
|
||||
when(plugin.getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class));
|
||||
// Exercise the public SDK writer. Startup has no optional file yet;
|
||||
// install the real owner before admitting either writer below.
|
||||
var inventory = new FullInventoryHandler(plugin);
|
||||
inventory.getTimer().shutdownNow();
|
||||
var cleared = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var armed = new AtomicBoolean();
|
||||
var observedIntermediate = new AtomicBoolean();
|
||||
var yaml = new YamlConfiguration() {
|
||||
@Override public void set(String path, Object value) {
|
||||
super.set(path, value);
|
||||
if (path.equals("FullInventory") && value == null && armed.compareAndSet(true, false)) {
|
||||
cleared.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public String saveToString() {
|
||||
String text = super.saveToString();
|
||||
if (cleared.getCount() == 0 && release.getCount() != 0 && !contains("FullInventory")) {
|
||||
observedIntermediate.set(true);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
};
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override public void saveDataChecked() {
|
||||
// New SDK owners mutate an isolated candidate, so clearing
|
||||
// the live YAML is no longer a guaranteed observable seam.
|
||||
// Pause the actual native publication instead. The legacy
|
||||
// clear hook above still exercises its temporary-live gap.
|
||||
if (armed.compareAndSet(true, false)) {
|
||||
assertTrue(Thread.holdsLock(this),
|
||||
"native snapshot publication must own the backing monitor");
|
||||
cleared.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
super.saveDataChecked();
|
||||
}
|
||||
};
|
||||
backing.setData(yaml);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
com.bencodez.advancedcore.data.ServerData composed = backing;
|
||||
for (int depth = 0; depth < facadeDepth; depth++) {
|
||||
composed = new GenerationFencedServerDataFile(plugin, composed, fence);
|
||||
}
|
||||
var exposed = composed;
|
||||
when(plugin.getServerDataFile()).thenReturn(exposed);
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
data.getData();
|
||||
UUID previous = new UUID(0, 2);
|
||||
inventory.getItems().put(previous, new ArrayList<>(java.util.List.of(new PendingItem(1))));
|
||||
assertTrue(inventory.saveDurably());
|
||||
var initialDisk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertInstanceOf(PendingItem.class, initialDisk.get("FullInventory." + previous + ".Items.0"));
|
||||
assertEquals(1, initialDisk.getItemStack("FullInventory." + previous + ".Items.0").getAmount());
|
||||
inventory.getItems().clear();
|
||||
UUID replacement = new UUID(0, 1);
|
||||
inventory.getItems().put(replacement, new ArrayList<>(java.util.List.of(new PendingItem(2))));
|
||||
var workers = Executors.newFixedThreadPool(2);
|
||||
var inventoryThread = new AtomicReference<Thread>();
|
||||
var competingThread = new AtomicReference<Thread>();
|
||||
var competingStarted = new CountDownLatch(1);
|
||||
try {
|
||||
armed.set(true);
|
||||
var saving = workers.submit(() -> {
|
||||
inventoryThread.set(Thread.currentThread());
|
||||
return inventory.saveDurably();
|
||||
});
|
||||
assertTrue(cleared.await(5, TimeUnit.SECONDS));
|
||||
var competing = workers.submit(() -> {
|
||||
competingThread.set(Thread.currentThread());
|
||||
competingStarted.countDown();
|
||||
if (finalPublication) {
|
||||
exposed.mutateFinalData(snapshot -> {
|
||||
if (release.getCount() != 0 && !snapshot.contains("FullInventory")) {
|
||||
observedIntermediate.set(true);
|
||||
}
|
||||
snapshot.set("SyntheticFinal.Value", "confirmed");
|
||||
}).toCompletableFuture().join();
|
||||
} else {
|
||||
data.setInt("VoteParty.Total", 7);
|
||||
}
|
||||
});
|
||||
assertTrue(competingStarted.await(5, TimeUnit.SECONDS));
|
||||
var threads = ManagementFactory.getThreadMXBean();
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
boolean blockedByOwner = false;
|
||||
while (!observedIntermediate.get() && !competing.isDone() && System.nanoTime() < deadline) {
|
||||
var info = threads.getThreadInfo(competingThread.get().threadId());
|
||||
if (info != null && info.getThreadState() == Thread.State.BLOCKED
|
||||
&& info.getLockOwnerId() == inventoryThread.get().threadId()
|
||||
&& info.getLockInfo() != null
|
||||
&& info.getLockInfo().getIdentityHashCode() == System.identityHashCode(backing)) {
|
||||
blockedByOwner = true;
|
||||
break;
|
||||
}
|
||||
Thread.yield();
|
||||
}
|
||||
assertTrue(blockedByOwner || observedIntermediate.get() || competing.isDone(),
|
||||
"competing writer never reached the ownership or serialization boundary");
|
||||
// Confirm the defective publication before releasing the SDK
|
||||
// replacement, rather than racing two unsynchronized YAML edits.
|
||||
if (observedIntermediate.get()) competing.get(5, TimeUnit.SECONDS);
|
||||
boolean publishedIntermediate = observedIntermediate.get();
|
||||
release.countDown();
|
||||
assertTrue(saving.get(5, TimeUnit.SECONDS));
|
||||
competing.get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile());
|
||||
assertTrue(disk.contains("FullInventory." + replacement + ".Time"));
|
||||
assertEquals(2, disk.getItemStack("FullInventory." + replacement + ".Items.0").getAmount());
|
||||
assertFalse(disk.contains("FullInventory." + previous));
|
||||
if (finalPublication) assertEquals("confirmed", disk.getString("SyntheticFinal.Value"));
|
||||
else assertEquals(7, disk.getInt("VotingPlugin.VoteParty.Total"));
|
||||
assertFalse(publishedIntermediate,
|
||||
"a successful publication serialized the SDK's temporarily cleared recovery section");
|
||||
assertTrue(blockedByOwner, "the competing writer must share the actual SDK transaction monitor");
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
inventory.getTimer().shutdownNow();
|
||||
assertTrue(inventory.getTimer().awaitTermination(5, TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
} finally {
|
||||
org.bukkit.configuration.serialization.ConfigurationSerialization.unregisterClass(PendingItem.class);
|
||||
}
|
||||
}
|
||||
|
||||
/** Headless item serialization keeps the actual SDK writer and recovery YAML in the test. */
|
||||
@org.bukkit.configuration.serialization.SerializableAs("VPServerDataMonitorTestItem")
|
||||
public static final class PendingItem extends org.bukkit.inventory.ItemStack {
|
||||
public PendingItem(int amount) { super(org.bukkit.Material.STONE, amount); }
|
||||
@Override public java.util.Map<String, Object> serialize() { return java.util.Map.of("amount", getAmount()); }
|
||||
public static PendingItem deserialize(java.util.Map<String, Object> values) {
|
||||
return new PendingItem(((Number) values.get("amount")).intValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.AdvancedCorePlugin;
|
||||
import com.bencodez.advancedcore.api.time.TimeType;
|
||||
import com.bencodez.advancedcore.data.ServerData;
|
||||
import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
|
||||
class SDKServerDataPublicationTest {
|
||||
@TempDir Path directory;
|
||||
private static final class StartupReached extends IllegalStateException { }
|
||||
|
||||
private VotingPluginMain plugin() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
// Mockito bypasses constructors; use the SDK's actual lifecycle lock.
|
||||
var lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock");
|
||||
lock.setAccessible(true);
|
||||
lock.set(plugin, new Object());
|
||||
doNothing().when(plugin).registerEvents(any());
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doReturn("VotingPlugin").when(plugin).getName();
|
||||
doReturn(new PluginDescriptionFile("VotingPlugin", "1", "synthetic.Main")).when(plugin).getDescription();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static org.mockito.MockedConstruction<AdvancedCoreLifecycle> completedCleanup() {
|
||||
// Control only unrelated platform cleanup. Real SDK startup admission,
|
||||
// file publishers and VotingPlugin generation ownership stay in use.
|
||||
// These fixtures do not claim full platform shutdown validation.
|
||||
return mockConstruction(AdvancedCoreLifecycle.class, (cleanup, context) -> {
|
||||
var plugin = (VotingPluginMain) context.arguments().get(0);
|
||||
doAnswer(call -> {
|
||||
var completed = CompletableFuture.<Void>completedFuture(null);
|
||||
plugin.onShutdownComplete(completed);
|
||||
plugin.releaseServerDataOwnershipAfter(completed);
|
||||
return null;
|
||||
}).when(cleanup).shutdown(anyLong());
|
||||
});
|
||||
}
|
||||
|
||||
private static void awaitCapturedRetirement(VotingPluginMain plugin) throws Exception {
|
||||
plugin.getServerDataPublicationFence().retainUntilTermination(plugin.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static void installSDKFile(VotingPluginMain plugin, ServerData file) throws Exception {
|
||||
var field = AdvancedCorePlugin.class.getDeclaredField("serverDataFile");
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, file);
|
||||
}
|
||||
|
||||
private static ScheduledExecutorService terminatedExecutor() {
|
||||
var executor = mock(ScheduledExecutorService.class);
|
||||
when(executor.isTerminated()).thenReturn(true);
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Test void rejectedSDKAdmissionRetiresOnlyItsUnusedPluginGenerationAndCanRetryAfterRelease() throws Exception {
|
||||
var plugin = plugin();
|
||||
var delayedRelease = new CompletableFuture<Void>();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
plugin.releaseServerDataOwnershipAfter(delayedRelease);
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
awaitCapturedRetirement(plugin);
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
var failure = assertThrows(IllegalStateException.class, plugin::onEnable);
|
||||
assertEquals("Previous ServerData shutdown has not finished", failure.getMessage());
|
||||
plugin.onDisable();
|
||||
}
|
||||
delayedRelease.complete(null);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
awaitCapturedRetirement(plugin);
|
||||
} finally {
|
||||
delayedRelease.complete(null);
|
||||
plugin.onShutdownComplete(CompletableFuture.completedFuture(null));
|
||||
if (plugin.getServerDataPublicationFence() != null) plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void admittedSDKInitializationFailureStillRetainsItsPendingFinalPublication() throws Exception {
|
||||
var plugin = plugin();
|
||||
var pendingPublication = new CompletableFuture<Void>();
|
||||
var initializationFailure = new IllegalStateException("synthetic executor initialization failure");
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
plugin.onShutdownComplete(pendingPublication);
|
||||
plugin.releaseServerDataOwnershipAfter(pendingPublication);
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any()))
|
||||
.thenThrow(initializationFailure).thenReturn(runtime);
|
||||
assertSame(initializationFailure, assertThrows(IllegalStateException.class, plugin::onEnable));
|
||||
verify(plugin, never()).onPreLoad();
|
||||
assertFalse(pendingPublication.isDone());
|
||||
assertThrows(IllegalStateException.class, plugin::onEnable);
|
||||
pendingPublication.complete(null);
|
||||
awaitCapturedRetirement(plugin);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
awaitCapturedRetirement(plugin);
|
||||
} finally {
|
||||
pendingPublication.complete(null);
|
||||
if (plugin.getServerDataPublicationFence() != null) plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualSDKVersionCallbackRetainsOwnershipOutsideCapturedExecutors() throws Exception {
|
||||
var old = plugin(); var next = plugin();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var scheduler = mock(BukkitScheduler.class);
|
||||
var callback = new AtomicReference<Runnable>();
|
||||
doAnswer(call -> { callback.set(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTaskAsynchronously(any(), any());
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(scheduler,
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService externalWriter = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var cleanup = completedCleanup();
|
||||
var constructors = mockConstruction(BukkitScheduler.class)) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
var writingReference = new AtomicReference<Future<?>>();
|
||||
doAnswer(startup -> {
|
||||
var backing = new ServerData(old);
|
||||
backing.setData(yaml); backing.setPluginVersion(old);
|
||||
installSDKFile(old, backing);
|
||||
assertSame(yaml, old.getServerDataFile().getData());
|
||||
old.setLoadServerData(true);
|
||||
old.checkPluginUpdate();
|
||||
assertNotNull(callback.get());
|
||||
doAnswer(call -> {
|
||||
entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
// The SDK now cleans up a failed startup itself. Start the
|
||||
// real captured writer before that cleanup retires startup.
|
||||
writingReference.set(externalWriter.submit(callback.get()));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
throw new StartupReached();
|
||||
}).when(old).onPreLoad();
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
Future<?> writing = writingReference.get();
|
||||
assertNotNull(writing);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
old.onDisable();
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached,
|
||||
"SDK publication must retain ownership even when every captured executor is terminated");
|
||||
verify(next, never()).onPreLoad();
|
||||
release.countDown(); writing.get(5, TimeUnit.SECONDS);
|
||||
var successor = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
successor.set("Adjacent.Value", "successor"); successor.save(target.toFile());
|
||||
doAnswer(startup -> {
|
||||
// Run while the SDK's replacement plugin identity is live,
|
||||
// before its sentinel failure triggers automatic cleanup.
|
||||
assertThrows(IllegalStateException.class, callback.get()::run,
|
||||
"an old callback that starts after replacement cannot publish");
|
||||
assertThrows(IllegalStateException.class, () -> old.getServerDataFile().setPrevDay(12));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("Adjacent.Value"));
|
||||
throw new StartupReached();
|
||||
}).when(next).onPreLoad();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
awaitCapturedRetirement(next);
|
||||
next.getServerDataPublicationFence().retire();
|
||||
} finally {
|
||||
release.countDown(); externalWriter.shutdownNow();
|
||||
assertTrue(externalWriter.awaitTermination(5, TimeUnit.SECONDS));
|
||||
if (old.getServerDataPublicationFence() != null) old.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inheritedFileOperationsKeepTheSDKConfigurationAuthoritative() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var backing = new ServerData(plugin); backing.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, backing);
|
||||
var facade = plugin.getServerDataFile();
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
facade.setIgnoreCase(true);
|
||||
assertTrue(backing.isIgnoreCase()); assertTrue(facade.isIgnoreCase());
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
facade.setValue("Sample.Value", "kept");
|
||||
facade.createSection("Sample.Section");
|
||||
var merged = new YamlConfiguration(); merged.set("Adjacent.Value", "merged");
|
||||
facade.setData(merged); facade.saveData();
|
||||
facade.reloadData();
|
||||
assertSame(backing.getData(), facade.getData());
|
||||
assertEquals("kept", facade.getData().getString("Sample.Value"));
|
||||
assertEquals("merged", facade.getData().getString("Adjacent.Value"));
|
||||
assertNotNull(facade.getData().getConfigurationSection("Sample.Section"));
|
||||
assertEquals(backing.isJustCreated(), facade.isJustCreated());
|
||||
assertEquals(backing.isFailedToRead(), facade.isFailedToRead());
|
||||
assertSame(backing.getPlugin(), facade.getPlugin());
|
||||
assertEquals(backing.getdFile(), facade.getdFile());
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void oldVotingPluginWrapperDoesNotBorrowTheSamePluginObjectsReplacementFileOrFence() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var first = new ServerData(plugin); first.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, first);
|
||||
var stale = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
stale.getData().set("Adjacent.Value", "first"); stale.saveData();
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, replacement);
|
||||
var current = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
current.getData().set("Adjacent.Value", "successor"); current.saveData();
|
||||
assertThrows(IllegalStateException.class, () -> stale.addTimeVoted(0,
|
||||
new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L)));
|
||||
assertEquals("successor", current.getData().getString("Adjacent.Value"));
|
||||
assertNull(current.getData().get("TimedVoteCache"));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile())
|
||||
.getString("VotingPlugin.Adjacent.Value"));
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void firstUseOfAnOldWrapperAfterReenableDoesNotMutateTheReplacementFile() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var first = new ServerData(plugin); first.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, first);
|
||||
var stale = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, replacement);
|
||||
var current = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
current.getData().set("Adjacent.Value", "successor"); current.saveData();
|
||||
assertThrows(IllegalStateException.class, () -> stale.addTimeVoted(0,
|
||||
new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L)));
|
||||
assertEquals("successor", current.getData().getString("Adjacent.Value"));
|
||||
assertNull(current.getData().get("TimedVoteCache"));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile())
|
||||
.getString("VotingPlugin.Adjacent.Value"));
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReenableBeforeFileReplacementCanRecoverAfterNativeTimestampCleanup() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
var observed = new AtomicReference<CompletionStage<Void>>();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> {
|
||||
doAnswer(call -> {
|
||||
// Exercise the actual SDK timestamp submission, while
|
||||
// unrelated platform teardown remains controlled.
|
||||
var platform = new com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform(plugin);
|
||||
var method = platform.getClass().getDeclaredMethod("submitTimestampPersistence", Executor.class);
|
||||
method.setAccessible(true);
|
||||
var workerField = platform.getClass().getDeclaredField("persistenceWorker");
|
||||
workerField.setAccessible(true);
|
||||
var worker = (ExecutorService) workerField.get(platform);
|
||||
CompletionStage<Void> stage;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
var submitted = (CompletionStage<Void>) method.invoke(platform, worker);
|
||||
stage = submitted;
|
||||
} finally {
|
||||
worker.shutdown();
|
||||
}
|
||||
observed.set(stage);
|
||||
plugin.onShutdownComplete(stage);
|
||||
plugin.releaseServerDataOwnershipAfter(stage);
|
||||
return null;
|
||||
}).when(owner).shutdown(anyLong());
|
||||
})) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
var initialFailure = assertThrows(StartupReached.class, plugin::onEnable);
|
||||
assertEquals(0, initialFailure.getSuppressed().length,
|
||||
"native timestamp cleanup must actually execute without hidden fixture failures");
|
||||
assertNotNull(observed.get(), "capture the actual SDK final-publication stage");
|
||||
observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
awaitCapturedRetirement(plugin);
|
||||
var backing = new ServerData(plugin); backing.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, backing);
|
||||
var retiredFacade = plugin.getServerDataFile();
|
||||
assertNotNull(retiredFacade);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
awaitCapturedRetirement(plugin);
|
||||
assertThrows(IllegalStateException.class, () -> retiredFacade.setPrevDay(12));
|
||||
assertThrows(StartupReached.class, plugin::onEnable,
|
||||
"a failed pre-load must not permanently reserve either generation owner");
|
||||
observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
awaitCapturedRetirement(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fileReadBetweenReenableAndSDKReplacementWithholdsPredecessorAndKeepsCapturedFence() throws Exception {
|
||||
var plugin = plugin();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var backing = new ServerData(plugin); backing.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, backing);
|
||||
backing.setPrevDay(1);
|
||||
var predecessor = plugin.getServerDataFile();
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
var intervening = new AtomicReference<ServerData>();
|
||||
doAnswer(call -> {
|
||||
intervening.set(plugin.getServerDataFile());
|
||||
throw new StartupReached();
|
||||
}).when(plugin).onPreLoad();
|
||||
assertThrows(StartupReached.class, plugin::onEnable);
|
||||
var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration());
|
||||
installSDKFile(plugin, replacement);
|
||||
plugin.getServerDataFile().setValue("Adjacent.Value", "successor");
|
||||
assertNull(intervening.get(), "pre-load must not expose a predecessor file to native final publication");
|
||||
assertThrows(IllegalStateException.class, () -> predecessor.setPrevDay(12));
|
||||
assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile())
|
||||
.getString("Adjacent.Value"));
|
||||
awaitCapturedRetirement(plugin);
|
||||
plugin.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sdkDurableTransitionPublicationAlsoOwnsTheGenerationUntilItReturns() throws Exception {
|
||||
var old = plugin(); var next = plugin();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService externalWriter = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var constructors = mockConstruction(BukkitScheduler.class);
|
||||
var completedCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
var backing = new ServerData(old); backing.setData(yaml); installSDKFile(old, backing);
|
||||
doAnswer(call -> {
|
||||
entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).saveToString();
|
||||
var file = old.getServerDataFile();
|
||||
Future<ServerData.TimeChangeTransitionState> writing = externalWriter.submit(() -> file.beginTimeChangeTransition(TimeType.DAY, "synthetic-period", "12"));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
old.getServerDataPublicationFence().retire();
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached);
|
||||
release.countDown(); var transition = writing.get(5, TimeUnit.SECONDS);
|
||||
var restored = new ServerData(old); restored.setData(YamlConfiguration.loadConfiguration(target.toFile()));
|
||||
assertEquals(transition, restored.getPendingTimeChangeTransition(TimeType.DAY));
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertThrows(IllegalStateException.class, () -> file.completeTimeChangeTransition(transition));
|
||||
next.getServerDataPublicationFence().retire();
|
||||
} finally {
|
||||
release.countDown(); externalWriter.shutdownNow();
|
||||
assertTrue(externalWriter.awaitTermination(5, TimeUnit.SECONDS));
|
||||
if (old.getServerDataPublicationFence() != null) old.getServerDataPublicationFence().retire();
|
||||
}
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
|
||||
class ServerDataGenerationShutdownTest {
|
||||
@TempDir Path directory;
|
||||
private static final class StartupReached extends IllegalStateException { }
|
||||
|
||||
private static void initializePlugin(VotingPluginMain plugin) throws Exception {
|
||||
// Constructor-bypassing Mockito objects need the real SDK lifecycle lock.
|
||||
var lock = com.bencodez.advancedcore.AdvancedCorePlugin.class.getDeclaredField("lifecycleLock");
|
||||
lock.setAccessible(true);
|
||||
lock.set(plugin, new Object());
|
||||
doNothing().when(plugin).registerEvents(any());
|
||||
}
|
||||
|
||||
private static org.mockito.MockedConstruction<AdvancedCoreLifecycle> completedCleanup() {
|
||||
// Only platform cleanup is controlled. Actual plugin admission, timed
|
||||
// publication and generation/writer reservations remain under test.
|
||||
return mockConstruction(AdvancedCoreLifecycle.class, (cleanup, context) -> {
|
||||
var plugin = (VotingPluginMain) context.arguments().get(0);
|
||||
doAnswer(call -> {
|
||||
for (var writer : plugin.getServerDataWriterExecutors())
|
||||
if (writer != null) writer.shutdown();
|
||||
var completed = CompletableFuture.<Void>completedFuture(null);
|
||||
plugin.onShutdownComplete(completed);
|
||||
plugin.releaseServerDataOwnershipAfter(completed);
|
||||
return null;
|
||||
}).when(cleanup).shutdown(anyLong());
|
||||
});
|
||||
}
|
||||
|
||||
private static ScheduledExecutorService terminatedExecutor() {
|
||||
var executor = mock(ScheduledExecutorService.class);
|
||||
when(executor.isTerminated()).thenReturn(true);
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Test void failedShutdownSuffixKeepsItsGenerationUntilExplicitRetryPersistsEveryVote() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
initializePlugin(plugin);
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target);
|
||||
var field = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence");
|
||||
field.setAccessible(true); field.set(plugin, fence);
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(plugin).getServerDataFile();
|
||||
doReturn(new ServerData(plugin)).when(plugin).getServerData();
|
||||
doReturn(new ScheduledExecutorService[0]).when(plugin).getServerDataWriterExecutors();
|
||||
var producer = new ScheduledThreadPoolExecutor(1);
|
||||
producer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producer).when(plugin).getVoteTimer();
|
||||
try {
|
||||
var handler = new TimeQueueHandler(plugin);
|
||||
handler.addVote("First", "site");
|
||||
handler.addVote("Second", "site");
|
||||
handler.addVote("Third", "site");
|
||||
producer.shutdown(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
int attempt = writes.incrementAndGet();
|
||||
if (attempt == 2 || attempt == 3) throw new java.io.IOException("synthetic suffix failure");
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown);
|
||||
fence.retire();
|
||||
assertEquals(2, handler.getTimeChangeQueue().size());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target));
|
||||
assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown);
|
||||
assertEquals(2, handler.getTimeChangeQueue().size());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target));
|
||||
handler.saveForShutdown();
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
var recovered = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("First", recovered.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals("Second", recovered.getString("VotingPlugin.TimedVoteCache.1.Name"));
|
||||
assertEquals("Third", recovered.getString("VotingPlugin.TimedVoteCache.2.Name"));
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
} finally {
|
||||
producer.shutdownNow(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedShutdownWorkerRetryRetainsTheOriginalProducerAfterUnloadClearsItsField() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
initializePlugin(plugin);
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target);
|
||||
var field = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence");
|
||||
field.setAccessible(true); field.set(plugin, fence);
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(plugin).getServerDataFile();
|
||||
doReturn(new ServerData(plugin)).when(plugin).getServerData();
|
||||
var releaseCore = new CountDownLatch(1);
|
||||
var coreWriter = mock(ScheduledExecutorService.class);
|
||||
when(coreWriter.isTerminated()).thenAnswer(call -> releaseCore.getCount() == 0);
|
||||
when(coreWriter.awaitTermination(anyLong(), any())).thenAnswer(call ->
|
||||
releaseCore.await(call.getArgument(0), call.getArgument(1)));
|
||||
var capturedWriters = new ScheduledExecutorService[] { coreWriter };
|
||||
doReturn(capturedWriters).when(plugin).getServerDataWriterExecutors();
|
||||
var producer = new ScheduledThreadPoolExecutor(1);
|
||||
producer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producer).when(plugin).getVoteTimer();
|
||||
var failWrites = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
if (failWrites.get()) throw new java.io.IOException("synthetic late suffix failure");
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
var handler = new TimeQueueHandler(plugin) {
|
||||
private boolean reject = true;
|
||||
@Override protected void startShutdownWorker(Runnable task) {
|
||||
if (reject) { reject = false; throw new RejectedExecutionException("synthetic admission failure"); }
|
||||
super.startShutdownWorker(task);
|
||||
}
|
||||
};
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var lateFailure = new java.util.concurrent.atomic.AtomicReference<Throwable>();
|
||||
try {
|
||||
producer.submit(() -> {
|
||||
entered.countDown();
|
||||
boolean interrupted = false;
|
||||
while (true) {
|
||||
try { if (release.await(10, TimeUnit.SECONDS)) break; throw new AssertionError("release timeout"); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
try { handler.addVote("Late", "site"); }
|
||||
catch (Throwable failure) { lateFailure.set(failure); }
|
||||
finally { if (interrupted) Thread.currentThread().interrupt(); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
producer.shutdownNow();
|
||||
assertThrows(RejectedExecutionException.class, handler::saveForShutdown);
|
||||
fence.retire();
|
||||
// The actual unload path clears this accessor even if shutdownNow did not terminate work.
|
||||
doReturn(null).when(plugin).getVoteTimer();
|
||||
capturedWriters[0] = null;
|
||||
handler.saveForShutdown();
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire());
|
||||
release.countDown(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertInstanceOf(java.io.UncheckedIOException.class, lateFailure.get());
|
||||
// The original core writer is also retained, independent of the caller's mutable array.
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire());
|
||||
releaseCore.countDown();
|
||||
assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown);
|
||||
assertEquals(1, handler.getTimeChangeQueue().size());
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target));
|
||||
failWrites.set(false); handler.saveForShutdown();
|
||||
assertTrue(handler.getTimeChangeQueue().isEmpty());
|
||||
assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
} finally {
|
||||
release.countDown(); releaseCore.countDown(); producer.shutdownNow();
|
||||
assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
failWrites.set(false);
|
||||
// Do not start a new retry from cleanup or hide the failed ownership assertion.
|
||||
fence.retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedStartupWithoutATimedQueueKeepsCoreWritesFencedThroughActualDisable() throws Exception {
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
ScheduledThreadPoolExecutor writer = new ScheduledThreadPoolExecutor(1);
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
writer, terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
var runningReference = new java.util.concurrent.atomic.AtomicReference<Future<?>>();
|
||||
doAnswer(startup -> {
|
||||
runningReference.set(writer.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
}));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
throw new StartupReached();
|
||||
}).when(old).onPreLoad();
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached);
|
||||
Future<?> running = runningReference.get();
|
||||
assertNotNull(running);
|
||||
old.onDisable();
|
||||
assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached);
|
||||
var completion = old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors());
|
||||
assertFalse(completion.toCompletableFuture().isDone());
|
||||
release.countDown(); running.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
completion.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
next.getServerDataPublicationFence().retire();
|
||||
} finally { release.countDown(); writer.shutdownNow(); assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
|
||||
@Test void heartbeatWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("heartbeat"); }
|
||||
@Test void loginWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("login"); }
|
||||
@Test void inventoryWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("inventory"); }
|
||||
@Test void timeCheckerWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("timechecker"); }
|
||||
|
||||
private void checkCoreWriter(String writerKind) throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
yaml.set("Adjacent.Value", "old");
|
||||
yaml.save(target.toFile());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
doReturn(new ServerData(old)).when(old).getServerData();
|
||||
doReturn(terminatedExecutor()).when(old).getVoteTimer();
|
||||
ScheduledThreadPoolExecutor writer = new ScheduledThreadPoolExecutor(1);
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
switch (writerKind) {
|
||||
case "heartbeat" -> doReturn(writer).when(old).getTimer();
|
||||
case "login" -> doReturn(writer).when(old).getLoginTimer();
|
||||
case "inventory" -> doReturn(writer).when(old).getInventoryTimer();
|
||||
case "timechecker" -> {
|
||||
var checker = mock(com.bencodez.advancedcore.api.time.TimeChecker.class);
|
||||
doReturn(checker).when(old).getTimeChecker();
|
||||
when(checker.getTimer()).thenReturn(writer);
|
||||
}
|
||||
default -> throw new AssertionError(writerKind);
|
||||
}
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
doAnswer(call -> {
|
||||
entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
Future<?> writing = writer.submit(() -> {
|
||||
try { file.getData().save(file.getdFile()); }
|
||||
catch (java.io.IOException failure) { throw new java.io.UncheckedIOException(failure); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached);
|
||||
verify(next, never()).onPreLoad();
|
||||
writer.shutdown(); release.countDown(); writing.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
} finally {
|
||||
release.countDown(); writer.shutdownNow(); caller.shutdownNow();
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void initialShutdownWriteFailureStillRetainsTheRunningProducerReservation() throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
var yaml = spy(new YamlConfiguration());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
doReturn(new ServerData(old)).when(old).getServerData();
|
||||
ScheduledThreadPoolExecutor producers = new ScheduledThreadPoolExecutor(1);
|
||||
producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producers).when(old).getVoteTimer();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
handler.addVote("First", "site");
|
||||
var late = producers.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
handler.addVote("Late", "site");
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS)); producers.shutdown();
|
||||
doThrow(new java.io.IOException("synthetic initial write failure")).doCallRealMethod()
|
||||
.when(yaml).save(target.toFile());
|
||||
caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached);
|
||||
release.countDown(); late.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
try { handler.saveForShutdown(); } catch (java.io.UncheckedIOException observedInitialFailure) { }
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertEquals("First", YamlConfiguration.loadConfiguration(target.toFile()).getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile()).getString("VotingPlugin.TimedVoteCache.1.Name"));
|
||||
} finally {
|
||||
release.countDown(); producers.shutdownNow(); caller.shutdownNow();
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void emptyShutdownSnapshotStillOwnsLateProducersUntilTheyFinish() throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (var plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
doThrow(new StartupReached()).when(plugin).onPreLoad();
|
||||
}
|
||||
var yaml = new YamlConfiguration();
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
var data = new ServerData(old);
|
||||
doReturn(data).when(old).getServerData();
|
||||
ScheduledThreadPoolExecutor producers = new ScheduledThreadPoolExecutor(1);
|
||||
producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
doReturn(producers).when(old).getVoteTimer();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(), terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
Future<?> late = producers.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(10, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
handler.addVote("Late", "site");
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
producers.shutdown();
|
||||
caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached);
|
||||
verify(next, never()).onPreLoad();
|
||||
release.countDown();
|
||||
late.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.TimedVoteCache.0.Name"));
|
||||
} finally {
|
||||
release.countDown(); producers.shutdownNow(); caller.shutdownNow();
|
||||
assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacementCannotReadOrWriteServerDataWhilePreviousShutdownPublicationIsBlocked() throws Exception {
|
||||
Path target = directory.resolve("ServerData.yml");
|
||||
VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
for (VotingPluginMain plugin : java.util.List.of(old, next)) {
|
||||
initializePlugin(plugin);
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(mock(Logger.class)).when(plugin).getLogger();
|
||||
}
|
||||
YamlConfiguration yaml = spy(new YamlConfiguration());
|
||||
yaml.set("Adjacent.Value", "old");
|
||||
yaml.save(target.toFile());
|
||||
var file = mock(com.bencodez.advancedcore.data.ServerData.class);
|
||||
when(file.publicationMonitor()).thenCallRealMethod();
|
||||
when(file.getData()).thenReturn(yaml);
|
||||
when(file.getdFile()).thenReturn(target.toFile());
|
||||
doReturn(file).when(old).getServerDataFile();
|
||||
ServerData data = new ServerData(old);
|
||||
doReturn(data).when(old).getServerData();
|
||||
doReturn(terminatedExecutor()).when(old).getVoteTimer();
|
||||
doThrow(new StartupReached()).when(old).onPreLoad();
|
||||
doAnswer(call -> {
|
||||
YamlConfiguration replacement = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
replacement.set("Adjacent.Value", "replacement");
|
||||
replacement.save(target.toFile());
|
||||
throw new StartupReached();
|
||||
}).when(next).onPreLoad();
|
||||
var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class),
|
||||
terminatedExecutor(), terminatedExecutor(),
|
||||
terminatedExecutor());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class);
|
||||
var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
var controlledCleanup = completedCleanup()) {
|
||||
lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime);
|
||||
assertThrows(StartupReached.class, old::onEnable);
|
||||
old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors())
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
old.getServerDataPublicationFence().retire();
|
||||
TimeQueueHandler handler = new TimeQueueHandler(old);
|
||||
handler.addVote("First", "site");
|
||||
doAnswer(call -> {
|
||||
entered.countDown();
|
||||
assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
return call.callRealMethod();
|
||||
}).when(yaml).save(target.toFile());
|
||||
Future<?> shutdown = caller.submit(handler::saveForShutdown);
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
shutdown.get(2, TimeUnit.SECONDS);
|
||||
IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable);
|
||||
assertFalse(blocked instanceof StartupReached, "replacement must be fenced before any startup read/write");
|
||||
verify(next, never()).onPreLoad();
|
||||
release.countDown();
|
||||
handler.saveForShutdown();
|
||||
assertThrows(StartupReached.class, next::onEnable);
|
||||
assertThrows(IllegalStateException.class, () -> data.addTimeVoted(1,
|
||||
new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L)));
|
||||
assertEquals("replacement", YamlConfiguration.loadConfiguration(target.toFile()).getString("Adjacent.Value"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class ServerDataProcessExitDrainTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
/** Observe entry to the real bounded wait without replacing its completion semantics. */
|
||||
private static final class ObservedCompletion extends CompletableFuture<Void> {
|
||||
final CountDownLatch entered = new CountDownLatch(1);
|
||||
volatile long nanos;
|
||||
@Override public Void get(long timeout, TimeUnit unit)
|
||||
throws InterruptedException, ExecutionException, TimeoutException {
|
||||
nanos = unit.toNanos(timeout);
|
||||
entered.countDown();
|
||||
return super.get(timeout, unit);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object field(Object owner, String name) throws Exception {
|
||||
var f = owner.getClass().getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f.get(owner);
|
||||
}
|
||||
|
||||
private static ObservedCompletion observe(ServerDataPublicationFence fence) throws Exception {
|
||||
var completion = new ObservedCompletion();
|
||||
var f = ServerDataPublicationFence.class.getDeclaredField("processExitCompletion");
|
||||
f.setAccessible(true);
|
||||
f.set(fence, completion);
|
||||
return completion;
|
||||
}
|
||||
|
||||
@Test void outstandingLeaseKeepsTheActualProcessHookWaitingUntilRetirementDrains() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
var lease = fence.acquire();
|
||||
fence.retainForProcessExit();
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
fence.retainForProcessExit();
|
||||
assertSame(hook, field(fence, "processHook"), "Repeated enrollment must not register another hook");
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var exit = caller.submit(hook::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(completion.isDone());
|
||||
assertFalse(exit.isDone(), "A daemon reservation must still be represented at process exit");
|
||||
lease.close();
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(completion.isDone());
|
||||
ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")).retire();
|
||||
} finally {
|
||||
lease.close();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void repeatedDisableCannotRenewTheOriginalSharedDeadline() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
var lease = fence.acquire();
|
||||
fence.retainForProcessExit();
|
||||
long original = System.nanoTime() - TimeUnit.SECONDS.toNanos(1);
|
||||
fence.beginShutdown(original);
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
var started = (CompletableFuture<?>) field(fence, "processShutdownStarted");
|
||||
assertEquals(original, started.get());
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
var exit = caller.submit(hook::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(completion.nanos > 0 && completion.nanos <= TimeUnit.SECONDS.toNanos(14),
|
||||
"Process exit must use the remaining disable budget, not another fifteen seconds");
|
||||
lease.close();
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
lease.close();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void expiredProcessBudgetDoesNotCertifyFailedPublicationOrLiftGenerationAdmission() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var completion = observe(fence);
|
||||
fence.retainForProcessExit();
|
||||
fence.retainUntilSuccessfulPublication(CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O")));
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
fence.beginShutdown(System.nanoTime() - TimeUnit.SECONDS.toNanos(16));
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
caller.submit(hook::run).get(5, TimeUnit.SECONDS);
|
||||
assertFalse(completion.isDone(), "Deadline expiry cannot report persistence success");
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
} finally {
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interruptedProcessWaitPreservesInterruptionWithoutAcknowledgingTheWriter() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
var lease = fence.acquire();
|
||||
fence.retainForProcessExit();
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
fence.retire();
|
||||
var interrupted = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var hook = (Thread) field(fence, "processHook");
|
||||
var observer = new Thread(() -> {
|
||||
hook.run();
|
||||
interrupted.set(Thread.currentThread().isInterrupted());
|
||||
});
|
||||
try {
|
||||
observer.start();
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
observer.interrupt();
|
||||
observer.join(5000);
|
||||
assertFalse(observer.isAlive());
|
||||
assertTrue(interrupted.get());
|
||||
assertFalse(completion.isDone());
|
||||
} finally {
|
||||
lease.close();
|
||||
observer.interrupt();
|
||||
observer.join(5000);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retainedExecutorTerminationIsIncludedWithoutAPreStorageDependencyCycle() throws Exception {
|
||||
var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"));
|
||||
var completion = observe(fence);
|
||||
fence.retainForProcessExit();
|
||||
var writer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
writer.execute(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
writer.shutdown();
|
||||
var retirement = fence.retainUntilTermination(new java.util.concurrent.ScheduledExecutorService[] { writer });
|
||||
fence.beginShutdown(System.nanoTime());
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var exit = caller.submit(((Thread) field(fence, "processHook"))::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(completion.isDone());
|
||||
release.countDown();
|
||||
retirement.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
release.countDown();
|
||||
writer.shutdownNow();
|
||||
caller.shutdownNow();
|
||||
assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedSignPublicationRemainsInTheProcessDrainAfterTheNativeSdkOwnerReleases() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var nativeOwner = com.bencodez.advancedcore.lifecycle.ServerDataOwnership.acquire(target);
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var completion = observe(fence);
|
||||
fence.retainForProcessExit();
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var workers = new ArrayList<Runnable>();
|
||||
var signs = new Signs(plugin) {
|
||||
@Override protected void startShutdownWorker(Runnable worker) { workers.add(worker); }
|
||||
};
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
var sign = mock(SignHandler.class, CALLS_REAL_METHODS);
|
||||
for (var entry : java.util.Map.<String, Object>of("sign", "0", "position", 1,
|
||||
"data", "SyntheticSign").entrySet()) {
|
||||
var f = SignHandler.class.getDeclaredField(entry.getKey());
|
||||
f.setAccessible(true);
|
||||
f.set(sign, entry.getValue());
|
||||
}
|
||||
sign.setLocation(new Location(world, 1, 2, 3));
|
||||
signs.getSigns().add(sign);
|
||||
var published = signs.storeSignsForShutdown();
|
||||
long started = System.nanoTime();
|
||||
nativeOwner.beginShutdown(started);
|
||||
// Exercise the actual SDK ownership release once its own aggregate has settled.
|
||||
// This fixture does not simulate unrelated native cleanup actions.
|
||||
nativeOwner.releaseAfter(CompletableFuture.completedFuture(null));
|
||||
assertTrue(nativeOwner.isReleased());
|
||||
fence.beginShutdown(started);
|
||||
fence.retire();
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var exit = caller.submit(((Thread) field(fence, "processHook"))::run);
|
||||
assertTrue(completion.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(completion.isDone(), "The native release cannot discard the consumer reservation");
|
||||
assertFalse(published.toCompletableFuture().isDone());
|
||||
workers.getFirst().run();
|
||||
published.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
exit.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("SyntheticSign", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.Signs.0.Data"));
|
||||
} finally {
|
||||
if (!published.toCompletableFuture().isDone()) workers.getFirst().run();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.data.GenerationFencedServerDataFile;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.signs.SignHandler;
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
|
||||
|
||||
class SignShutdownPublicationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private static final class Admission extends Signs {
|
||||
final List<Runnable> workers = new ArrayList<>();
|
||||
boolean reject;
|
||||
Admission(VotingPluginMain plugin) { super(plugin); }
|
||||
@Override protected void startShutdownWorker(Runnable work) {
|
||||
if (reject) throw new RejectedExecutionException("Synthetic rejection");
|
||||
workers.add(work);
|
||||
}
|
||||
}
|
||||
|
||||
private VotingPluginMain plugin(ServerDataPublicationFence fence) {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
when(plugin.getDataFolder()).thenReturn(directory.toFile());
|
||||
when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
|
||||
when(plugin.getServerDataPublicationFence()).thenReturn(fence);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private SignHandler sign(VotingPluginMain plugin, String id, Location location, Location skull) throws Exception {
|
||||
var sign = mock(SignHandler.class, CALLS_REAL_METHODS);
|
||||
for (var entry : java.util.Map.<String, Object>of("plugin", plugin, "sign", id,
|
||||
"data", "SyntheticSign" + id, "position", 2).entrySet()) {
|
||||
var field = SignHandler.class.getDeclaredField(entry.getKey());
|
||||
field.setAccessible(true);
|
||||
field.set(sign, entry.getValue());
|
||||
}
|
||||
sign.setLocation(location);
|
||||
sign.setSkullLocation(skull);
|
||||
return sign;
|
||||
}
|
||||
|
||||
@Test void rejectedAdmissionRetainsTheOriginalBatchAndPublishesItOnceOnRetry() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin);
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("VotingPlugin.Unrelated", "preserved");
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var owner = Thread.currentThread();
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenAnswer(call -> {
|
||||
assertSame(owner, Thread.currentThread(), "Platform state must be captured before handoff");
|
||||
return "SyntheticWorld";
|
||||
});
|
||||
var location = new Location(world, -1.5, 64, 2.5);
|
||||
var first = sign(plugin, "0", location, new Location(world, 3, 65, 4));
|
||||
signs.getSigns().add(first);
|
||||
signs.getSigns().add(sign(plugin, "1", new Location(world, 5, 66, 6), null));
|
||||
signs.reject = true;
|
||||
assertThrows(RejectedExecutionException.class, signs::storeSignsForShutdown);
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
// Retry must use the accepted detached snapshot, not newer platform state.
|
||||
location.setX(90);
|
||||
first.setData("LaterUnacceptedState");
|
||||
signs.getSigns().clear();
|
||||
signs.reject = false;
|
||||
var completion = signs.storeSignsForShutdown();
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(1, signs.workers.size());
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
worker.submit(signs.workers.getFirst()).get(5, TimeUnit.SECONDS);
|
||||
completion.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals(-1, disk.getInt("VotingPlugin.Signs.0.X"), "Preserve legacy coordinate truncation");
|
||||
assertEquals(3, disk.getInt("VotingPlugin.Signs.0.Skull.X"));
|
||||
assertEquals("SyntheticSign0", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
assertEquals(2, disk.getInt("VotingPlugin.Signs.1.Position"));
|
||||
assertEquals("preserved", disk.getString("VotingPlugin.Unrelated"));
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(1, signs.workers.size(), "Successful publication is idempotent");
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancellationAndForgedObservationCannotReleaseTheRealPublicationOwner() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var data = mock(ServerData.class);
|
||||
var durable = new CompletableFuture<Void>();
|
||||
when(data.publishShutdownSigns(anyList())).thenReturn(durable);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var pending = signs.storeSignsForShutdown();
|
||||
pending.toCompletableFuture().cancel(true);
|
||||
pending.toCompletableFuture().complete(null);
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(1, signs.workers.size(), "Accepted but unexecuted work retains one owner");
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
signs.workers.getFirst().run();
|
||||
assertFalse(pending.toCompletableFuture().isDone(), "Submission is not durability");
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
durable.complete(null);
|
||||
pending.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
|
||||
@Test void failedPublicationRetainsPayloadAndRequiresAnExplicitSuccessfulRetry() throws Exception {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var data = mock(ServerData.class);
|
||||
var attempts = new ArrayList<List<ServerData.SignSnapshot>>();
|
||||
when(data.publishShutdownSigns(anyList())).thenAnswer(call -> {
|
||||
attempts.add(call.getArgument(0));
|
||||
return attempts.size() == 1 ? CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O"))
|
||||
: CompletableFuture.completedFuture(null);
|
||||
});
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var first = signs.storeSignsForShutdown();
|
||||
signs.workers.getFirst().run();
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join());
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
signs.getSigns().clear();
|
||||
var retry = signs.storeSignsForShutdown();
|
||||
signs.workers.get(1).run();
|
||||
retry.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertSame(attempts.get(0), attempts.get(1), "Failure must not discard or recapture the accepted batch");
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
|
||||
@Test void emptyShutdownDoesNotAdmitAWorkerOrTouchServerData() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var signs = new Admission(plugin);
|
||||
assertDoesNotThrow(() -> signs.storeSignsForShutdown().toCompletableFuture().join());
|
||||
assertEquals(0, signs.workers.size());
|
||||
verifyNoInteractions(plugin);
|
||||
}
|
||||
|
||||
@Test void actualSdkFailedThenSuccessfulRetryReleasesTheOriginalPublicationOwner() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var attempts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
if (attempts.incrementAndGet() == 1)
|
||||
throw new java.io.IOException("Synthetic first publication failure");
|
||||
super.moveAtomically(source, destination);
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
backing.getData().set("VotingPlugin.Unrelated", "preserved");
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var failed = signs.storeSignsForShutdown();
|
||||
signs.workers.getFirst().run();
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> failed.toCompletableFuture().join());
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
facade.mutateFinalData(candidate -> candidate.set("Independent.Value", "confirmed"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target),
|
||||
"A different successful operation cannot acknowledge the failed sign batch");
|
||||
signs.getSigns().clear();
|
||||
var retry = signs.storeSignsForShutdown();
|
||||
signs.workers.get(1).run();
|
||||
retry.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("SyntheticSign0", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
assertEquals("preserved", disk.getString("VotingPlugin.Unrelated"));
|
||||
assertEquals("confirmed", disk.getString("Independent.Value"));
|
||||
assertEquals(3, attempts.get());
|
||||
signs.storeSignsForShutdown();
|
||||
assertEquals(2, signs.workers.size(), "Successful retry is not republished");
|
||||
assertDoesNotThrow(() -> ServerDataPublicationFence.beginGeneration(target).retire(),
|
||||
"Confirmed retry must release both facade and sign-batch ownership");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualPendingRetryCoalescesWithoutReplacingItsPayloadOrTrustingObservationCancellation() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var attempts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var retryEntered = new java.util.concurrent.CountDownLatch(1);
|
||||
var releaseRetry = new java.util.concurrent.CountDownLatch(1);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
if (attempts.incrementAndGet() == 1)
|
||||
throw new java.io.IOException("Synthetic first publication failure");
|
||||
retryEntered.countDown();
|
||||
try {
|
||||
if (!releaseRetry.await(5, TimeUnit.SECONDS))
|
||||
throw new java.io.IOException("Synthetic retry barrier expired");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(interrupted);
|
||||
}
|
||||
super.moveAtomically(source, destination);
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
var snapshot = List.of(ServerData.captureSignSnapshot("0", new Location(world, 1, 2, 3),
|
||||
null, "OriginalAcceptedSign", 2));
|
||||
var different = List.of(ServerData.captureSignSnapshot("0", new Location(world, 9, 8, 7),
|
||||
null, "DifferentUnacceptedSign", 4));
|
||||
assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> data.publishShutdownSigns(snapshot).toCompletableFuture().join());
|
||||
fence.retire();
|
||||
var workers = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var retry = workers.submit(() -> data.publishShutdownSigns(snapshot));
|
||||
assertTrue(retryEntered.await(5, TimeUnit.SECONDS));
|
||||
var coalesced = workers.submit(() -> data.publishShutdownSigns(snapshot)).get(2, TimeUnit.SECONDS);
|
||||
coalesced.toCompletableFuture().cancel(true);
|
||||
coalesced.toCompletableFuture().complete(null);
|
||||
assertFalse(coalesced.toCompletableFuture().isDone(), "The real publication still owns its lease");
|
||||
var rejected = workers.submit(() -> data.publishShutdownSigns(different)).get(2, TimeUnit.SECONDS);
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> rejected.toCompletableFuture().join());
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
releaseRetry.countDown();
|
||||
retry.get(5, TimeUnit.SECONDS).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
coalesced.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
data.publishShutdownSigns(snapshot).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(2, attempts.get(), "One failed attempt and one shared durable retry");
|
||||
var disk = YamlConfiguration.loadConfiguration(target.toFile());
|
||||
assertEquals("OriginalAcceptedSign", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
assertEquals(1, disk.getInt("VotingPlugin.Signs.0.X"));
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
} finally {
|
||||
releaseRetry.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void confirmedPublicationRetriesOnlyFailedDisplayObservationWithoutRepeatingTheWrite() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
writes.incrementAndGet();
|
||||
super.moveAtomically(source, destination);
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = spy(new ServerData(plugin));
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
doThrow(new IllegalStateException("Synthetic observation failure")).doCallRealMethod()
|
||||
.when(data).refreshVotePartyDisplay();
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
var snapshot = List.of(ServerData.captureSignSnapshot("0", new Location(world, 1, 2, 3),
|
||||
null, "OriginalAcceptedSign", 2));
|
||||
assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> data.publishShutdownSigns(snapshot).toCompletableFuture().join());
|
||||
assertEquals("OriginalAcceptedSign", YamlConfiguration.loadConfiguration(target.toFile())
|
||||
.getString("VotingPlugin.Signs.0.Data"), "The write committed before observation failed");
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target));
|
||||
data.publishShutdownSigns(snapshot).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, writes.get(), "Retry must not replay an already confirmed mutation");
|
||||
ServerDataPublicationFence.beginGeneration(target).retire();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualSdkIoFailureKeepsTheGenerationBlocked() throws Exception {
|
||||
try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var target = directory.resolve("ServerData.yml");
|
||||
var fence = ServerDataPublicationFence.beginGeneration(target);
|
||||
var plugin = plugin(fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException {
|
||||
throw new java.io.IOException("Synthetic failed atomic publication");
|
||||
}
|
||||
};
|
||||
backing.setData(new YamlConfiguration());
|
||||
var facade = new GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
when(plugin.getServerDataFile()).thenReturn(facade);
|
||||
var data = new ServerData(plugin);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var world = mock(World.class);
|
||||
when(world.getName()).thenReturn("SyntheticWorld");
|
||||
signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null));
|
||||
var result = signs.storeSignsForShutdown();
|
||||
signs.workers.getFirst().run();
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join());
|
||||
assertFalse(java.nio.file.Files.exists(target));
|
||||
fence.retire();
|
||||
assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target),
|
||||
"Neither the SDK guard nor sign handoff may call failed I/O durable");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void runtimeSignSavingRemainsSynchronous() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var data = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(data);
|
||||
var signs = new Admission(plugin);
|
||||
var location = new Location(mock(World.class), 1, 2, 3);
|
||||
signs.getSigns().add(sign(plugin, "0", location, null));
|
||||
var caller = Thread.currentThread();
|
||||
doAnswer(call -> { assertSame(caller, Thread.currentThread()); return null; })
|
||||
.when(data).setSign("0", location, null, "SyntheticSign0", 2);
|
||||
signs.storeSigns();
|
||||
verify(data).setSign("0", location, null, "SyntheticSign0", 2);
|
||||
assertEquals(0, signs.workers.size());
|
||||
}
|
||||
}
|
||||
+64
@@ -47,6 +47,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -1398,6 +1399,69 @@ 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
@@ -0,0 +1,63 @@
|
||||
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,8 +10,10 @@ 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;
|
||||
@@ -27,12 +29,15 @@ 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 {
|
||||
@@ -88,7 +93,7 @@ public class VotePartyTest {
|
||||
public void addTotal_IncrementsTotalVotes() {
|
||||
when(plugin.getServerData().getData().getInt("VoteParty.Total")).thenReturn(5);
|
||||
voteParty.addTotal(user);
|
||||
verify(plugin.getServerData().getData()).set("VoteParty.Total", 6);
|
||||
verify(plugin.getServerData()).setInt("VoteParty.Total", 6);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,6 +119,8 @@ public class VotePartyTest {
|
||||
|
||||
@Test
|
||||
public void commandVoteParty_VotePartyEnabled_SendsMessage() {
|
||||
when(plugin.getServerData().getVotePartyDisplay()).thenReturn(
|
||||
new com.bencodez.votingplugin.data.ServerData.VotePartyDisplay(0, 0));
|
||||
CommandSender sender = Mockito.mock(CommandSender.class);
|
||||
when(plugin.getSpecialRewardsConfig().isVotePartyEnabled()).thenReturn(true);
|
||||
when(plugin.getConfigFile().getFormatCommandsVoteParty()).thenReturn(new ArrayList<>());
|
||||
@@ -188,4 +195,48 @@ 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);
|
||||
}
|
||||
}
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import com.bencodez.votingplugin.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.signs.Signs;
|
||||
import com.bencodez.votingplugin.timequeue.TimeQueueHandler;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence;
|
||||
import com.bencodez.votingplugin.webhook.VotingPluginWebhookManager;
|
||||
|
||||
class VotingPluginMainShutdownTest {
|
||||
@Test
|
||||
void lateVoteTimerIsOptionalDuringPartialStartup() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
assertNull(field(plugin, "voteTimer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voteTimerCleanupIsRepeatable() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
setField(plugin, "voteTimer", timer);
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
assertDoesNotThrow(() -> invokeShutdownVoteTimer(plugin));
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, field(plugin, "voteTimer"));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(timer.isShutdown());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicUnloadSkipsLateOptionalResourcesAndIsIdempotent() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertNull(field(plugin, "signs"));
|
||||
assertNull(field(plugin, "timeQueueHandler"));
|
||||
verify((java.util.concurrent.ExecutorService) field(plugin, "backendHostedControlLifecycle")).shutdownNow();
|
||||
verify((java.util.concurrent.ExecutorService) field(plugin, "backendControlConnectorLifecycle")).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stoppedTimerRemainsVisibleUntilOverflowAndProducerHandlersClose() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
var queue = mock(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
setField(plugin, "votifierVoteOverflowQueue", queue);
|
||||
var duringClose = new java.util.concurrent.atomic.AtomicReference<ScheduledExecutorService>();
|
||||
var rejected = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
var ran = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
duringClose.set(plugin.getVoteTimer());
|
||||
rejected.set(!com.bencodez.votingplugin.util.VoteTaskAdmission.trySubmit(plugin.getVoteTimer(),
|
||||
() -> ran.set(true)));
|
||||
return null;
|
||||
}).when(queue).close();
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
plugin.onUnLoad();
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, duringClose.get());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(rejected.get());
|
||||
org.junit.jupiter.api.Assertions.assertFalse(ran.get());
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
assertNull(plugin.getVoteTimer());
|
||||
} finally { timer.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void integratedUnloadClosesTheCurrentStreakMenuOnce() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var menu = mock(com.bencodez.votingplugin.streaks.VoteStreaksMenu.class);
|
||||
var signs = shutdownSignsMock();
|
||||
setField(plugin, "voteStreaksMenu", menu);
|
||||
setField(plugin, "signs", signs);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(menu).close();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedStreakMenuCloseDoesNotSkipOtherCleanupAndRetriesOnRepeat() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var menu = mock(com.bencodez.votingplugin.streaks.VoteStreaksMenu.class);
|
||||
var signs = shutdownSignsMock();
|
||||
setField(plugin, "voteStreaksMenu", menu);
|
||||
setField(plugin, "signs", signs);
|
||||
doThrow(new IllegalStateException("synthetic close failure")).doNothing().when(menu).close();
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(signs).storeSignsForShutdown();
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(menu, org.mockito.Mockito.times(2)).close();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedDiscordCloseDoesNotSkipDisplayRetirementAndRetriesOnlyTheFailedStep() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var generation = new java.util.concurrent.atomic.AtomicLong();
|
||||
setField(plugin, "topVoterDisplayGeneration", generation);
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class);
|
||||
setField(plugin, "discordHandler", discord);
|
||||
doThrow(new IllegalStateException("synthetic Discord close failure")).doNothing().when(discord).close();
|
||||
var pending = new java.util.concurrent.CompletableFuture<com.bencodez.advancedcore.api.user.presence.PlayerPresence>();
|
||||
var filter = new com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter(row -> pending);
|
||||
setField(plugin, "topVoterDisplayFilter", filter);
|
||||
var rankings = new java.util.LinkedHashMap<com.bencodez.votingplugin.topvoter.TopVoterPlayer, Integer>();
|
||||
rankings.put(new com.bencodez.votingplugin.topvoter.TopVoterPlayer(java.util.UUID.randomUUID(), "Example", 1L), 10);
|
||||
var display = filter.projectAsync(rankings, true).toCompletableFuture();
|
||||
org.junit.jupiter.api.Assertions.assertFalse(display.isDone());
|
||||
var signs = shutdownSignsMock();
|
||||
setField(plugin, "signs", signs);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(display.isDone(), "A failed independent close must not strand pending display evidence");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(rankings, display.getNow(null));
|
||||
verify(signs).storeSignsForShutdown();
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(discord, org.mockito.Mockito.times(2)).close();
|
||||
verify(discord).retireLeaderboardPublications();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1L, generation.get());
|
||||
pending.complete(com.bencodez.advancedcore.api.user.presence.PlayerPresence.NEVER_JOINED);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(rankings, display.getNow(null));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicUnloadCleansInitializedQueueAndSignResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
Signs signs = shutdownSignsMock();
|
||||
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
|
||||
setField(plugin, "signs", signs);
|
||||
setField(plugin, "timeQueueHandler", timeQueue);
|
||||
|
||||
plugin.onUnLoad();
|
||||
|
||||
verify(signs).storeSignsForShutdown();
|
||||
verify(timeQueue).saveForShutdown();
|
||||
plugin.onUnLoad();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedCleanupDoesNotSkipLaterResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
Signs signs = shutdownSignsMock();
|
||||
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
|
||||
VotingPluginWebhookManager webhook = mock(VotingPluginWebhookManager.class);
|
||||
doThrow(new IllegalStateException("webhook close failed")).when(webhook).shutdown();
|
||||
doThrow(new IllegalStateException("queue save failed")).when(timeQueue).saveForShutdown();
|
||||
setField(plugin, "webhookManager", webhook);
|
||||
setField(plugin, "signs", signs);
|
||||
setField(plugin, "timeQueueHandler", timeQueue);
|
||||
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
|
||||
verify(webhook).shutdown();
|
||||
verify(timeQueue).saveForShutdown();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedUnloadRetriesFailedBackendAndWebhookWithoutRepeatingSuccessfulResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var backend = mock(com.bencodez.votingplugin.backendproxy.BackendProxyHandler.class);
|
||||
var webhook = mock(VotingPluginWebhookManager.class);
|
||||
var signs = shutdownSignsMock();
|
||||
org.mockito.Mockito.doReturn(backend).when(plugin).getBackendProxyHandler();
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("synthetic backend close failure")).doNothing()
|
||||
.when(backend).closeForShutdown();
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("synthetic webhook close failure")).doNothing()
|
||||
.when(webhook).shutdown();
|
||||
setField(plugin, "webhookManager", webhook); setField(plugin, "signs", signs);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(backend, org.mockito.Mockito.times(2)).closeForShutdown();
|
||||
verify(webhook, org.mockito.Mockito.times(2)).shutdown();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedTimerShutdownRetainsItsReferenceUntilARepeatCanStopIt() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var timer = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("synthetic forced stop failure"))
|
||||
.doThrow(new IllegalStateException("synthetic repeated forced stop failure"))
|
||||
.doAnswer(call -> java.util.List.of())
|
||||
.when(timer).shutdownNow();
|
||||
setField(plugin, "voteTimer", timer);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, plugin.getVoteTimer());
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertNull(plugin.getVoteTimer());
|
||||
verify(timer, org.mockito.Mockito.times(2)).shutdown();
|
||||
verify(timer, org.mockito.Mockito.times(3)).shutdownNow();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentUnloadDoesNotRepeatAnActiveResourceClose() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var backend = mock(com.bencodez.votingplugin.backendproxy.BackendProxyHandler.class);
|
||||
org.mockito.Mockito.doReturn(backend).when(plugin).getBackendProxyHandler();
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var worker = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
entered.countDown();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(release.await(10, java.util.concurrent.TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(backend).closeForShutdown();
|
||||
try {
|
||||
var first = worker.submit(plugin::onUnLoad);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(backend).closeForShutdown();
|
||||
release.countDown(); first.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(backend).closeForShutdown();
|
||||
} finally {
|
||||
release.countDown(); worker.shutdownNow();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedUnloadRetriesOnlyFailedTimedVotePersistence() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
TimeQueueHandler timeQueue = mock(TimeQueueHandler.class);
|
||||
Signs signs = shutdownSignsMock();
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("disk unavailable")).doNothing()
|
||||
.when(timeQueue).saveForShutdown();
|
||||
setField(plugin, "timeQueueHandler", timeQueue);
|
||||
setField(plugin, "signs", signs);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(timeQueue, org.mockito.Mockito.times(2)).saveForShutdown();
|
||||
verify(signs).storeSignsForShutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void gracefulTimerShutdownDoesNotForceStop() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
|
||||
verify(timer).shutdown();
|
||||
verify(timer, never()).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void timerTimeoutForcesStop() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(false);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
|
||||
verify(timer).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void interruptedTimerShutdownRestoresInterruptAndForcesStop() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
doThrow(new InterruptedException()).when(timer).awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS);
|
||||
setField(plugin, "voteTimer", timer);
|
||||
|
||||
invokeShutdownVoteTimer(plugin);
|
||||
|
||||
try {
|
||||
org.junit.jupiter.api.Assertions.assertTrue(Thread.currentThread().isInterrupted());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
verify(timer).shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedForcedTimerStopStillClosesInitializedQueuesAndListenersOnce() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
|
||||
doThrow(new IllegalStateException("controlled forced-stop failure")).when(timer).shutdownNow();
|
||||
setField(plugin, "voteTimer", timer);
|
||||
var queue = mock(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class);
|
||||
var cooldown = mock(com.bencodez.votingplugin.cooldown.CoolDownCheck.class);
|
||||
var reminders = mock(com.bencodez.votingplugin.votereminding.VoteRemindersManager.class);
|
||||
setField(plugin, "votifierVoteOverflowQueue", queue);
|
||||
setField(plugin, "coolDownCheck", cooldown);
|
||||
setField(plugin, "voteRemindersManager", reminders);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
assertDoesNotThrow(plugin::onUnLoad);
|
||||
verify(queue).close();
|
||||
verify(cooldown).shutdown();
|
||||
verify(reminders).shutdown();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertSame(timer, field(plugin, "voteTimer"));
|
||||
verify(timer, org.mockito.Mockito.times(4)).shutdownNow();
|
||||
assertNull(field(plugin, "votifierVoteOverflowQueue"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedMysqlStartupRetainsItsCauseAndFullDisableCleansEarlyResources() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class,
|
||||
org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
org.mockito.Mockito.doReturn(options).when(plugin).getOptions();
|
||||
org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("startup-fixture")).when(plugin).getLogger();
|
||||
org.mockito.Mockito.when(options.getDebug().isDebug()).thenReturn(true);
|
||||
setField(plugin, "plugin", plugin);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var logger = mock(java.util.logging.Logger.class);
|
||||
org.mockito.Mockito.doReturn(logger).when(plugin).getLogger();
|
||||
ScheduledExecutorService coreTimer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
setField(plugin, "timer", coreTimer);
|
||||
setField(plugin, "loadUserData", true);
|
||||
var configuration = new org.bukkit.configuration.file.YamlConfiguration();
|
||||
configuration.set("Host", "127.0.0.1"); configuration.set("Port", 3306);
|
||||
configuration.set("Username", "fixture"); configuration.set("Password", "fixture");
|
||||
configuration.set("Database", "fixture"); configuration.set("MaxConnections", 1);
|
||||
configuration.set("ConnectionTimeout", 1000); configuration.set("MinimumIdle", 0);
|
||||
configuration.set("Driver", RejectingDriver.class.getName());
|
||||
configuration.set("Debug", true);
|
||||
try {
|
||||
IllegalStateException failure = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class,
|
||||
() -> new com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL(plugin, "Users", configuration));
|
||||
Throwable original = failure;
|
||||
while (original.getCause() != null) original = original.getCause();
|
||||
org.junit.jupiter.api.Assertions.assertInstanceOf(java.sql.SQLException.class, original);
|
||||
org.junit.jupiter.api.Assertions.assertEquals("controlled authentication rejection", original.getMessage());
|
||||
assertNull(plugin.getMysql(), "rejected constructor must not publish storage");
|
||||
assertDoesNotThrow(plugin::onDisable);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(coreTimer.isTerminated());
|
||||
assertNull(field(plugin, "voteTimer"));
|
||||
assertNull(field(plugin, "signs"));
|
||||
assertDoesNotThrow(plugin::onDisable);
|
||||
org.junit.jupiter.api.Assertions.assertSame(original, rootCause(failure));
|
||||
verify(logger, never()).warning(org.mockito.ArgumentMatchers.anyString());
|
||||
verify(logger, never()).log(org.mockito.ArgumentMatchers.eq(java.util.logging.Level.WARNING),
|
||||
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(Throwable.class));
|
||||
} finally { coreTimer.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void signPersistenceDoesNotReblockUnloadAfterATimedVoteCheckpointTimesOut(
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) throws Exception {
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var firstMove = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var caller = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
var producers = new java.util.concurrent.ScheduledThreadPoolExecutor(1);
|
||||
producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||
try (var schedulers = org.mockito.Mockito.mockConstruction(
|
||||
com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
org.mockito.Mockito.doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(
|
||||
directory.resolve("ServerData.yml"));
|
||||
setField(plugin, "serverDataPublicationFence", fence);
|
||||
var backing = new com.bencodez.advancedcore.data.ServerData(plugin) {
|
||||
@Override protected void moveAtomically(java.nio.file.Path source, java.nio.file.Path target)
|
||||
throws java.io.IOException {
|
||||
if (firstMove.compareAndSet(true, false)) {
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!release.await(10, java.util.concurrent.TimeUnit.SECONDS))
|
||||
throw new java.io.IOException("Synthetic stalled writer was not released");
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(failure);
|
||||
}
|
||||
}
|
||||
super.moveAtomically(source, target);
|
||||
}
|
||||
};
|
||||
backing.setData(new org.bukkit.configuration.file.YamlConfiguration());
|
||||
var facade = new com.bencodez.votingplugin.data.GenerationFencedServerDataFile(plugin, backing, fence);
|
||||
org.mockito.Mockito.doReturn(facade).when(plugin).getServerDataFile();
|
||||
var data = new com.bencodez.votingplugin.data.ServerData(plugin);
|
||||
org.mockito.Mockito.doReturn(data).when(plugin).getServerData();
|
||||
org.mockito.Mockito.doReturn(producers).when(plugin).getVoteTimer();
|
||||
var queue = new TimeQueueHandler(plugin);
|
||||
queue.addVote("SyntheticVoter", "SyntheticSite");
|
||||
producers.shutdown();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(producers.awaitTermination(2,
|
||||
java.util.concurrent.TimeUnit.SECONDS));
|
||||
setField(plugin, "timeQueueHandler", queue);
|
||||
var signs = new Signs(plugin);
|
||||
var sign = mock(com.bencodez.votingplugin.signs.SignHandler.class, CALLS_REAL_METHODS);
|
||||
for (var entry : java.util.Map.<String, Object>of("plugin", plugin, "sign", "0",
|
||||
"data", "SyntheticSign", "position", 1).entrySet()) {
|
||||
var f = com.bencodez.votingplugin.signs.SignHandler.class.getDeclaredField(entry.getKey());
|
||||
f.setAccessible(true);
|
||||
f.set(sign, entry.getValue());
|
||||
}
|
||||
var world = mock(org.bukkit.World.class);
|
||||
org.mockito.Mockito.when(world.getName()).thenReturn("SyntheticWorld");
|
||||
sign.setLocation(new org.bukkit.Location(world, -1.5, 64, 2.5));
|
||||
signs.getSigns().add(sign);
|
||||
setField(plugin, "signs", signs);
|
||||
try {
|
||||
var unload = caller.submit(() -> {
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
plugin.onUnLoad();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
}
|
||||
});
|
||||
org.junit.jupiter.api.Assertions.assertTrue(entered.await(5,
|
||||
java.util.concurrent.TimeUnit.SECONDS), "Actual SDK file writer did not reach disk boundary");
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(
|
||||
() -> unload.get(3, java.util.concurrent.TimeUnit.SECONDS),
|
||||
"Sign persistence must not reacquire the stalled YAML monitor on the unload caller");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1, release.getCount(),
|
||||
"Unload must complete before the stalled writer is released");
|
||||
} finally {
|
||||
release.countDown();
|
||||
var pendingSigns = (java.util.concurrent.CompletionStage<?>) field(plugin, "signShutdownPublication");
|
||||
if (pendingSigns != null) pendingSigns.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
queue.saveForShutdown();
|
||||
var disk = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(
|
||||
directory.resolve("ServerData.yml").toFile());
|
||||
org.junit.jupiter.api.Assertions.assertEquals("SyntheticSign", disk.getString("VotingPlugin.Signs.0.Data"));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1, data.getTimedVoteCacheKeys().size(),
|
||||
"Final sign publication must preserve the timed-vote checkpoint");
|
||||
caller.shutdown();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(caller.awaitTermination(5,
|
||||
java.util.concurrent.TimeUnit.SECONDS));
|
||||
fence.retire();
|
||||
}
|
||||
} finally {
|
||||
release.countDown();
|
||||
caller.shutdownNow();
|
||||
producers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedUnloadRetriesAFailedSignPublicationWithoutRepeatingListenerCleanup() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
|
||||
setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean());
|
||||
setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence());
|
||||
initializeEarlyLifecycle(plugin);
|
||||
var signs = mock(Signs.class);
|
||||
var pending = new java.util.concurrent.CompletableFuture<Void>();
|
||||
org.mockito.Mockito.when(signs.storeSignsForShutdown()).thenReturn(
|
||||
java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O")),
|
||||
pending.minimalCompletionStage());
|
||||
setField(plugin, "signs", signs);
|
||||
try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) {
|
||||
plugin.onUnLoad();
|
||||
plugin.onUnLoad();
|
||||
plugin.onUnLoad();
|
||||
verify(signs, org.mockito.Mockito.times(2)).storeSignsForShutdown();
|
||||
verify(signs, never()).storeSigns();
|
||||
handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin));
|
||||
pending.complete(null);
|
||||
plugin.onUnLoad();
|
||||
verify(signs, org.mockito.Mockito.times(2)).storeSignsForShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static Signs shutdownSignsMock() {
|
||||
var signs = mock(Signs.class);
|
||||
org.mockito.Mockito.when(signs.storeSignsForShutdown())
|
||||
.thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null));
|
||||
return signs;
|
||||
}
|
||||
|
||||
private static Throwable rootCause(Throwable failure) {
|
||||
while (failure.getCause() != null) failure = failure.getCause();
|
||||
return failure;
|
||||
}
|
||||
|
||||
public static final class RejectingDriver implements java.sql.Driver {
|
||||
public java.sql.Connection connect(String url, java.util.Properties properties) throws java.sql.SQLException {
|
||||
throw new java.sql.SQLException("controlled authentication rejection", "28000", 1045);
|
||||
}
|
||||
public boolean acceptsURL(String url) { return url != null && url.startsWith("jdbc:"); }
|
||||
public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties properties) {
|
||||
return new java.sql.DriverPropertyInfo[0];
|
||||
}
|
||||
public int getMajorVersion() { return 1; }
|
||||
public int getMinorVersion() { return 0; }
|
||||
public boolean jdbcCompliant() { return false; }
|
||||
public java.util.logging.Logger getParentLogger() { return java.util.logging.Logger.getGlobal(); }
|
||||
}
|
||||
|
||||
private static void initializeEarlyLifecycle(VotingPluginMain plugin) throws Exception {
|
||||
// The real plugin initializes this display retirement counter before startup.
|
||||
setField(plugin, "topVoterDisplayGeneration", new java.util.concurrent.atomic.AtomicLong());
|
||||
// Mockito bypasses the SDK constructor that creates this lock.
|
||||
setField(plugin, "lifecycleLock", new Object());
|
||||
var hosted = mock(java.util.concurrent.ExecutorService.class);
|
||||
var connector = mock(ScheduledExecutorService.class);
|
||||
org.mockito.Mockito.when(hosted.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
|
||||
org.mockito.Mockito.when(connector.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true);
|
||||
setField(plugin, "pendingNativeUserStorageCloseLock", new Object());
|
||||
setField(plugin, "pendingNativeUserStorageCloses", new java.util.ArrayList<>());
|
||||
setField(plugin, "backendHostedControlLifecycle", hosted);
|
||||
setField(plugin, "backendControlConnectorLifecycle", connector);
|
||||
setField(plugin, "backendHostedControlManagers", java.util.concurrent.ConcurrentHashMap.newKeySet());
|
||||
org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("shutdown-fixture")).when(plugin).getLogger();
|
||||
}
|
||||
|
||||
private static void invokeShutdownVoteTimer(VotingPluginMain plugin) throws Exception {
|
||||
Method method = VotingPluginMain.class.getDeclaredMethod("shutdownVoteTimer");
|
||||
method.setAccessible(true);
|
||||
method.invoke(plugin);
|
||||
}
|
||||
|
||||
private static Object field(VotingPluginMain plugin, String name) throws Exception {
|
||||
Field field = VotingPluginMain.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field.get(plugin);
|
||||
}
|
||||
|
||||
private static void setField(VotingPluginMain plugin, String name, Object value) throws Exception {
|
||||
Field field = findField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(plugin, value);
|
||||
}
|
||||
|
||||
private static Field findField(String name) throws Exception {
|
||||
Class<?> type = VotingPluginMain.class;
|
||||
while (type != null) {
|
||||
try { return type.getDeclaredField(name); }
|
||||
catch (NoSuchFieldException ignored) { type = type.getSuperclass(); }
|
||||
}
|
||||
throw new NoSuchFieldException(name);
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -722,6 +722,9 @@ 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")
|
||||
@@ -738,7 +741,11 @@ 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);
|
||||
verify(multiProxyHandler, Mockito.times(2)).sendMultiProxyEnvelopeAccepted(Mockito.any(), recipients.capture());
|
||||
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));
|
||||
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
|
||||
@@ -750,6 +757,8 @@ 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());
|
||||
}
|
||||
@@ -838,6 +847,8 @@ 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));
|
||||
@@ -1697,7 +1708,8 @@ public class VotingPluginProxyTest {
|
||||
|
||||
assertEquals(1, queue.size());
|
||||
assertEquals(voteId, queue.element().getVoteId());
|
||||
assertEquals("", queue.element().getTotals());
|
||||
assertEquals(0, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.parseStorage(queue.element().getTotals()).getAllTimeTotal());
|
||||
assertEquals(100L, queue.element().getCanonicalOccurrenceTime());
|
||||
assertFalse(queue.element().isRealVote());
|
||||
assertEquals(1024, retryMap.size());
|
||||
}
|
||||
@@ -1725,6 +1737,10 @@ 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
@@ -0,0 +1,572 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
+57
-8
@@ -840,6 +840,38 @@ 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}) {
|
||||
@@ -857,7 +889,7 @@ class BackendProxyMessageRouterTest {
|
||||
var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(user, plugin);
|
||||
org.mockito.Mockito.doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(
|
||||
any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(),
|
||||
anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong());
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
var manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(manager);
|
||||
@@ -897,7 +929,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(), eq(true), anyBoolean());
|
||||
anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong());
|
||||
BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
|
||||
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
|
||||
mock(BackendVotePartySync.class), cache);
|
||||
@@ -943,7 +975,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), eq(true), anyBoolean());
|
||||
anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false), anyBoolean(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -964,7 +996,24 @@ 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), eq(true), 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());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -984,7 +1033,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), eq(true), anyBoolean());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true), anyBoolean(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1006,7 +1055,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(), eq(true), anyBoolean());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong());
|
||||
verify(cache).cancelReservation(voteId);
|
||||
}
|
||||
|
||||
@@ -1026,7 +1075,7 @@ class BackendProxyMessageRouterTest {
|
||||
|
||||
assertEquals(OrderedVoteOutcome.RETRY, outcome.get());
|
||||
verify(user, never()).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), eq(true), anyBoolean());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong());
|
||||
verify(cache, never()).complete(voteId);
|
||||
}
|
||||
|
||||
@@ -1249,7 +1298,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), eq(true), anyBoolean());
|
||||
anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true), anyBoolean(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-1
@@ -266,7 +266,9 @@ 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(), anyBoolean());
|
||||
anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong());
|
||||
when(plugin.getDateVoteMilestones()).thenReturn(
|
||||
new com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones(plugin));
|
||||
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.ServerImplementation implementation =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class);
|
||||
com.bencodez.simpleapi.folialib.impl.PlatformScheduler implementation =
|
||||
mock(com.bencodez.simpleapi.folialib.impl.PlatformScheduler.class);
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+46
-2
@@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.simpleapi.folialib.FoliaLib;
|
||||
import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
|
||||
import com.bencodez.simpleapi.folialib.impl.PlatformScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
import com.bencodez.votingplugin.user.PointTransferResult;
|
||||
@@ -209,9 +209,53 @@ class CommandLoaderSchedulingTest {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(2, storageTasks.size());
|
||||
}
|
||||
|
||||
|
||||
@Test void clearingDiscordIdRunsOnTheWorkerAndReportsOnlyAfterConfirmation() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class);
|
||||
when(plugin.getDiscordHandler()).thenReturn(discord);
|
||||
when(discord.clearTopVoterMessageId(com.bencodez.votingplugin.topvoter.TopVoter.Monthly)).thenReturn(true);
|
||||
var worker = mock(java.util.concurrent.ScheduledExecutorService.class);
|
||||
when(plugin.getTimer()).thenReturn(worker);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
var queued = new AtomicReference<Runnable>(); var completion = new AtomicReference<Runnable>();
|
||||
org.mockito.Mockito.doAnswer(call -> { queued.set(call.getArgument(0)); return null; })
|
||||
.when(worker).execute(any(Runnable.class));
|
||||
org.mockito.Mockito.doAnswer(call -> { completion.set(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin),any(Runnable.class));
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
var method = CommandLoader.class.getDeclaredMethod("clearDiscordMessageId",CommandSender.class,
|
||||
com.bencodez.votingplugin.topvoter.TopVoter.class); method.setAccessible(true);
|
||||
method.invoke(new CommandLoader(plugin),sender,com.bencodez.votingplugin.topvoter.TopVoter.Monthly);
|
||||
org.mockito.Mockito.verifyNoInteractions(discord,sender);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(queued.get()); queued.get().run();
|
||||
verify(discord).clearTopVoterMessageId(com.bencodez.votingplugin.topvoter.TopVoter.Monthly);
|
||||
org.mockito.Mockito.verifyNoInteractions(sender);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(completion.get()); completion.get().run();
|
||||
verify(sender).sendMessage(org.mockito.ArgumentMatchers.contains("Cleared discord message ID"));
|
||||
}
|
||||
|
||||
@Test void rejectedDiscordIdClearDoesNotTouchStorage() throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class); when(plugin.getDiscordHandler()).thenReturn(discord);
|
||||
var worker = mock(java.util.concurrent.ScheduledExecutorService.class); when(plugin.getTimer()).thenReturn(worker);
|
||||
doThrow(new java.util.concurrent.RejectedExecutionException("synthetic")).when(worker).execute(any(Runnable.class));
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
var completion = new AtomicReference<Runnable>();
|
||||
org.mockito.Mockito.doAnswer(call -> { completion.set(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin),any(Runnable.class));
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
var method = CommandLoader.class.getDeclaredMethod("clearDiscordMessageId",CommandSender.class,
|
||||
com.bencodez.votingplugin.topvoter.TopVoter.class); method.setAccessible(true);
|
||||
method.invoke(new CommandLoader(plugin),sender,com.bencodez.votingplugin.topvoter.TopVoter.Monthly);
|
||||
org.mockito.Mockito.verifyNoInteractions(discord);verify(plugin,never()).getServerData();
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(completion.get());completion.get().run();
|
||||
verify(sender).sendMessage(org.mockito.ArgumentMatchers.contains("no changes made"));
|
||||
}
|
||||
|
||||
private static void configureEntityScheduler(BukkitScheduler scheduler) {
|
||||
FoliaLib folia = mock(FoliaLib.class);
|
||||
ServerImplementation entityScheduler = mock(ServerImplementation.class);
|
||||
PlatformScheduler entityScheduler = mock(PlatformScheduler.class);
|
||||
when(scheduler.getFoliaLib()).thenReturn(folia);
|
||||
when(folia.getImpl()).thenReturn(entityScheduler);
|
||||
when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class)))
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
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
@@ -0,0 +1,13 @@
|
||||
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});
|
||||
}
|
||||
}
|
||||
-310
@@ -1,310 +0,0 @@
|
||||
package com.bencodez.votingplugin.tests.commands;
|
||||
|
||||
import com.bencodez.votingplugin.commands.CommandLoader;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
|
||||
class GuidedSessionCommandRegistrationTest {
|
||||
@Test void disabledGuidePreservesTheExistingCustomSessionHandler() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.register();
|
||||
assertEquals(1, f.sessionHandlers().size());
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
assertFalse(f.plugin.getVoteCommand().stream().anyMatch(h -> h.getArgs().length == 2
|
||||
&& h.getArgs()[0].equalsIgnoreCase("Session")));
|
||||
}
|
||||
|
||||
@Test void enabledGuideReservesSessionAndAllNavigationHandlers() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
assertEquals(8, f.sessionHandlers().size()); // seven built-ins plus the owner custom command
|
||||
assertEquals("Guided voting session", f.sessionHandlers().get(0).getHelpMessage());
|
||||
for (String action : List.of("next", "previous", "skip", "check", "finish", "restart")) {
|
||||
assertTrue(f.sessionHandlers().stream().anyMatch(h -> h.getArgs().length == 2
|
||||
&& h.getArgs()[1].equals(action)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disablingAndReloadingRestoresCustomRoutingWithoutDuplicateHandlers() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertEquals(1, f.sessionHandlers().size());
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
}
|
||||
|
||||
@Test void enablingOnReloadAddsOnlyGuideHandlersAndPreservesProductionIdentity() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.register();
|
||||
var production = new ArrayList<>(f.plugin.getVoteCommand());
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertEquals(production.size() + 7, f.plugin.getVoteCommand().size());
|
||||
for (CommandHandler original : production) assertTrue(f.plugin.getVoteCommand().contains(original));
|
||||
assertEquals("Guided voting session", f.sessionHandlers().get(0).getHelpMessage());
|
||||
}
|
||||
|
||||
@Test void reloadHonorsDisabledGuidePermissionWithoutRemovingCustomHandler() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
when(f.config.getDisabledCommands()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertEquals(1, f.sessionHandlers().size());
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
}
|
||||
|
||||
@Test void permissionLookupTracksEnableDisableAndRetainsUnrelatedCommands() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
org.bukkit.entity.Player owner = mock(org.bukkit.entity.Player.class);
|
||||
when(owner.hasPermission("owner.session")).thenReturn(true);
|
||||
org.bukkit.entity.Player voter = mock(org.bukkit.entity.Player.class);
|
||||
when(voter.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
var index = new java.util.HashMap<String, CommandHandler>();
|
||||
CommandHandler custom = f.sessionHandlers().get(0);
|
||||
CommandHandler unrelated = f.plugin.getVoteCommand().get(0);
|
||||
index.put("voteSession", custom);
|
||||
index.put("voteHelp", unrelated);
|
||||
f.loader.setCommands(index);
|
||||
assertTrue(f.loader.hasPermission(owner, "voteSession"));
|
||||
assertFalse(f.loader.hasPermission(voter, "voteSession"));
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.hasPermission(owner, "voteSession"));
|
||||
assertTrue(f.loader.hasPermission(voter, "votingplugin:voteSession"));
|
||||
assertSame(unrelated, index.get("voteHelp"));
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertSame(custom, index.get("voteSession"));
|
||||
assertTrue(f.loader.hasPermission(owner, "voteSession"));
|
||||
assertFalse(f.loader.hasPermission(voter, "voteSession"));
|
||||
}
|
||||
|
||||
@Test void initialAliasIndexUsesGuideRatherThanCustomOrNavigationHandler() throws Exception {
|
||||
assertInitialAliasIndexUsesGuide("vote");
|
||||
}
|
||||
|
||||
@Test void renamedPlayerRootKeepsTheGuidedSessionPermissionIndex() throws Exception {
|
||||
assertInitialAliasIndexUsesGuide("ballot");
|
||||
}
|
||||
|
||||
public interface CommandMapAccess { org.bukkit.command.CommandMap getCommandMap(); }
|
||||
|
||||
private void assertInitialAliasIndexUsesGuide(String commandName) throws Exception {
|
||||
Fixture f = new Fixture(commandName);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
when(f.plugin.getAdminVoteCommand()).thenReturn(new ArrayList<>());
|
||||
when(f.plugin.getDescription().getCommands()).thenReturn(java.util.Map.of());
|
||||
when(f.plugin.getDescription().getName()).thenReturn("VotingPlugin");
|
||||
when(f.plugin.getName()).thenReturn("VotingPlugin");
|
||||
org.bukkit.Server server = mock(org.bukkit.Server.class, withSettings().extraInterfaces(CommandMapAccess.class));
|
||||
var map = new org.bukkit.command.SimpleCommandMap(server);
|
||||
when(((CommandMapAccess) server).getCommandMap()).thenReturn(map);
|
||||
when(f.plugin.getServer()).thenReturn(server);
|
||||
var constructor = org.bukkit.command.PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
|
||||
constructor.setAccessible(true);
|
||||
var root = constructor.newInstance("vote", f.plugin);
|
||||
map.register("votingplugin", root);
|
||||
when(f.plugin.getCommand("vote")).thenReturn(root);
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
f.loader.loadAliases();
|
||||
}
|
||||
assertSame(root, map.getCommand(commandName));
|
||||
assertTrue(f.loader.isVoteCommandEnabled());
|
||||
CommandHandler indexed = f.loader.getCommands().get("voteSession");
|
||||
assertEquals("Guided voting session", indexed.getHelpMessage());
|
||||
assertArrayEquals(new String[] {"Session"}, indexed.getArgs());
|
||||
org.bukkit.entity.Player voter = mock(org.bukkit.entity.Player.class);
|
||||
when(voter.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.hasPermission(voter, "voteSession"));
|
||||
}
|
||||
|
||||
@Test void enablingOnReloadHonorsDisabledDefaultPermission() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
when(f.plugin.getOptions().isMultiplePermissionChecks()).thenReturn(true);
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.setCommands(new java.util.HashMap<>());
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
assertFalse(f.loader.hasPermission(player, "voteSession"));
|
||||
assertTrue(f.sessionHandlers().stream().filter(h -> h.getHelpMessage().equals("Guided voting session"))
|
||||
.allMatch(h -> h.getPerm().equals("VotingPlugin.Commands.Vote.Session")));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.hasPermission(player, "voteSession"));
|
||||
}
|
||||
|
||||
@Test void guidedSessionUseRequiresTheRegisteredRootHandlerPolicy() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
when(f.plugin.getOptions().isMultiplePermissionChecks()).thenReturn(true);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
assertTrue(f.loader.canUseGuidedSession(player));
|
||||
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.canUseGuidedSession(player));
|
||||
|
||||
when(f.config.getDisabledCommands()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
}
|
||||
|
||||
@Test void guidedSessionUseRejectsMultiplePermissionFallbackAndCustomOnlyOrDisabledGuide() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
when(f.plugin.getOptions().isMultiplePermissionChecks()).thenReturn(false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.canUseGuidedSession(player));
|
||||
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
when(f.config.getDisabledCommands()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
}
|
||||
|
||||
@Test void reloadReconcilesOnlyGuidePermissionInheritanceAndInvalidatesStaleTasks() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
java.util.List<Runnable> tasks = new ArrayList<>();
|
||||
var scheduler = f.plugin.getBukkitScheduler();
|
||||
doAnswer(call -> { tasks.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(f.plugin), any(Runnable.class));
|
||||
var manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(manager);
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
var parent = new org.bukkit.permissions.Permission("VotingPlugin.Player");
|
||||
parent.getChildren().put("owner.unrelated", true);
|
||||
when(manager.getPermission("VotingPlugin.Player")).thenReturn(parent);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertEquals(true, parent.getChildren().get("VotingPlugin.Commands.Vote.Session"));
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertFalse(parent.getChildren().containsKey("VotingPlugin.Commands.Vote.Session"));
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>());
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertEquals(true, parent.getChildren().get("VotingPlugin.Commands.Vote.Session"));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
Runnable staleEnable = tasks.remove(0);
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
Runnable staleDisable = tasks.remove(0);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
staleEnable.run();
|
||||
staleDisable.run();
|
||||
assertFalse(parent.getChildren().containsKey("VotingPlugin.Commands.Vote.Session"));
|
||||
assertEquals(true, parent.getChildren().get("owner.unrelated"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disablingGuideRetainsCustomSessionInheritanceUnlessOwnerDisablesDefaultPermission() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("CustomCommands.owner-session.Permission", "VotingPlugin.Commands.Vote.Session|VotingPlugin.Player");
|
||||
f.register();
|
||||
java.util.List<Runnable> tasks = new ArrayList<>();
|
||||
var scheduler = f.plugin.getBukkitScheduler();
|
||||
doAnswer(call -> { tasks.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(f.plugin), any(Runnable.class));
|
||||
var manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(manager);
|
||||
var parent = new org.bukkit.permissions.Permission("VotingPlugin.Player");
|
||||
when(manager.getPermission("VotingPlugin.Player")).thenReturn(parent);
|
||||
parent.getChildren().put("VotingPlugin.Commands.Vote.Session", true);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertEquals(true, parent.getChildren().get("VotingPlugin.Commands.Vote.Session"));
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertFalse(parent.getChildren().containsKey("VotingPlugin.Commands.Vote.Session"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final Config config = mock(Config.class);
|
||||
final CommandLoader loader;
|
||||
final YamlConfiguration data = new YamlConfiguration();
|
||||
final AtomicReference<ArrayList<CommandHandler>> handlers = new AtomicReference<>();
|
||||
Fixture() { this("vote"); }
|
||||
Fixture(String commandName) {
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isVoteCommandEnabled()).thenReturn(true);
|
||||
when(config.getVoteCommandName()).thenReturn(commandName);
|
||||
loader = new CommandLoader(plugin);
|
||||
when(config.getData()).thenReturn(data);
|
||||
when(config.getDisabledCommands()).thenReturn(new ArrayList<>());
|
||||
when(config.isAddCustomCommands()).thenReturn(true);
|
||||
when(config.getCustomCommands()).thenReturn(Set.of("owner-session"));
|
||||
var custom = data.createSection("CustomCommands.owner-session");
|
||||
custom.set("Args", new ArrayList<>(List.of("Session")));
|
||||
custom.set("HelpMessage", "existing owner command");
|
||||
custom.set("Permission", "owner.session");
|
||||
when(config.getCustomCommands("owner-session")).thenReturn(custom);
|
||||
doAnswer(call -> { handlers.set(call.getArgument(0)); return null; })
|
||||
.when(plugin).setVoteCommand(any(ArrayList.class));
|
||||
when(plugin.getVoteCommand()).thenAnswer(call -> handlers.get());
|
||||
}
|
||||
void register() throws Exception {
|
||||
Method load = CommandLoader.class.getDeclaredMethod("loadVoteCommand");
|
||||
load.setAccessible(true);
|
||||
try (var singleton = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) {
|
||||
var core = mock(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
singleton.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(core);
|
||||
when(core.getBasicCommands("VotingPlugin")).thenReturn(new ArrayList<>());
|
||||
load.invoke(loader);
|
||||
}
|
||||
}
|
||||
List<CommandHandler> sessionHandlers() {
|
||||
return plugin.getVoteCommand().stream().filter(h -> h.getArgs().length > 0
|
||||
&& h.getArgs()[0].equalsIgnoreCase("Session")).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user