mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-04 09:02:00 -05:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a6e47a80 | ||
|
|
5fc7d5a67b | ||
|
|
f5b8c669a3 | ||
|
|
22ba18f481 | ||
|
|
1a19feebbb | ||
|
|
a39d2cf77f | ||
|
|
21204495bd | ||
|
|
b510322a09 | ||
|
|
7821ccaaff | ||
|
|
229c096086 | ||
|
|
b5c759570a | ||
|
|
7321ea5da4 | ||
|
|
832772f382 | ||
|
|
fda4b09909 | ||
|
|
6cbc180d7b | ||
|
|
51e2396f0d | ||
|
|
6a5c124ec3 | ||
|
|
54cffb5f95 | ||
|
|
a1905322ae | ||
|
|
fa39ec9b7c | ||
|
|
0be5deedfe | ||
|
|
9865b50938 | ||
|
|
65498873f7 | ||
|
|
4615be0eaa | ||
|
|
c2446c5151 |
No files matched your search
@@ -357,3 +357,7 @@ Control behavior in production.
|
||||
- After a MEX write, say exactly what changed and its sharing boundary: a local draft is checkout-only and nothing is shared; a canonical artifact is written to the working tree and requires commit/push to share.
|
||||
- Skill activation is not approval for canonical actions.
|
||||
<!-- mex-agent:skills:end -->
|
||||
|
||||
## Guided proxy session confirmation
|
||||
|
||||
Guided sessions never award votes or rewards. Proxy confirmation requires a bounded versioned session-fence reply matching the player, backend and random session nonce; keep the first fence for each source. New original proxy ingress assigns an opaque UUIDv8 before asynchronous handoff, with random source incarnation and monotonically increasing sequence. Preserve that canonical ID through every journal, retry, cache and multi-proxy hop; forwarding or replay must never mint new occurrence order. Do not compare proxy clocks with backend clocks or infer freshness from delivery/queue flags. New post-fence delayed/queued votes can confirm only through the existing credited pipeline; older, legacy, fake/test and unsupported-source occurrences remain unconfirmed without changing normal accounting. Unknown peers must preserve ordinary voting. No cache schema migration or new reward path.
|
||||
@@ -52,3 +52,7 @@ or disabling it; inspections gate disabled state immediately and report a newly
|
||||
```
|
||||
|
||||
`LATEST` resolves to the latest stable release.
|
||||
|
||||
## Guided voting session
|
||||
|
||||
Optional `/vote session` chat navigation tracks real accepted votes without changing existing menus or rewards. See [configuration, controls and timing limits](docs/guided-voting-session.md).
|
||||
@@ -156,6 +156,13 @@ import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
private final com.bencodez.votingplugin.session.GuidedVotingSessions guidedVotingSessions =
|
||||
new com.bencodez.votingplugin.session.GuidedVotingSessions(this);
|
||||
|
||||
public com.bencodez.votingplugin.session.GuidedVotingSessions getGuidedVotingSessions() {
|
||||
return guidedVotingSessions;
|
||||
}
|
||||
|
||||
|
||||
@Getter
|
||||
public static VotingPluginMain plugin;
|
||||
@@ -1978,6 +1985,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
stopAutoCreateVoteSitesSetupReminder();
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
|
||||
try {
|
||||
guidedVotingSessions.clear();
|
||||
shutdownVoteReminders();
|
||||
} finally {
|
||||
super.onDisable();
|
||||
@@ -2098,10 +2106,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
pm.registerEvents(new PlayerJoinEvent(this), this);
|
||||
if (isVotifierLoaded()) {
|
||||
VotiferEvent votifierEvent = new VotiferEvent(this);
|
||||
votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, votifierEvent::processVote);
|
||||
votifierVoteOverflowQueue = VotifierVoteOverflowQueue.withObservations(this, votifierEvent::processVote);
|
||||
pm.registerEvents(votifierEvent, this);
|
||||
}
|
||||
pm.registerEvents(new PlayerVoteListener(this), this);
|
||||
pm.registerEvents(guidedVotingSessions, this);
|
||||
pm.registerEvents(new PlayerPostVoteLoggerListener(this), this);
|
||||
pm.registerEvents(new PlayerSpecialRewardLoggerListener(this), this);
|
||||
pm.registerEvents(new VoteShopPurchaseLoggerListener(this), this);
|
||||
@@ -2156,7 +2165,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
}, () -> { }, () -> { }));
|
||||
}
|
||||
|
||||
static UUID placeholderStorageUuid(Player player, boolean onlineMode) {
|
||||
/** Snapshot-only UUID resolution; call while owning the player, before storage work. */
|
||||
public static UUID placeholderStorageUuid(Player player, boolean onlineMode) {
|
||||
if (onlineMode) return player.getUniqueId();
|
||||
String resolvedUuid = UuidLookup.getInstance().getUUIDWithoutStorage(player.getName());
|
||||
try {
|
||||
@@ -2177,9 +2187,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
boolean updateActiveBackendRuntime) {
|
||||
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.pause();
|
||||
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());
|
||||
|
||||
|
||||
+12
-3
@@ -57,7 +57,12 @@ public class BackendProxyMessageRouter {
|
||||
}
|
||||
|
||||
public void register(GlobalMessageHandler messages, BungeeMethod method) {
|
||||
this.messages = messages;
|
||||
this.messages = messages;
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REPLY) {
|
||||
@Override public void onReceive(JsonEnvelope message) {
|
||||
plugin.getGuidedVotingSessions().proxyFence(message);
|
||||
}
|
||||
});
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE) {
|
||||
@Override public void onReceive(JsonEnvelope msg) { handleWireVote(msg); }
|
||||
});
|
||||
@@ -715,11 +720,15 @@ public class BackendProxyMessageRouter {
|
||||
user.cache();
|
||||
boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : user.isOnline();
|
||||
boolean queuedDelivery = vote.queuedDeliveryKnown ? vote.queuedDelivery : vote.delayValidated;
|
||||
// Even an immediate proxy send can arrive after a guide opened on this backend.
|
||||
// Transport freshness flags cannot establish original occurrence order. The guide
|
||||
// separately validates the canonical ingress ID against its negotiated source fence.
|
||||
var event = user.bungeeVotePluginMessagingEvent(vote.service, vote.time, totals, !vote.manageTotals,
|
||||
wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown,
|
||||
vote.queuedDeliveryKnown, voteId,
|
||||
VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()));
|
||||
if ((event == null || !event.isCancelled()) && plugin.getBungeeSettings().isPerServerPoints()) {
|
||||
VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()),
|
||||
true, vote.realVote);
|
||||
if ((event == null || !event.isCancelled()) && plugin.getBungeeSettings().isPerServerPoints()) {
|
||||
user.addPoints(plugin.getConfigFile().getPointsOnVote());
|
||||
}
|
||||
if (vote.service != null && !vote.service.isEmpty()) {
|
||||
|
||||
@@ -104,6 +104,10 @@ import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
|
||||
public class CommandLoader {
|
||||
private static final int BULK_PLAYER_CAPTURE_BATCH_SIZE = 64;
|
||||
private final Set<CommandHandler> guidedSessionCommands = new HashSet<>();
|
||||
private volatile CommandHandler guidedSessionPermissionHandler;
|
||||
private final java.util.concurrent.atomic.AtomicLong guidedPermissionRevision = new java.util.concurrent.atomic.AtomicLong();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean guidedPermissionsManaged = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final MaintenanceConfirmation maintenanceConfirmation =
|
||||
new MaintenanceConfirmation();
|
||||
|
||||
@@ -3274,6 +3278,7 @@ public class CommandLoader {
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshGuidedSessionPermissionIndex();
|
||||
}
|
||||
|
||||
static void registerAdditionalPermissions(PluginManager manager, CommandHandler handler) {
|
||||
@@ -3629,7 +3634,83 @@ public class CommandLoader {
|
||||
/**
|
||||
* Load vote command.
|
||||
*/
|
||||
/** Refresh only guide-owned handlers; preserve production/custom registrations and command-map aliases. */
|
||||
public void refreshGuidedSessionCommands() {
|
||||
boolean previouslyRegistered = !guidedSessionCommands.isEmpty();
|
||||
long permissionRevision = guidedPermissionRevision.incrementAndGet();
|
||||
ArrayList<CommandHandler> handlers = new ArrayList<>(plugin.getVoteCommand());
|
||||
handlers.removeAll(guidedSessionCommands);
|
||||
guidedSessionCommands.clear();
|
||||
if (plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)) {
|
||||
for (String action : java.util.List.of("", "next", "previous", "skip", "check", "finish", "restart")) {
|
||||
String[] path = action.isEmpty() ? new String[] { "Session" } : new String[] { "Session", action };
|
||||
var sessionCommand = plugin.getGuidedVotingSessions().commandHandler(action);
|
||||
CommandHandler handler = new CommandHandler(plugin, path,
|
||||
"VotingPlugin.Commands.Vote.Session|" + playerPerm, "Guided voting session", false) {
|
||||
@Override public void execute(CommandSender sender, String[] args) {
|
||||
sessionCommand.accept((Player) sender);
|
||||
}
|
||||
};
|
||||
if (plugin.getConfigFile().getDisabledCommands().stream().noneMatch(handler.getPerm()::contains)) {
|
||||
if (plugin.getConfigFile().getDisabledDefaultPermissions().stream()
|
||||
.anyMatch(disabled -> handler.getPerm().contains(disabled + "|"))) {
|
||||
handler.setPerm(handler.getPerm().replace("|" + playerPerm, ""));
|
||||
}
|
||||
guidedSessionCommands.add(handler);
|
||||
handlers.add(guidedSessionCommands.size() - 1, handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
plugin.setVoteCommand(handlers);
|
||||
refreshGuidedSessionPermissionIndex();
|
||||
if (previouslyRegistered || !guidedSessionCommands.isEmpty()) guidedPermissionsManaged.set(true);
|
||||
if (guidedPermissionsManaged.get()) {
|
||||
String inheritedPermission = "VotingPlugin.Commands.Vote.Session|" + playerPerm;
|
||||
boolean inherit = plugin.getConfigFile().getDisabledDefaultPermissions().stream()
|
||||
.noneMatch(disabled -> inheritedPermission.contains(disabled + "|"))
|
||||
&& handlers.stream().anyMatch(handler -> handler.getPerm().contains(inheritedPermission));
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
if (guidedPermissionRevision.get() != permissionRevision) return;
|
||||
Permission parent = Bukkit.getPluginManager().getPermission(playerPerm);
|
||||
if (parent == null) return;
|
||||
if (inherit) parent.getChildren().put("VotingPlugin.Commands.Vote.Session", true);
|
||||
else parent.getChildren().remove("VotingPlugin.Commands.Vote.Session");
|
||||
parent.recalculatePermissibles();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Use the registered built-in command policy for guide-owned callbacks and refreshes. */
|
||||
public boolean canUseGuidedSession(Player player) {
|
||||
CommandHandler handler = guidedSessionPermissionHandler;
|
||||
return handler != null && handler.hasPerm(player);
|
||||
}
|
||||
|
||||
/** Keep the session lookup consistent with routing without registering or replacing Bukkit aliases. */
|
||||
private void refreshGuidedSessionPermissionIndex() {
|
||||
CommandHandler selected = null;
|
||||
for (CommandHandler handler : plugin.getVoteCommand()) {
|
||||
if (!handler.isCommand("Session")) continue;
|
||||
if (guidedSessionCommands.contains(handler)) {
|
||||
if (handler.getArgs().length == 1) {
|
||||
selected = handler;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Preserve the existing alias index's last-handler policy when the guide is disabled.
|
||||
selected = handler;
|
||||
}
|
||||
}
|
||||
guidedSessionPermissionHandler = selected != null && guidedSessionCommands.contains(selected) ? selected : null;
|
||||
if (commands == null) return;
|
||||
final CommandHandler indexed = selected;
|
||||
commands.entrySet().removeIf(entry -> entry.getKey().equalsIgnoreCase("voteSession"));
|
||||
if (indexed != null) commands.put("voteSession", indexed);
|
||||
}
|
||||
|
||||
private void loadVoteCommand() {
|
||||
guidedSessionPermissionHandler = null;
|
||||
guidedSessionCommands.clear();
|
||||
plugin.setVoteCommand(new ArrayList<>());
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" },
|
||||
"VotingPlugin.Commands.Vote.Help|" + playerPerm, "View help page") {
|
||||
@@ -3696,6 +3777,8 @@ public class CommandLoader {
|
||||
}
|
||||
});
|
||||
|
||||
refreshGuidedSessionCommands();
|
||||
|
||||
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "URL", "(SiteName)" },
|
||||
"VotingPlugin.Commands.Vote.URL.VoteSite", "Open VoteURL GUI for VoteSite", false) {
|
||||
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Ephemeral, observation-only progress. Never invokes vote or reward processing. */
|
||||
public final class GuidedVoteSession {
|
||||
public enum Status { AWAITING, RECEIVED, UNAVAILABLE, SKIPPED }
|
||||
public record Site(String key, String name, String url, boolean eligible, long lastVote) { }
|
||||
public record Entry(Site site, Status status) { }
|
||||
public record View(List<Entry> entries, int index, boolean finished) {
|
||||
public Entry current() { return entries.isEmpty() ? null : entries.get(index); }
|
||||
public long received() { return entries.stream().filter(e -> e.status() == Status.RECEIVED).count(); }
|
||||
public boolean complete() { return !entries.isEmpty() && entries.stream().allMatch(e -> e.status() == Status.RECEIVED); }
|
||||
}
|
||||
private final long started;
|
||||
private final long observationOrder;
|
||||
private final Map<String, Site> candidates = new LinkedHashMap<>();
|
||||
private boolean candidatesBound;
|
||||
private final Map<String, Site> sites = new LinkedHashMap<>();
|
||||
private final Map<String, Long> received = new LinkedHashMap<>();
|
||||
private final java.util.Set<String> skipped = new java.util.HashSet<>();
|
||||
private final Map<UUID, String> occurrences = new LinkedHashMap<>();
|
||||
private final Map<UUID, ProxyVoteSequence.Fence> proxyFences = new LinkedHashMap<>();
|
||||
private final Map<UUID, String> pendingProxy = new LinkedHashMap<>();
|
||||
private boolean initialized;
|
||||
private boolean finished;
|
||||
private int index;
|
||||
|
||||
public GuidedVoteSession(long started) { this(started, VoteObservationSequence.next()); }
|
||||
public GuidedVoteSession(long started, long observationOrder) { this.started = started; this.observationOrder = observationOrder; }
|
||||
public long started() { return started; }
|
||||
|
||||
/** Bind visible identities on the player context before asynchronous eligibility sampling. */
|
||||
public synchronized void bindCandidates(List<Site> available) {
|
||||
if (candidatesBound) return;
|
||||
for (Site site : available) if (candidates.size() < 100) candidates.put(site.key(), site);
|
||||
candidatesBound = true;
|
||||
}
|
||||
public synchronized void refresh(List<Site> available) {
|
||||
bindCandidates(available);
|
||||
if (!initialized) {
|
||||
// A cooldown timestamp alone does not establish original ingress order.
|
||||
for (Site site : available) if (candidates.containsKey(site.key()) && (site.eligible() || received.containsKey(site.key())) && sites.size() < 100) sites.put(site.key(), site);
|
||||
// A temporarily hidden/removed site can be absent from the worker sample.
|
||||
// Keep its confirmed identity so visibility restoration can recover progress.
|
||||
for (Site candidate : candidates.values()) if (received.containsKey(candidate.key()))
|
||||
sites.putIfAbsent(candidate.key(), candidate);
|
||||
initialized = true;
|
||||
}
|
||||
Map<String, Site> current = new LinkedHashMap<>();
|
||||
for (Site site : available) current.put(site.key(), site);
|
||||
sites.replaceAll((key, old) -> {
|
||||
Site fresh = current.get(key);
|
||||
return fresh == null ? new Site(key, old.name(), old.url(), false, old.lastVote()) : fresh;
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized boolean candidate(String key) { return candidates.containsKey(key); }
|
||||
/** The accepted pipeline supplies stable IDs and its authoritative occurrence time. */
|
||||
public synchronized void accepted(String site, UUID occurrence, long time, boolean real, boolean cancelled) {
|
||||
if (real && !cancelled && occurrence != null && time > started && candidates.containsKey(site)
|
||||
&& !received.containsKey(site) && !occurrences.containsKey(occurrence)) {
|
||||
received.put(site, time);
|
||||
occurrences.put(occurrence, site);
|
||||
// A credited vote may make the first asynchronous eligibility sample unavailable.
|
||||
// Its identity was already bound, so a later notification can restore that entry.
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
}
|
||||
/** Fresh local ingress uses backend-local order; proxy occurrences require their source fence. */
|
||||
public synchronized void acceptedObserved(String site, UUID occurrence, long order) {
|
||||
if (order > observationOrder && occurrence != null && candidates.containsKey(site)
|
||||
&& !received.containsKey(site) && !occurrences.containsKey(occurrence)) {
|
||||
received.put(site, order);
|
||||
occurrences.put(occurrence, site);
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
}
|
||||
/** A negotiated source fence is fixed for the entire session, not advanced by status checks. */
|
||||
public synchronized void bindProxyFence(ProxyVoteSequence.Fence fence) {
|
||||
if (!proxyFences.containsKey(fence.incarnation()) && proxyFences.size() < 32) {
|
||||
proxyFences.put(fence.incarnation(), fence);
|
||||
var pending = new ArrayList<>(pendingProxy.entrySet());
|
||||
for (var receipt : pending) acceptedProxy(receipt.getValue(), receipt.getKey());
|
||||
}
|
||||
}
|
||||
public synchronized boolean hasProxyFence() { return !proxyFences.isEmpty(); }
|
||||
/** The normal credited pipeline supplies the original canonical identity, including delayed votes. */
|
||||
public synchronized void acceptedProxy(String site, UUID occurrence) {
|
||||
if (occurrence == null || occurrence.version() != 8 || occurrence.variant() != 2
|
||||
|| !candidates.containsKey(site) || received.containsKey(site) || occurrences.containsKey(occurrence)) return;
|
||||
UUID source = new UUID(occurrence.getMostSignificantBits(), 0x8000000000000000L);
|
||||
ProxyVoteSequence.Fence fence = proxyFences.get(source);
|
||||
if (fence == null) {
|
||||
// Bounded provisional receipts cover a new occurrence overtaking its fence reply.
|
||||
if (pendingProxy.size() < 400) pendingProxy.putIfAbsent(occurrence, site);
|
||||
return;
|
||||
}
|
||||
pendingProxy.remove(occurrence);
|
||||
if (!fence.newer(occurrence)) return;
|
||||
received.put(site, occurrence.getLeastSignificantBits() & 0x3fffffffffffffffL);
|
||||
occurrences.put(occurrence, site);
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
/**
|
||||
* Select the next eligible site for automatic guidance after the current site
|
||||
* can no longer be the active target. Explicit navigation remains owned by
|
||||
* {@link #view(String)}; this method only advances from a received,
|
||||
* unavailable, or skipped current entry.
|
||||
*
|
||||
* @return whether the active index changed
|
||||
*/
|
||||
public synchronized boolean selectNextAwaiting() {
|
||||
if (finished || sites.isEmpty()) return false;
|
||||
int start = Math.floorMod(index, sites.size());
|
||||
String current = new ArrayList<>(sites.keySet()).get(start);
|
||||
Status currentStatus = status(sites.get(current));
|
||||
if (currentStatus == Status.AWAITING) return false;
|
||||
List<Site> ordered = new ArrayList<>(sites.values());
|
||||
for (int offset = 1; offset <= ordered.size(); offset++) {
|
||||
int candidateIndex = (start + offset) % ordered.size();
|
||||
Site candidate = ordered.get(candidateIndex);
|
||||
if (status(candidate) == Status.AWAITING && candidate.eligible() && !skipped.contains(candidate.key())) {
|
||||
if (candidateIndex == index) return false;
|
||||
index = candidateIndex;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Status status(Site site) {
|
||||
if (received.containsKey(site.key())) return Status.RECEIVED;
|
||||
if (!site.eligible()) return Status.UNAVAILABLE;
|
||||
return skipped.contains(site.key()) ? Status.SKIPPED : Status.AWAITING;
|
||||
}
|
||||
public synchronized View view(String action) {
|
||||
if (!sites.isEmpty()) {
|
||||
String current = new ArrayList<>(sites.keySet()).get(index);
|
||||
if (action.equals("skip")) { skipped.add(current); index = (index + 1) % sites.size(); }
|
||||
if (action.equals("next")) index = (index + 1) % sites.size();
|
||||
if (action.equals("previous")) index = Math.floorMod(index - 1, sites.size());
|
||||
}
|
||||
if (action.equals("finish")) finished = true;
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
for (Site site : sites.values()) {
|
||||
entries.add(new Entry(site, status(site)));
|
||||
}
|
||||
return new View(List.copyOf(entries), index, finished);
|
||||
}
|
||||
public static String httpUrl(String input) {
|
||||
if (input == null || input.length() > 2048) return null;
|
||||
try {
|
||||
String destination = input.trim();
|
||||
if (destination.startsWith("[")) {
|
||||
var wrapper = java.util.regex.Pattern.compile("(?i)\\[Text=\"[^\"\\r\\n]*\",\\s*url=\"([^\"\\r\\n]*)\"\\]").matcher(destination);
|
||||
if (!wrapper.matches()) return null;
|
||||
destination = wrapper.group(1);
|
||||
}
|
||||
URI uri = URI.create(destination);
|
||||
return ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|
||||
&& uri.getHost() != null && uri.getUserInfo() == null ? uri.toASCIIString() : null;
|
||||
} catch (IllegalArgumentException invalid) { return null; }
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Original proxy ingress order, carried by the existing opaque canonical vote UUID.
|
||||
* UUIDv8: random 60-bit source incarnation and a monotonically increasing 62-bit sequence.
|
||||
* The process/classloader owns one source across runtime replacement; a restart creates
|
||||
* another. Never assign a new ID when forwarding, retrying or replaying an occurrence. */
|
||||
public final class ProxyVoteSequence {
|
||||
private static final long MAX_SEQUENCE = 0x3fffffffffffffffL;
|
||||
private static final ProxyVoteSequence LIVE = new ProxyVoteSequence(UUID.randomUUID());
|
||||
private final long source;
|
||||
private long sequence;
|
||||
|
||||
public ProxyVoteSequence(UUID incarnation) {
|
||||
source = (incarnation.getMostSignificantBits() & 0xffffffffffff0fffL) | 0x8000L;
|
||||
}
|
||||
public static UUID nextVoteId() { return LIVE.next(); }
|
||||
public static Fence currentFence() { return LIVE.fence(); }
|
||||
public synchronized UUID next() {
|
||||
if (sequence == MAX_SEQUENCE) throw new IllegalStateException("Proxy vote sequence exhausted");
|
||||
return new UUID(source, 0x8000000000000000L | ++sequence);
|
||||
}
|
||||
public synchronized Fence fence() { return new Fence(new UUID(source, 0x8000000000000000L), sequence); }
|
||||
|
||||
public record Fence(UUID incarnation, long watermark) {
|
||||
public Fence {
|
||||
if (incarnation == null || incarnation.version() != 8 || incarnation.variant() != 2
|
||||
|| incarnation.getLeastSignificantBits() != 0x8000000000000000L
|
||||
|| watermark < 0 || watermark > MAX_SEQUENCE)
|
||||
throw new IllegalArgumentException("Invalid proxy occurrence fence");
|
||||
}
|
||||
public boolean newer(UUID occurrence) {
|
||||
return occurrence != null && occurrence.version() == 8 && occurrence.variant() == 2
|
||||
&& occurrence.getMostSignificantBits() == incarnation.getMostSignificantBits()
|
||||
&& (occurrence.getLeastSignificantBits() & MAX_SEQUENCE) > watermark;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/** Process-local total ordering shared by guide creation and vote ingress; never a remote timestamp. */
|
||||
public final class VoteObservationSequence {
|
||||
private static final AtomicLong SEQUENCE = new AtomicLong();
|
||||
private VoteObservationSequence() { }
|
||||
public static long next() { return SEQUENCE.incrementAndGet(); }
|
||||
}
|
||||
+14
-1
@@ -12,7 +12,20 @@ import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class PlayerPostVoteEvent extends Event {
|
||||
public class PlayerPostVoteEvent extends Event {
|
||||
@Getter @Setter private boolean proxySessionRealVote = true;
|
||||
/** Local recovered ingress cannot prove ordering relative to a new guide. */
|
||||
@Getter @Setter private boolean unconfirmedLocalSessionDelivery;
|
||||
|
||||
/** Backend-local ingress order forwarded by the accepted pipeline. Zero means unknown. */
|
||||
@Getter @Setter private long backendObservationOrder;
|
||||
@Getter @Setter private boolean proxyQueueClassificationKnown;
|
||||
@Getter @Setter private boolean queuedProxyVote;
|
||||
@Getter @Setter private boolean proxySessionDelivery;
|
||||
/** Guide-only provenance; independent of normal reward queue/delay policy. */
|
||||
@Getter @Setter private boolean unconfirmedProxySessionDelivery;
|
||||
/** Fresh local ingress with no supplied historical occurrence timestamp. */
|
||||
@Getter @Setter private boolean liveLocalSessionDelivery;
|
||||
|
||||
/** The Constant handlers. */
|
||||
private static final HandlerList handlers = new HandlerList();
|
||||
|
||||
@@ -20,6 +20,16 @@ import lombok.Setter;
|
||||
* Integrations must cancel before the MONITOR vote consumer runs.</p>
|
||||
*/
|
||||
public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
@Getter @Setter private boolean proxySessionRealVote = true;
|
||||
|
||||
/** Backend-local strictly increasing observation, never a timestamp from another node. */
|
||||
@Getter
|
||||
@Setter
|
||||
private long backendObservationOrder = com.bencodez.votingplugin.core.session.VoteObservationSequence.next();
|
||||
/** Guide-only marker; preserves normal original vote timestamp and accounting semantics. */
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean timeQueuedLocalSessionDelivery;
|
||||
|
||||
/** The Constant handlers. */
|
||||
private static final HandlerList handlers = new HandlerList();
|
||||
@@ -96,6 +106,11 @@ public class PlayerVoteEvent extends Event implements Cancellable {
|
||||
@Setter
|
||||
private boolean proxyQueueClassificationKnown;
|
||||
|
||||
/** Guide-only provenance: original ingress is forwarded, replayed, or unknown. */
|
||||
@Getter
|
||||
@Setter
|
||||
private boolean unconfirmedProxySessionDelivery;
|
||||
|
||||
/** Whether the proxy explicitly supplied its delay-validation decision. */
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
+12
@@ -184,6 +184,7 @@ public class PlayerVoteListener implements Listener {
|
||||
long voteTime, UUID voteId, boolean cached) {
|
||||
PlayerPostVoteEvent post = new PlayerPostVoteEvent(site, user, event.isRealVote(), event.isForceBungee(),
|
||||
voteTime, cached, site.getServiceSite(), user.getJavaUUID(), playerName, voteId);
|
||||
copySessionObservation(post, event);
|
||||
plugin.getServer().getPluginManager().callEvent(post);
|
||||
}
|
||||
@Override public boolean placeholderCacheAlways() { return plugin.getConfigFile().getPlaceholderCacheLevel().isCacheAlways(); }
|
||||
@@ -192,6 +193,17 @@ public class PlayerVoteListener implements Listener {
|
||||
@Override public void setUpdate() { plugin.setUpdate(true); }
|
||||
}
|
||||
|
||||
static void copySessionObservation(PlayerPostVoteEvent post, PlayerVoteEvent input) {
|
||||
post.setProxySessionDelivery(input.isBungee());
|
||||
post.setProxySessionRealVote(input.isProxySessionRealVote());
|
||||
post.setUnconfirmedProxySessionDelivery(input.isUnconfirmedProxySessionDelivery());
|
||||
post.setLiveLocalSessionDelivery(!input.isBungee() && (input.getTime() == 0 || input.isTimeQueuedLocalSessionDelivery()) && input.getBackendObservationOrder() > 0);
|
||||
post.setUnconfirmedLocalSessionDelivery(!input.isBungee() && (input.getTime() == 0 || input.isTimeQueuedLocalSessionDelivery()) && input.getBackendObservationOrder() <= 0);
|
||||
post.setBackendObservationOrder(input.getBackendObservationOrder());
|
||||
post.setProxyQueueClassificationKnown(input.isProxyQueueClassificationKnown());
|
||||
post.setQueuedProxyVote(input.isQueuedProxyVote());
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
static UUID resolveProxyVoteId(PlayerVoteEvent event) {
|
||||
if (event.getProxyVoteId() != null) return event.getProxyVoteId();
|
||||
|
||||
@@ -36,6 +36,10 @@ public class VotiferEvent implements Listener {
|
||||
* @param voteUsername the validated player name
|
||||
*/
|
||||
public void processVote(String voteSite, String voteUsername) {
|
||||
processVote(voteSite, voteUsername, 0L);
|
||||
}
|
||||
/** Original process-local ingress order; zero identifies recovered or unknown ordering. */
|
||||
public void processVote(String voteSite, String voteUsername, long observationOrder) {
|
||||
try {
|
||||
plugin.getServerData().addServiceSite(voteSite);
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass()
|
||||
@@ -77,7 +81,7 @@ public class VotiferEvent implements Listener {
|
||||
if (plugin.getTimeChecker().isActiveProcessing()
|
||||
&& plugin.getConfigFile().isQueueVotesDuringTimeChange()) {
|
||||
plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite);
|
||||
plugin.getTimeQueueHandler().addVote(voteUsername, voteSite);
|
||||
plugin.getTimeQueueHandler().addVote(voteUsername, voteSite, observationOrder);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,6 +89,7 @@ public class VotiferEvent implements Listener {
|
||||
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
|
||||
plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true);
|
||||
voteEvent.setBackendObservationOrder(observationOrder);
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
|
||||
if (voteEvent.isCancelled()) {
|
||||
@@ -135,11 +140,12 @@ 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));
|
||||
plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername, observationOrder));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue();
|
||||
if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) {
|
||||
if (overflow == null || !overflow.enqueue(voteUsername, voteSite, observationOrder)) {
|
||||
plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for "
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(voteUsername));
|
||||
} else {
|
||||
|
||||
+19
-5
@@ -42,7 +42,9 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
private static final String QUEUE_FILE = "VotifierVoteQueue.yml";
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final BiConsumer<String, String> processor;
|
||||
@FunctionalInterface
|
||||
public interface ObservedVoteProcessor { void accept(String site, String player, long order); }
|
||||
private final ObservedVoteProcessor processor;
|
||||
private final Path file;
|
||||
private final ScheduledThreadPoolExecutor worker;
|
||||
private final Object lock = new Object();
|
||||
@@ -63,6 +65,12 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
* @param processor callback receiving service site and player name
|
||||
*/
|
||||
public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer<String, String> processor) {
|
||||
this(plugin, (site, player, order) -> processor.accept(site, player));
|
||||
}
|
||||
public static VotifierVoteOverflowQueue withObservations(VotingPluginMain plugin, ObservedVoteProcessor processor) {
|
||||
return new VotifierVoteOverflowQueue(plugin, processor);
|
||||
}
|
||||
private VotifierVoteOverflowQueue(VotingPluginMain plugin, ObservedVoteProcessor processor) {
|
||||
this.plugin = plugin;
|
||||
this.processor = processor;
|
||||
this.file = new File(plugin.getDataFolder(), QUEUE_FILE).toPath();
|
||||
@@ -97,10 +105,13 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
* @return false when the bounded overflow is full or shutting down
|
||||
*/
|
||||
public boolean enqueue(String username, String serviceSite) {
|
||||
return enqueue(username, serviceSite, com.bencodez.votingplugin.core.session.VoteObservationSequence.next());
|
||||
}
|
||||
public boolean enqueue(String username, String serviceSite, long observationOrder) {
|
||||
if (username == null || serviceSite == null) return false;
|
||||
synchronized (lock) {
|
||||
if (closed || entries.size() >= MAX_ENTRIES) return false;
|
||||
entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis()));
|
||||
entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis(), observationOrder));
|
||||
stateVersion++;
|
||||
requestPersistenceLocked();
|
||||
scheduleDrainLocked();
|
||||
@@ -153,7 +164,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
// above cannot change in the gap before submit accepts this vote.
|
||||
plugin.getVoteTimer().submit(() -> {
|
||||
try {
|
||||
processor.accept(pending.serviceSite, pending.username);
|
||||
processor.accept(pending.serviceSite, pending.username, pending.observationOrder);
|
||||
} finally {
|
||||
acknowledge(pending);
|
||||
}
|
||||
@@ -265,7 +276,7 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
entries.addLast(new PendingVote(name, site, timestamp.longValue()));
|
||||
entries.addLast(new PendingVote(name, site, timestamp.longValue(), 0L));
|
||||
}
|
||||
if (skipped) {
|
||||
synchronized (lock) {
|
||||
@@ -361,7 +372,10 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable {
|
||||
private final long time;
|
||||
private boolean submitted;
|
||||
|
||||
private PendingVote(String username, String serviceSite, long time) {
|
||||
// Process-local ordering is intentionally not restored across server restarts.
|
||||
private final long observationOrder;
|
||||
private PendingVote(String username, String serviceSite, long time, long observationOrder) {
|
||||
this.observationOrder = observationOrder;
|
||||
this.username = username;
|
||||
this.serviceSite = serviceSite;
|
||||
this.time = time;
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ public final class PendingIncomingVoteQueue {
|
||||
|
||||
public synchronized PendingIncomingVote admit(String player, String service) {
|
||||
if (!accepting || votes.size() >= MAX_PENDING) return null;
|
||||
PendingIncomingVote vote = new PendingIncomingVote(UUID.randomUUID(), player, service,
|
||||
PendingIncomingVote vote = new PendingIncomingVote(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId(), player, service,
|
||||
System.currentTimeMillis());
|
||||
votes.put(vote.getVoteId(), vote);
|
||||
return vote;
|
||||
|
||||
+11
-1
@@ -74,6 +74,7 @@ final class ReliableVoteDeliveryOutbox {
|
||||
long deferredCount = entries.values().stream().filter(Entry::deferredUpdate).count();
|
||||
if (deferred ? !update || deferredCount >= MAX_DEFERRED_UPDATE_ENTRIES
|
||||
: update ? readyUpdates() >= MAX_UPDATE_ENTRIES : entries.size() - updates >= MAX_ENTRIES) return false;
|
||||
envelope = retryEnvelope(envelope);
|
||||
String record = addRecord(server, envelope, deferred);
|
||||
Entry entry = new Entry(server, envelope, false, false, false, deferred);
|
||||
long remainingReserve = terminalRecordReserve() + terminalRecordReserve(entry);
|
||||
@@ -238,7 +239,7 @@ final class ReliableVoteDeliveryOutbox {
|
||||
try {
|
||||
if (parts.length == 3 && (ADD.equals(parts[0]) || DEFERRED.equals(parts[0]))) {
|
||||
String server = decode(parts[1]);
|
||||
JsonEnvelope envelope = JsonEnvelopeCodec.decode(decode(parts[2]));
|
||||
JsonEnvelope envelope = retryEnvelope(JsonEnvelopeCodec.decode(decode(parts[2])));
|
||||
String key = key(server, envelope);
|
||||
if (key == null) throw new IllegalArgumentException("Invalid vote envelope");
|
||||
boolean deferred = DEFERRED.equals(parts[0]);
|
||||
@@ -395,6 +396,15 @@ final class ReliableVoteDeliveryOutbox {
|
||||
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
/** A retained delivery is no longer an immediate live observation, including pre-upgrade rows. */
|
||||
private static JsonEnvelope retryEnvelope(JsonEnvelope envelope) {
|
||||
if (!VotingPluginWire.SUB_VOTE.equals(envelope.getSubChannel())
|
||||
&& !VotingPluginWire.SUB_VOTE_ONLINE.equals(envelope.getSubChannel())) return envelope;
|
||||
var copy = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
envelope.getFields().forEach(copy::put);
|
||||
return copy.put(VotingPluginWire.K_SESSION_DELIVERY_FRESH, false).build();
|
||||
}
|
||||
|
||||
private static String key(String server, JsonEnvelope envelope) {
|
||||
if (server == null || server.isBlank() || envelope == null) return null;
|
||||
String subChannel = envelope.getSubChannel();
|
||||
|
||||
@@ -2253,7 +2253,10 @@ public abstract class VotingPluginProxy {
|
||||
votePartyVotes = getVoteCacheCurrentVotePartyVotes();
|
||||
|
||||
globalMessageProxyHandler = new VotingPluginGlobalMessageProxyHandler();
|
||||
registerControlEnrollmentListener(globalMessageProxyHandler);
|
||||
registerControlEnrollmentListener(globalMessageProxyHandler);
|
||||
globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REQUEST) {
|
||||
@Override public void onReceive(JsonEnvelope message) { handleSessionFenceRequest(message); }
|
||||
});
|
||||
|
||||
globalMessageProxyHandler.addListener(new GlobalMessageListener(VotingPluginWire.SUB_LOGIN) {
|
||||
@Override
|
||||
@@ -2744,7 +2747,14 @@ public abstract class VotingPluginProxy {
|
||||
// encryption policy during a soft reload.
|
||||
SharedTransportEnvelopeAuthenticator multiProxyAuthenticator = getConfig().getMultiProxySupport()
|
||||
? sharedTransportAuthenticator() : sharedTransportAuthenticator;
|
||||
multiProxyHandler = new MultiProxyHandler() {
|
||||
multiProxyHandler = new MultiProxyHandler() {
|
||||
@Override protected void onSessionFenceReply(JsonEnvelope message) {
|
||||
var reply = VotingPluginWire.readSessionFenceReply(message);
|
||||
if (reply != null && reply.request().origin().equals(getConfig().getProxyServerName())
|
||||
&& isServerValid(reply.request().server()))
|
||||
globalMessageProxyHandler.sendMessage(reply.request().server(), 0, message);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void addNonVotedPlayerCache(String uuid, String player) {
|
||||
@@ -2895,6 +2905,8 @@ public abstract class VotingPluginProxy {
|
||||
@Override
|
||||
public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime,
|
||||
VoteTotalsSnapshot text, String uuid) {
|
||||
// Legacy callbacks have no ACK/retirement owner. Preserve their ordinary
|
||||
// delivery path rather than creating permanent multi-proxy completion files.
|
||||
vote(player, service, realVote, timeQueue, queueTime, text, uuid);
|
||||
}
|
||||
|
||||
@@ -5992,6 +6004,18 @@ public abstract class VotingPluginProxy {
|
||||
return sendProxyBroadcastEnvelopeNow(server, envelope);
|
||||
}
|
||||
|
||||
/** Read-only session negotiation uses existing encrypted/authenticated transports. */
|
||||
protected void handleSessionFenceRequest(JsonEnvelope message) {
|
||||
var request = VotingPluginWire.readSessionFenceRequest(message);
|
||||
if (request == null || !request.origin().isEmpty() || !isServerValid(request.server())) return;
|
||||
var fence = com.bencodez.votingplugin.core.session.ProxyVoteSequence.currentFence();
|
||||
globalMessageProxyHandler.sendMessage(request.server(), 0, VotingPluginWire.sessionFenceReply(request, fence));
|
||||
if (multiProxyHandler != null && getConfig().getMultiProxySupport()) {
|
||||
var relayed = new VotingPluginWire.SessionFenceRequest(request.player(), request.nonce(),
|
||||
request.server(), getConfig().getProxyServerName());
|
||||
multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.sessionFenceRequest(relayed));
|
||||
}
|
||||
}
|
||||
protected void handleStatusOkay(JsonEnvelope message) {
|
||||
String server = message.getFields().getOrDefault(VotingPluginWire.K_SERVER, "");
|
||||
String request = message.getFields().getOrDefault(VotingPluginWire.K_REQUEST_ID, "");
|
||||
@@ -6049,6 +6073,14 @@ public abstract class VotingPluginProxy {
|
||||
return sendVoteEnvelopeAccepted(playerServer, 1, envelope);
|
||||
}
|
||||
|
||||
/** Backend ingress cannot establish original freshness after a forwarding hop or replay. */
|
||||
private JsonEnvelope withSessionDeliveryProvenance(JsonEnvelope envelope, UUID voteId, VoteTimeQueue queuedVote) {
|
||||
MultiProxyVoteRetry forwarded = multiProxyVoteRetries.get(voteId);
|
||||
if (forwarded != null) return VotingPluginWire.unconfirmedSessionDelivery(envelope, forwarded.origin);
|
||||
if (queuedVote != null) return VotingPluginWire.unconfirmedSessionDelivery(envelope, queuedVote.getMultiProxyOrigin());
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private JsonEnvelope cachedVoteEnvelope(OfflineBungeeVote vote, boolean online, boolean broadcast,
|
||||
int num, int numberOfVotes) {
|
||||
if (vote.hasCachedVoteBatch()) {
|
||||
@@ -6073,6 +6105,9 @@ public abstract class VotingPluginProxy {
|
||||
resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(),
|
||||
getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes);
|
||||
}
|
||||
// Cached delivery cannot prove fresh original ingress in the backend's clock
|
||||
// domain, even when the reward policy correctly classifies it as non-queued.
|
||||
envelope = VotingPluginWire.unconfirmedSessionDelivery(envelope, "");
|
||||
// A cache entry is only known to have passed the proxy's delay gate when
|
||||
// that decision was persisted with it. Unknown legacy entries must still
|
||||
// be checked by the receiving backend.
|
||||
@@ -6851,6 +6886,7 @@ public abstract class VotingPluginProxy {
|
||||
authoritativeDelayValidated)
|
||||
: VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, text.toString(),
|
||||
voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
rewardEnvelope = withSessionDeliveryProvenance(rewardEnvelope, voteId, queuedVote);
|
||||
boolean rewardAccepted = sendVoteEnvelopeAccepted(s, 2, rewardEnvelope, pendingVote);
|
||||
if (!rewardAccepted) {
|
||||
pendingVote.setRewardDelivered(false);
|
||||
@@ -6910,6 +6946,7 @@ public abstract class VotingPluginProxy {
|
||||
authoritativeDelayValidated)
|
||||
: VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote,
|
||||
text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1);
|
||||
rewardEnvelope = withSessionDeliveryProvenance(rewardEnvelope, voteId, queuedVote);
|
||||
rewardAccepted = sendVoteEnvelopeAccepted(server, 1, rewardEnvelope, pendingVote);
|
||||
if (rewardAccepted) {
|
||||
retryState.deliveredRewardServers.add(server);
|
||||
|
||||
@@ -35,10 +35,59 @@ public final class VotingPluginWire {
|
||||
|
||||
public static final int SCHEMA_VERSION = 1;
|
||||
|
||||
/** Additive request/reply negotiation; old peers simply ignore these subchannels. */
|
||||
public record SessionFenceRequest(UUID player, UUID nonce, String server, String origin) { }
|
||||
public record SessionFenceReply(SessionFenceRequest request,
|
||||
com.bencodez.votingplugin.core.session.ProxyVoteSequence.Fence fence) { }
|
||||
public static JsonEnvelope sessionFenceRequest(SessionFenceRequest request) {
|
||||
return sessionFenceBuilder(SUB_VOTE_SESSION_FENCE_REQUEST, request).build();
|
||||
}
|
||||
public static JsonEnvelope sessionFenceReply(SessionFenceRequest request,
|
||||
com.bencodez.votingplugin.core.session.ProxyVoteSequence.Fence fence) {
|
||||
return sessionFenceBuilder(SUB_VOTE_SESSION_FENCE_REPLY, request)
|
||||
.put(K_SESSION_INCARNATION, fence.incarnation().toString())
|
||||
.put(K_SESSION_WATERMARK, Long.toString(fence.watermark())).build();
|
||||
}
|
||||
private static JsonEnvelope.Builder sessionFenceBuilder(String sub, SessionFenceRequest request) {
|
||||
return base(sub).put(K_SESSION_FENCE_VERSION, 1).put(K_UUID, request.player().toString())
|
||||
.put(K_SESSION_NONCE, request.nonce().toString()).put(K_SERVER, request.server())
|
||||
.put(K_SESSION_ORIGIN, request.origin());
|
||||
}
|
||||
public static SessionFenceRequest readSessionFenceRequest(JsonEnvelope message) {
|
||||
if (message == null || message.getSchema() != SCHEMA_VERSION
|
||||
|| !(SUB_VOTE_SESSION_FENCE_REQUEST.equals(message.getSubChannel())
|
||||
|| SUB_VOTE_SESSION_FENCE_REPLY.equals(message.getSubChannel()))
|
||||
|| !"1".equals(message.getFields().get(K_SESSION_FENCE_VERSION))) return null;
|
||||
var fields = message.getFields();
|
||||
try {
|
||||
String server = fields.getOrDefault(K_SERVER, "");
|
||||
String origin = fields.getOrDefault(K_SESSION_ORIGIN, "");
|
||||
if (server.isBlank() || server.length() > 128 || origin.length() > 128) return null;
|
||||
return new SessionFenceRequest(UUID.fromString(fields.get(K_UUID)),
|
||||
UUID.fromString(fields.get(K_SESSION_NONCE)), server, origin);
|
||||
} catch (IllegalArgumentException | NullPointerException invalid) { return null; }
|
||||
}
|
||||
public static SessionFenceReply readSessionFenceReply(JsonEnvelope message) {
|
||||
SessionFenceRequest request = readSessionFenceRequest(message);
|
||||
if (request == null || !SUB_VOTE_SESSION_FENCE_REPLY.equals(message.getSubChannel())) return null;
|
||||
try {
|
||||
return new SessionFenceReply(request, new com.bencodez.votingplugin.core.session.ProxyVoteSequence.Fence(
|
||||
UUID.fromString(message.getFields().get(K_SESSION_INCARNATION)),
|
||||
Long.parseLong(message.getFields().get(K_SESSION_WATERMARK))));
|
||||
} catch (IllegalArgumentException | NullPointerException invalid) { return null; }
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Subchannels (canonical)
|
||||
// =========================
|
||||
public static final String SUB_VOTE = "Vote";
|
||||
public static final String SUB_VOTE_SESSION_FENCE_REQUEST = "VoteSessionFenceRequest";
|
||||
public static final String SUB_VOTE_SESSION_FENCE_REPLY = "VoteSessionFenceReply";
|
||||
public static final String K_SESSION_FENCE_VERSION = "sessionFenceVersion";
|
||||
public static final String K_SESSION_NONCE = "sessionNonce";
|
||||
public static final String K_SESSION_ORIGIN = "sessionOrigin";
|
||||
public static final String K_SESSION_INCARNATION = "sessionIncarnation";
|
||||
public static final String K_SESSION_WATERMARK = "sessionWatermark";
|
||||
public static final String SUB_VOTE = "Vote";
|
||||
public static final String SUB_VOTE_ONLINE = "VoteOnline";
|
||||
public static final String SUB_VOTE_UPDATE = "VoteUpdate";
|
||||
/** Internal queue metadata; never a backend delivery target. */
|
||||
@@ -121,6 +170,8 @@ public final class VotingPluginWire {
|
||||
public static final String K_NUMBER_OF_VOTES = "numberOfVotes";
|
||||
public static final String K_DELAY_VALIDATED = "delayValidated";
|
||||
public static final String K_QUEUED_DELIVERY = "queuedDelivery";
|
||||
/** Guide-only delivery hint; cross-node occurrence order remains unconfirmed. */
|
||||
public static final String K_SESSION_DELIVERY_FRESH = "sessionDeliveryFresh";
|
||||
public static final String K_VOTE_DELIVERY_ACK_VERSION = "voteDeliveryAckVersion";
|
||||
public static final String K_AUTHENTICATED_SOCKET_VOTE_DELIVERY_VERSION =
|
||||
"authenticatedSocketVoteDeliveryVersion";
|
||||
@@ -186,7 +237,7 @@ public final class VotingPluginWire {
|
||||
.put(K_TIME, time).put(K_WAS_ONLINE, wasOnline).put(K_WAS_ONLINE_KNOWN, true)
|
||||
.put(K_REAL_VOTE, realVote).put(K_TOTALS, safe(totals))
|
||||
.put(K_VOTE_ID, voteId == null ? "" : voteId.toString()).put(K_SET_TOTALS, true)
|
||||
.put(K_MANAGE_TOTALS, manageTotals).put(K_QUEUED_DELIVERY, false)
|
||||
.put(K_MANAGE_TOTALS, manageTotals).put(K_QUEUED_DELIVERY, false).put(K_SESSION_DELIVERY_FRESH, false)
|
||||
.put(K_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num)
|
||||
.put(K_NUMBER_OF_VOTES, numberOfVotes);
|
||||
}
|
||||
@@ -252,6 +303,19 @@ public final class VotingPluginWire {
|
||||
return builder.put(K_QUEUED_DELIVERY, true).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retains origin provenance for guide observations without changing reward queue
|
||||
* classification. An empty origin explicitly means the original ingress is unknown
|
||||
* (for example a cached delivery); it does not claim a reliable multi-proxy sender.
|
||||
*/
|
||||
public static JsonEnvelope unconfirmedSessionDelivery(JsonEnvelope envelope, String origin) {
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
for (Map.Entry<String, String> field : envelope.getFields().entrySet()) {
|
||||
builder.put(field.getKey(), field.getValue());
|
||||
}
|
||||
return builder.put(K_MULTI_PROXY_ORIGIN, safe(origin)).put(K_SESSION_DELIVERY_FRESH, false).build();
|
||||
}
|
||||
|
||||
/** Removes the additive queue marker for cache rows created by pre-marker proxies. */
|
||||
public static JsonEnvelope legacyUnclassifiedDelivery(JsonEnvelope envelope) {
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
|
||||
+1
-1
@@ -629,7 +629,7 @@ public class VotingPluginBungee extends Plugin implements Listener {
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
return;
|
||||
}
|
||||
pending = new PendingIncomingVote(UUID.randomUUID(), player, service, System.currentTimeMillis());
|
||||
pending = new PendingIncomingVote(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId(), player, service, System.currentTimeMillis());
|
||||
if (votingPluginProxy == null || !votingPluginProxy.retainIncomingVoteForRestart(pending)) {
|
||||
getLogger().severe("Pending vote admission is full and durable overflow failed; vote was not accepted for "
|
||||
+ MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
|
||||
+16
@@ -958,6 +958,8 @@ public abstract class MultiProxyHandler {
|
||||
&& getRedisPrefix() != null && !getRedisPrefix().isEmpty();
|
||||
}
|
||||
|
||||
protected void onSessionFenceReply(JsonEnvelope message) { }
|
||||
|
||||
private synchronized void loadMultiProxyRedisListener(String channel) {
|
||||
if (!registeredRedisChannels.add(channel)) return;
|
||||
try {
|
||||
@@ -1161,6 +1163,18 @@ public abstract class MultiProxyHandler {
|
||||
f = new HashMap<>();
|
||||
}
|
||||
|
||||
if (sub.equals(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REQUEST)) {
|
||||
var request = VotingPluginWire.readSessionFenceRequest(envelope);
|
||||
if (request != null && configuredMultiProxyRecipientNames().containsKey(request.origin().toLowerCase(Locale.ROOT)))
|
||||
sendMultiProxyEnvelopeAccepted(VotingPluginWire.sessionFenceReply(request,
|
||||
com.bencodez.votingplugin.core.session.ProxyVoteSequence.currentFence()), List.of(request.origin()));
|
||||
return;
|
||||
}
|
||||
if (sub.equals(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REPLY)) {
|
||||
var reply = VotingPluginWire.readSessionFenceReply(envelope);
|
||||
if (reply != null && reply.request().origin().equals(getMultiProxyServerName())) onSessionFenceReply(envelope);
|
||||
return;
|
||||
}
|
||||
if (sub.equalsIgnoreCase(VotingPluginWire.SUB_STATUS)) {
|
||||
logInfo("Multi-proxy status message received");
|
||||
return;
|
||||
@@ -1276,6 +1290,8 @@ public abstract class MultiProxyHandler {
|
||||
if (origin.isBlank()) {
|
||||
triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid);
|
||||
} else {
|
||||
// Preserve the receiver-local time used by ordinary backend cooldowns.
|
||||
// Guide confirmation rejects forwarded ingress independently of this time.
|
||||
triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid,
|
||||
wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown);
|
||||
}
|
||||
|
||||
+1
-1
@@ -524,7 +524,7 @@ public class VotingPluginVelocity {
|
||||
MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
return;
|
||||
}
|
||||
pending = new PendingIncomingVote(UUID.randomUUID(), player, service, System.currentTimeMillis());
|
||||
pending = new PendingIncomingVote(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId(), player, service, System.currentTimeMillis());
|
||||
if (votingPluginProxy == null || !votingPluginProxy.retainIncomingVoteForRestart(pending)) {
|
||||
logger.error("Pending vote admission is full and durable overflow failed; vote was not accepted for {}",
|
||||
MinecraftUsernameValidator.sanitizeForLog(player));
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.bencodez.votingplugin.session;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import io.github.projectunified.unidialog.core.payload.DialogPayload;
|
||||
|
||||
/** Lazy host adapter over already bundled UniDialog; owns its own callbacks and event hook. */
|
||||
final class GuidedSessionDialogs implements SessionDialogView {
|
||||
private record Invocation(BiConsumer<UUID, String> callback, UUID owner, String action) { }
|
||||
private final ThreadLocal<List<Invocation>> pendingCallbacks = new ThreadLocal<>();
|
||||
private static final String NAMESPACE = "votingplugin_session";
|
||||
private record Platform(String manager, Class<?> event, boolean paper) {}
|
||||
private final Object manager;
|
||||
private final Platform platform;
|
||||
|
||||
private static Platform probe() throws ReflectiveOperationException {
|
||||
ClassLoader loader = GuidedSessionDialogs.class.getClassLoader();
|
||||
try {
|
||||
Class<?> dialog = Class.forName("io.papermc.paper.dialog.Dialog", false, loader);
|
||||
Class<?> event = Class.forName("io.papermc.paper.event.player.PlayerCustomClickEvent", false, loader);
|
||||
event.getMethod("getIdentifier");
|
||||
event.getMethod("getCommonConnection");
|
||||
Class<?> dialogLike = Class.forName("net.kyori.adventure.dialog.DialogLike", false, loader);
|
||||
Player.class.getMethod("showDialog", dialogLike);
|
||||
Class.forName("io.papermc.paper.registry.data.dialog.ActionButton", false, loader);
|
||||
if (!dialogLike.isAssignableFrom(dialog)) throw new NoSuchMethodException("Incompatible Paper dialog API");
|
||||
return new Platform("io.github.projectunified.unidialog.paper.PaperDialogManager", event, true);
|
||||
} catch (ClassNotFoundException | NoSuchMethodException noPaper) {
|
||||
Class<?> dialog = Class.forName("net.md_5.bungee.api.dialog.Dialog", false, loader);
|
||||
Class<?> event = Class.forName("org.bukkit.event.player.PlayerCustomClickEvent", false, loader);
|
||||
event.getMethod("getId");
|
||||
event.getMethod("getPlayer");
|
||||
Player.class.getMethod("showDialog", dialog);
|
||||
return new Platform("io.github.projectunified.unidialog.spigot.SpigotDialogManager", event, false);
|
||||
}
|
||||
}
|
||||
static boolean supported() {
|
||||
try { probe(); return true; }
|
||||
catch (ReflectiveOperationException | LinkageError unavailable) { return false; }
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
GuidedSessionDialogs(VotingPluginMain plugin) {
|
||||
try {
|
||||
platform = probe();
|
||||
Class<?> type = Class.forName(platform.manager(), true, getClass().getClassLoader());
|
||||
manager = type.getConstructor(Plugin.class, String.class).newInstance(plugin, NAMESPACE);
|
||||
Method dispatch = type.getMethod("onCustomClick", platform.event());
|
||||
type.getMethod("registerCustomAction", String.class, String.class, Consumer.class);
|
||||
type.getMethod("unregisterCustomAction", String.class, String.class);
|
||||
// Do not register the library listener: all its HashMap accesses use this private lock.
|
||||
plugin.getServer().getPluginManager().registerEvent((Class<? extends Event>) platform.event(),
|
||||
new Listener() {}, EventPriority.NORMAL, (listener, event) -> {
|
||||
if (!platform.event().isInstance(event)) return;
|
||||
List<Invocation> callbacks = new ArrayList<>();
|
||||
pendingCallbacks.set(callbacks);
|
||||
try {
|
||||
synchronized (manager) { dispatch.invoke(manager, event); }
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
plugin.getLogger().warning("Voting session dialog callback failed: " + failure.getClass().getSimpleName());
|
||||
} finally {
|
||||
pendingCallbacks.remove();
|
||||
}
|
||||
// Controller callbacks can acquire their own locks or schedule work.
|
||||
// Never invoke them while holding the library manager's registry lock.
|
||||
for (Invocation callback : callbacks)
|
||||
callback.callback().accept(callback.owner(), callback.action());
|
||||
}, plugin, false);
|
||||
} catch (ReflectiveOperationException | LinkageError failure) {
|
||||
throw new IllegalStateException("Native dialog API unavailable", failure);
|
||||
}
|
||||
}
|
||||
public Runnable show(Player player, String title, String body, List<Button> buttons,
|
||||
BiConsumer<UUID, String> action) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
synchronized (manager) {
|
||||
try {
|
||||
Object dialog = call(manager, "createMultiActionDialog", new Class<?>[0]);
|
||||
call(dialog, "title", new Class<?>[] {String.class}, title);
|
||||
call(dialog, "canCloseWithEscape", new Class<?>[] {boolean.class}, true);
|
||||
call(dialog, "pause", new Class<?>[] {boolean.class}, false);
|
||||
call(dialog, "afterAction", new Class<?>[] {io.github.projectunified.unidialog.core.dialog.Dialog.AfterAction.class},
|
||||
io.github.projectunified.unidialog.core.dialog.Dialog.AfterAction.CLOSE);
|
||||
call(dialog, "columns", new Class<?>[] {int.class}, 1);
|
||||
call(dialog, "body", new Class<?>[] {Consumer.class}, (Consumer<Object>) builder -> {
|
||||
Object text = call(builder, "text", new Class<?>[0]);
|
||||
call(text, "text", new Class<?>[] {String.class}, body);
|
||||
});
|
||||
for (var button : buttons) {
|
||||
String id = UUID.randomUUID().toString();
|
||||
if (button.action() != null) {
|
||||
Consumer<DialogPayload> callback = payload -> {
|
||||
List<Invocation> queued = pendingCallbacks.get();
|
||||
if (queued != null && queued.size() < 16)
|
||||
queued.add(new Invocation(action, payload.owner(), button.action()));
|
||||
};
|
||||
call(manager, "registerCustomAction", new Class<?>[] {String.class, String.class, Consumer.class},
|
||||
NAMESPACE, id, callback);
|
||||
ids.add(id);
|
||||
}
|
||||
call(dialog, "action", new Class<?>[] {Consumer.class}, (Consumer<Object>) builder -> {
|
||||
call(builder, "label", new Class<?>[] {String.class}, button.label());
|
||||
call(builder, "tooltip", new Class<?>[] {String.class}, button.tooltip());
|
||||
if (button.url() != null) call(builder, "openUrl", new Class<?>[] {String.class}, button.url());
|
||||
else call(builder, "dynamicCustom", new Class<?>[] {String.class, String.class}, NAMESPACE, id);
|
||||
});
|
||||
}
|
||||
Object opener = call(dialog, "opener", new Class<?>[0]);
|
||||
Class<?> audience = platform.paper()
|
||||
? Class.forName("net.kyori.adventure.audience.Audience", false, getClass().getClassLoader()) : Player.class;
|
||||
call(opener, "open", new Class<?>[] {audience}, player);
|
||||
} catch (RuntimeException | LinkageError | ClassNotFoundException failure) {
|
||||
release(ids);
|
||||
throw new IllegalStateException("Native dialog could not open", failure);
|
||||
}
|
||||
}
|
||||
List<String> owned = List.copyOf(ids);
|
||||
return () -> { synchronized (manager) { release(owned); } };
|
||||
}
|
||||
private void release(List<String> ids) {
|
||||
for (String id : ids) call(manager, "unregisterCustomAction", new Class<?>[] {String.class, String.class}, NAMESPACE, id);
|
||||
}
|
||||
private static Object call(Object target, String name, Class<?>[] types, Object... arguments) {
|
||||
try { return target.getClass().getMethod(name, types).invoke(target, arguments); }
|
||||
catch (ReflectiveOperationException failure) { throw new IllegalStateException("Native dialog method unavailable: " + name, failure); }
|
||||
}
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
package com.bencodez.votingplugin.session;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.core.session.GuidedVoteSession;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import net.md_5.bungee.api.chat.ClickEvent;
|
||||
import net.md_5.bungee.api.chat.TextComponent;
|
||||
|
||||
/** Observation-only native guidance with a clear chat fallback on older hosts. */
|
||||
public final class GuidedVotingSessions implements Listener {
|
||||
private final VotingPluginMain plugin;
|
||||
private final Map<UUID, GuidedVoteSession> sessions = new LinkedHashMap<>();
|
||||
private final Map<UUID, Long> requests = new LinkedHashMap<>();
|
||||
private record EarlyReceipt(UUID storageId, String site, UUID occurrence, long time, boolean observed, boolean proxy) { }
|
||||
private final Map<UUID, UUID> storageIds = new LinkedHashMap<>();
|
||||
private final Map<UUID, String> playerNames = new LinkedHashMap<>();
|
||||
private final Map<UUID, Player> owners = new LinkedHashMap<>();
|
||||
private final Map<UUID, Map<String, List<EarlyReceipt>>> early = new LinkedHashMap<>();
|
||||
private record ProxyRequest(UUID nonce, String server, long startedNanos, boolean sent) { }
|
||||
private final Map<UUID, ProxyRequest> proxyRequests = new LinkedHashMap<>();
|
||||
private long lifecycle;
|
||||
private final java.util.Set<Long> outstanding = new java.util.HashSet<>();
|
||||
private final Map<UUID, Long> pending = new LinkedHashMap<>();
|
||||
private long generation;
|
||||
private final java.util.function.Supplier<SessionDialogView> dialogFactory;
|
||||
private SessionDialogView dialogView;
|
||||
private boolean dialogChecked;
|
||||
private record DialogRegistration(UUID nonce, Runnable cleanup) { }
|
||||
private java.util.concurrent.ScheduledFuture<?> dialogCleanup;
|
||||
private final Map<UUID, DialogRegistration> dialogs = new LinkedHashMap<>();
|
||||
private final java.util.Set<UUID> automaticRefresh = new java.util.HashSet<>();
|
||||
|
||||
public GuidedVotingSessions(VotingPluginMain plugin) {
|
||||
this(plugin, () -> GuidedSessionDialogs.supported() ? new GuidedSessionDialogs(plugin) : null);
|
||||
}
|
||||
GuidedVotingSessions(VotingPluginMain plugin, java.util.function.Supplier<SessionDialogView> factory) {
|
||||
this.plugin = plugin; this.dialogFactory = factory;
|
||||
}
|
||||
public synchronized void clear() { for (UUID owner : List.copyOf(dialogs.keySet())) retireDialog(owner); if (dialogCleanup != null) { dialogCleanup.cancel(false); dialogCleanup = null; } automaticRefresh.clear(); sessions.clear(); proxyRequests.clear(); requests.clear(); storageIds.clear(); playerNames.clear(); owners.clear(); early.clear(); pending.clear(); generation++; lifecycle++; }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public synchronized void credited(PlayerPostVoteEvent event) {
|
||||
if (event.getVoteSite() == null || !event.isRealVote() || event.isCancelled()
|
||||
|| event.getVoteUUID() == null || event.getUuid() == null) return;
|
||||
boolean proxy = event.isProxySessionDelivery() || event.isBungee();
|
||||
// Proxy transport flags and clocks prove no original order; negotiated source fences do.
|
||||
if (proxy ? !event.isProxySessionRealVote() : event.isUnconfirmedLocalSessionDelivery()) return;
|
||||
boolean observed = event.isLiveLocalSessionDelivery() && event.getBackendObservationOrder() != 0;
|
||||
long time = observed ? event.getBackendObservationOrder() : event.getVoteTime();
|
||||
expire(System.currentTimeMillis());
|
||||
String key = event.getVoteSite().getKey();
|
||||
for (var entry : sessions.entrySet()) {
|
||||
UUID owner = entry.getKey(); GuidedVoteSession session = entry.getValue();
|
||||
if (event.getUuid().equals(storageIds.get(owner))) {
|
||||
var before = session.view("");
|
||||
if (proxy) session.acceptedProxy(key, event.getVoteUUID());
|
||||
else if (observed) session.acceptedObserved(key, event.getVoteUUID(), time);
|
||||
else session.accepted(key, event.getVoteUUID(), time, true, false);
|
||||
if (confirmedTarget(before, session.view(""))) refresh(owner, session);
|
||||
} else if (!storageIds.containsKey(owner) && session.candidate(key)
|
||||
&& (proxy || observed || time > session.started()) && event.getPlayerName() != null
|
||||
&& event.getPlayerName().equalsIgnoreCase(playerNames.get(owner))) {
|
||||
// Name only routes a bounded provisional receipt. Resolved storage UUID must
|
||||
// match before it can confirm progress; no player/entity access occurs here.
|
||||
// A failed lookup releases pending work, but this active guide still owns
|
||||
// its bounded provisional receipts until identity resolution or expiration.
|
||||
var receipts = early.computeIfAbsent(owner, ignored -> new LinkedHashMap<>())
|
||||
.computeIfAbsent(key, ignored -> new ArrayList<>());
|
||||
receipts.removeIf(receipt -> receipt.occurrence().equals(event.getVoteUUID()));
|
||||
if (receipts.size() >= 4) receipts.remove(0);
|
||||
receipts.add(new EarlyReceipt(event.getUuid(), key, event.getVoteUUID(), time, observed, proxy));
|
||||
}
|
||||
}
|
||||
// Never execute rewards from a notification; a newly accepted occurrence may refresh the guide.
|
||||
}
|
||||
|
||||
/** Recheck current eligibility on the existing worker before reopening a credited guide. */
|
||||
private void refresh(UUID owner, GuidedVoteSession session) {
|
||||
if (session.view("").finished()) return;
|
||||
automaticRefresh.add(owner);
|
||||
Player player = owners.get(owner);
|
||||
if (player == null) return;
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) {
|
||||
expire(System.currentTimeMillis());
|
||||
if (sessions.get(owner) != session || owners.get(owner) != player || session.view("").finished()) return;
|
||||
// A current worker owns a pending refresh; its final owner callback
|
||||
// consumes the flag and samples the latest confirmed progress.
|
||||
if (!pending.containsKey(owner)) prepare(player, "automatic");
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
}
|
||||
|
||||
/** Transport callback: bounded identities only; no Bukkit, user storage or reward execution. */
|
||||
public synchronized void proxyFence(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope message) {
|
||||
var reply = com.bencodez.votingplugin.proxy.VotingPluginWire.readSessionFenceReply(message);
|
||||
if (reply == null) return;
|
||||
expire(System.currentTimeMillis());
|
||||
UUID owner = reply.request().player();
|
||||
ProxyRequest request = proxyRequests.get(owner);
|
||||
GuidedVoteSession session = sessions.get(owner);
|
||||
if (request == null || !request.sent() || session == null || !request.nonce().equals(reply.request().nonce())
|
||||
|| !request.server().equals(reply.request().server())
|
||||
|| System.nanoTime() - request.startedNanos() > java.util.concurrent.TimeUnit.SECONDS.toNanos(30)) return;
|
||||
var before = session.view("");
|
||||
session.bindProxyFence(reply.fence());
|
||||
if (confirmedTarget(before, session.view(""))) refresh(owner, session);
|
||||
}
|
||||
|
||||
private static boolean confirmedTarget(GuidedVoteSession.View before, GuidedVoteSession.View after) {
|
||||
return before.current() != null && after.current() != null
|
||||
&& before.current().site().key().equals(after.current().site().key())
|
||||
&& before.current().status() != GuidedVoteSession.Status.RECEIVED
|
||||
&& after.current().status() == GuidedVoteSession.Status.RECEIVED;
|
||||
}
|
||||
|
||||
/** Capture a registration's lifecycle before the command framework queues asynchronous execution. */
|
||||
public synchronized java.util.function.Consumer<Player> commandHandler(String action) {
|
||||
long epoch = lifecycle;
|
||||
return player -> BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) { if (epoch == lifecycle) prepare(player, action); }
|
||||
}, () -> { }, () -> { });
|
||||
}
|
||||
|
||||
public void command(Player player, String action) {
|
||||
commandHandler(action).accept(player);
|
||||
}
|
||||
private void prepare(Player player, String action) {
|
||||
if (!player.isOnline() || plugin.getServer().getPlayer(player.getUniqueId()) != player) return;
|
||||
if (!plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)) {
|
||||
player.sendMessage("Guided voting sessions are disabled by this server."); return;
|
||||
}
|
||||
if (!plugin.getCommandLoader().canUseGuidedSession(player)) return;
|
||||
UUID uuid = player.getUniqueId();
|
||||
UUID storageUuid = VotingPluginMain.placeholderStorageUuid(player, plugin.getOptions().isOnlineMode());
|
||||
String name = player.getName();
|
||||
List<VoteSite> visible = new ArrayList<>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) {
|
||||
if (site.isEnabled() && !site.isHidden() && (site.getPermissionToView().isEmpty()
|
||||
|| player.hasPermission(site.getPermissionToView()))) visible.add(site);
|
||||
if (visible.size() >= 100) break;
|
||||
}
|
||||
GuidedVoteSession session;
|
||||
boolean creating;
|
||||
long request;
|
||||
com.bencodez.votingplugin.proxy.VotingPluginWire.SessionFenceRequest proxyRequest;
|
||||
synchronized (this) {
|
||||
long now = System.currentTimeMillis();
|
||||
expire(now);
|
||||
if (action.equals("automatic") && !sessions.containsKey(uuid)) return;
|
||||
if (pending.containsKey(uuid) || outstanding.size() >= 64) {
|
||||
player.sendMessage("Voting status is already being checked or busy. Please try again shortly."); return;
|
||||
}
|
||||
if (!sessions.containsKey(uuid) && sessions.size() >= 2048) {
|
||||
player.sendMessage("Voting sessions are busy. Please try again later."); return;
|
||||
}
|
||||
if (action.equals("restart")) { retireDialog(uuid); automaticRefresh.remove(uuid); sessions.remove(uuid); storageIds.remove(uuid); early.remove(uuid); proxyRequests.remove(uuid); }
|
||||
creating = !sessions.containsKey(uuid);
|
||||
session = sessions.computeIfAbsent(uuid, ignored -> new GuidedVoteSession(now));
|
||||
proxyRequest = null;
|
||||
if (creating && plugin.getBungeeSettings().isUseBungeecoord()) {
|
||||
var fenceRequest = new ProxyRequest(UUID.randomUUID(), plugin.getOptions().getServer(), 0, false);
|
||||
proxyRequests.put(uuid, fenceRequest);
|
||||
proxyRequest = new com.bencodez.votingplugin.proxy.VotingPluginWire.SessionFenceRequest(
|
||||
uuid, fenceRequest.nonce(), fenceRequest.server(), "");
|
||||
}
|
||||
session.bindCandidates(visible.stream().map(site -> new GuidedVoteSession.Site(site.getKey(),
|
||||
site.getDisplayName(), site.getVoteURL(true), false, 0)).toList());
|
||||
playerNames.put(uuid, name);
|
||||
owners.put(uuid, player);
|
||||
request = ++generation;
|
||||
requests.put(uuid, request);
|
||||
pending.put(uuid, request);
|
||||
outstanding.add(request);
|
||||
}
|
||||
final var requestToSend = proxyRequest;
|
||||
try {
|
||||
plugin.getTimer().execute(() -> {
|
||||
try {
|
||||
synchronized (this) { if (!current(uuid, session, request)) return; }
|
||||
if (requestToSend != null) {
|
||||
var handler = plugin.getBackendProxyHandler();
|
||||
if (handler != null && handler.getGlobalMessageHandler() != null) {
|
||||
synchronized (this) {
|
||||
if (!current(uuid, session, request)) return;
|
||||
var queued = proxyRequests.get(uuid);
|
||||
if (queued == null || !queued.nonce().equals(requestToSend.nonce())) return;
|
||||
// Queue wait is not reply time. Publish before sending so an
|
||||
// immediate transport reply can bind this exact live request.
|
||||
proxyRequests.put(uuid, new ProxyRequest(queued.nonce(), queued.server(), System.nanoTime(), true));
|
||||
}
|
||||
handler.getGlobalMessageHandler().sendMessage(
|
||||
com.bencodez.votingplugin.proxy.VotingPluginWire.sessionFenceRequest(requestToSend));
|
||||
}
|
||||
}
|
||||
var currentSites = new java.util.HashMap<String, VoteSite>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) currentSites.put(site.getKey(), site);
|
||||
var sampledSites = new java.util.HashMap<String, VoteSite>();
|
||||
var user = plugin.getVotingPluginUserManager().getVotingPluginUser(storageUuid, name);
|
||||
String storedName = user.getPlayerName();
|
||||
String voteName = storedName == null ? name : storedName;
|
||||
var lastVotes = new java.util.HashMap<String, Long>();
|
||||
user.getLastVotes().forEach((site, time) -> lastVotes.put(site.getKey(), time));
|
||||
List<GuidedVoteSession.Site> snapshots = new ArrayList<>();
|
||||
for (VoteSite original : visible) {
|
||||
VoteSite site = currentSites.get(original.getKey());
|
||||
if (site == null || !site.isEnabled() || site.isHidden()) continue;
|
||||
sampledSites.put(site.getKey(), site);
|
||||
snapshots.add(new GuidedVoteSession.Site(site.getKey(), site.getDisplayName(),
|
||||
com.bencodez.advancedcore.api.messages.PlaceholderUtils.replacePlaceHolder(site.getVoteURL(true),
|
||||
"player", voteName),
|
||||
user.canVoteSite(site, lastVotes.getOrDefault(site.getKey(), 0L)),
|
||||
plugin.getBungeeSettings().isUseBungeecoord() ? 0 : lastVotes.getOrDefault(site.getKey(), 0L)));
|
||||
}
|
||||
synchronized (this) {
|
||||
if (!current(uuid, session, request)) return;
|
||||
UUID storageId = user.getJavaUUID();
|
||||
storageIds.put(uuid, storageId);
|
||||
var provisional = early.remove(uuid);
|
||||
if (provisional != null) for (var receipts : provisional.values()) for (var receipt : receipts) {
|
||||
if (receipt.storageId().equals(storageId)) {
|
||||
if (receipt.proxy()) session.acceptedProxy(receipt.site(), receipt.occurrence());
|
||||
else if (receipt.observed()) session.acceptedObserved(receipt.site(), receipt.occurrence(), receipt.time());
|
||||
else session.accepted(receipt.site(), receipt.occurrence(), receipt.time(), true, false);
|
||||
}
|
||||
}
|
||||
session.refresh(snapshots);
|
||||
// Storage sampling is complete before an owner callback can render.
|
||||
// A newly credited vote must be able to prepare its successor even
|
||||
// when this callback completes before the worker enters finally.
|
||||
pending.remove(uuid, request); outstanding.remove(request);
|
||||
}
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) { if (!current(uuid, session, request)) return; }
|
||||
if (player.isOnline() && plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)
|
||||
&& plugin.getCommandLoader().canUseGuidedSession(player)) {
|
||||
synchronized (this) {
|
||||
if (!current(uuid, session, request)) return;
|
||||
boolean automatic = automaticRefresh.remove(uuid) || action.equals("automatic");
|
||||
if (action.equals("automatic") && session.view("").finished()) return;
|
||||
// Visibility may change after the worker's storage sample.
|
||||
// Refresh from owner-validated immutable snapshots before
|
||||
// choosing the next target; no storage is accessed here.
|
||||
var ownerVisible = visibleView(player, session.view(""), sampledSites);
|
||||
session.refresh(ownerVisible.entries().stream().map(GuidedVoteSession.Entry::site).toList());
|
||||
if (automatic || action.isEmpty()) session.selectNextAwaiting();
|
||||
var view = session.view(action);
|
||||
render(player, visibleView(player, view, sampledSites));
|
||||
}
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
} catch (RuntimeException failure) {
|
||||
plugin.debug(failure);
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) { if (!current(uuid, session, request)) return; }
|
||||
if (player.isOnline() && plugin.getConfigFile().getData().getBoolean("GuidedVotingSession.Enabled", false)
|
||||
&& plugin.getCommandLoader().canUseGuidedSession(player)) player.sendMessage("Could not check voting status. Please try " + sessionCommand("check") + ".");
|
||||
}, () -> { }, () -> { });
|
||||
} finally {
|
||||
synchronized (this) { pending.remove(uuid, request); outstanding.remove(request); }
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException unavailable) {
|
||||
synchronized (this) {
|
||||
pending.remove(uuid, request); outstanding.remove(request);
|
||||
// No worker ran to send the initial fence. Retire only this new guide,
|
||||
// so an ordinary reopen retries creation without resetting existing progress.
|
||||
if (creating && sessions.get(uuid) == session && Long.valueOf(request).equals(requests.get(uuid))) {
|
||||
sessions.remove(uuid); requests.remove(uuid); storageIds.remove(uuid);
|
||||
playerNames.remove(uuid); early.remove(uuid); proxyRequests.remove(uuid);
|
||||
}
|
||||
}
|
||||
player.sendMessage("Voting status is temporarily unavailable. Please try again.");
|
||||
}
|
||||
}
|
||||
/** Expiration releases owner admission, while outstanding work retains its global slot until it finishes. */
|
||||
private synchronized void expire(long now) {
|
||||
int minutes = Math.max(1, Math.min(120, plugin.getConfigFile().getData().getInt("GuidedVotingSession.TimeoutMinutes", 30)));
|
||||
sessions.entrySet().removeIf(e -> {
|
||||
if (now - e.getValue().started() < minutes * 60_000L) return false;
|
||||
retireDialog(e.getKey()); automaticRefresh.remove(e.getKey()); return true;
|
||||
});
|
||||
requests.keySet().retainAll(sessions.keySet());
|
||||
pending.keySet().retainAll(sessions.keySet());
|
||||
storageIds.keySet().retainAll(sessions.keySet());
|
||||
playerNames.keySet().retainAll(sessions.keySet());
|
||||
owners.keySet().retainAll(sessions.keySet());
|
||||
early.keySet().retainAll(sessions.keySet());
|
||||
proxyRequests.keySet().retainAll(sessions.keySet());
|
||||
}
|
||||
private synchronized boolean current(UUID uuid, GuidedVoteSession session, long request) {
|
||||
expire(System.currentTimeMillis());
|
||||
return sessions.get(uuid) == session && Long.valueOf(request).equals(requests.get(uuid));
|
||||
}
|
||||
/** Recheck visibility on the player context, after any asynchronous wait. */
|
||||
private GuidedVoteSession.View visibleView(Player player, GuidedVoteSession.View view, Map<String, VoteSite> sampledSites) {
|
||||
java.util.Set<String> allowed = new java.util.HashSet<>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) {
|
||||
if (sampledSites.get(site.getKey()) == site && site.isEnabled() && !site.isHidden() && (site.getPermissionToView().isEmpty()
|
||||
|| player.hasPermission(site.getPermissionToView()))) allowed.add(site.getKey());
|
||||
}
|
||||
List<GuidedVoteSession.Entry> entries = new ArrayList<>();
|
||||
for (var entry : view.entries()) entries.add(allowed.contains(entry.site().key()) ? entry
|
||||
: new GuidedVoteSession.Entry(new GuidedVoteSession.Site(entry.site().key(), "Unavailable site",
|
||||
null, false, 0), GuidedVoteSession.Status.UNAVAILABLE));
|
||||
return new GuidedVoteSession.View(List.copyOf(entries), view.index(), view.finished());
|
||||
}
|
||||
private void render(Player player, GuidedVoteSession.View view) {
|
||||
retireDialog(player.getUniqueId());
|
||||
try {
|
||||
if (!dialogChecked) { dialogChecked = true; dialogView = dialogFactory.get(); }
|
||||
if (dialogView != null) { renderDialog(player, view); return; }
|
||||
} catch (RuntimeException | LinkageError unsupported) {
|
||||
plugin.debug(unsupported);
|
||||
}
|
||||
player.sendMessage("Native voting dialogs are unavailable on this server. Showing chat guidance instead.");
|
||||
renderChat(player, view);
|
||||
}
|
||||
private void renderDialog(Player player, GuidedVoteSession.View view) {
|
||||
UUID owner = player.getUniqueId();
|
||||
UUID nonce = UUID.randomUUID();
|
||||
GuidedVoteSession session = sessions.get(owner);
|
||||
List<SessionDialogView.Button> buttons = new ArrayList<>();
|
||||
String body = view.received() + "/" + view.entries().size() + " votes received.";
|
||||
if (view.entries().isEmpty()) body += "\nNo eligible, visible voting sites. Restart after your cooldowns end.";
|
||||
else if (view.finished() || view.complete()) body += view.complete()
|
||||
? "\nAll session votes were received. Thank you!" : "\nSession finished. Unconfirmed votes remain unconfirmed.";
|
||||
else {
|
||||
var entry = view.current();
|
||||
body += "\nSite " + (view.index() + 1) + "/" + view.entries().size() + ": " + entry.site().name()
|
||||
+ "\n" + entry.status() + "\nWaiting for VotingPlugin to receive your vote.";
|
||||
String url = GuidedVoteSession.httpUrl(entry.site().url());
|
||||
if (url != null && (entry.status() == GuidedVoteSession.Status.AWAITING || entry.status() == GuidedVoteSession.Status.SKIPPED))
|
||||
buttons.add(new SessionDialogView.Button("Open voting link", "Your client controls opening the browser.", null, url));
|
||||
else if (url == null) body += "\nThis site has no valid HTTP/HTTPS link. Ask an administrator.";
|
||||
}
|
||||
if (session != null && plugin.getBungeeSettings().isUseBungeecoord() && !session.hasProxyFence())
|
||||
body += "\nProxy confirmation is pending or unavailable.";
|
||||
body += "\nOpening a link never grants a vote or reward.";
|
||||
if (!view.entries().isEmpty() && !view.finished() && !view.complete())
|
||||
for (String action : List.of("previous", "next", "skip", "check", "finish"))
|
||||
buttons.add(new SessionDialogView.Button(action, "", action, null));
|
||||
buttons.add(new SessionDialogView.Button("Restart session", "", "restart", null));
|
||||
buttons.add(new SessionDialogView.Button("Close", "Your current session is retained.", "close", null));
|
||||
java.util.concurrent.atomic.AtomicBoolean used = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
Runnable cleanup = dialogView.show(player, "Voting session", body, List.copyOf(buttons), (actor, action) -> {
|
||||
if (!owner.equals(actor)) return;
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
synchronized (this) {
|
||||
expire(System.currentTimeMillis());
|
||||
DialogRegistration active = dialogs.get(owner);
|
||||
if (active == null || !nonce.equals(active.nonce()) || sessions.get(owner) != session
|
||||
|| !player.isOnline() || !used.compareAndSet(false, true)) return;
|
||||
retireDialog(owner);
|
||||
if (plugin.getCommandLoader().canUseGuidedSession(player) && !action.equals("close")) prepare(player, action);
|
||||
}
|
||||
}, () -> { }, () -> { });
|
||||
});
|
||||
try {
|
||||
// One bounded sweep owns all callback deadlines. Replacing a view
|
||||
// does not leave cancelled per-view futures in a shared timer queue.
|
||||
if (dialogCleanup == null) dialogCleanup = plugin.getTimer().scheduleWithFixedDelay(() -> {
|
||||
synchronized (this) { expire(System.currentTimeMillis()); }
|
||||
}, 30, 30, java.util.concurrent.TimeUnit.SECONDS);
|
||||
dialogs.put(owner, new DialogRegistration(nonce, cleanup));
|
||||
} catch (RuntimeException failure) {
|
||||
cleanup.run(); throw failure;
|
||||
}
|
||||
}
|
||||
private void retireDialog(UUID owner) {
|
||||
DialogRegistration registration = dialogs.remove(owner);
|
||||
if (registration == null) return;
|
||||
try { registration.cleanup().run(); }
|
||||
catch (RuntimeException failure) { plugin.debug(failure); }
|
||||
}
|
||||
@EventHandler
|
||||
public synchronized void disconnected(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
UUID owner = event.getPlayer().getUniqueId();
|
||||
retireDialog(owner); requests.remove(owner); pending.remove(owner); automaticRefresh.remove(owner); owners.remove(owner);
|
||||
}
|
||||
private void renderChat(Player player, GuidedVoteSession.View view) {
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord()) {
|
||||
GuidedVoteSession session = sessions.get(player.getUniqueId());
|
||||
if (session != null && !session.hasProxyFence()) player.sendMessage(
|
||||
"Proxy confirmation is pending or unavailable. Only votes after a supporting proxy acknowledges this session can be confirmed.");
|
||||
}
|
||||
player.sendMessage("Voting session: " + view.received() + "/" + view.entries().size() + " votes received.");
|
||||
if (view.entries().isEmpty()) {
|
||||
player.sendMessage("No eligible, visible voting sites. Try " + sessionCommand("restart") + " after your cooldowns end."); return;
|
||||
}
|
||||
if (view.finished() || view.complete()) {
|
||||
player.sendMessage(view.complete() ? "All session votes were received. Thank you!"
|
||||
: "Session finished. Unconfirmed votes remain unconfirmed; normal vote rewards are unchanged.");
|
||||
}
|
||||
for (int i = 0; i < view.entries().size(); i++) {
|
||||
var entry = view.entries().get(i);
|
||||
player.sendMessage((i == view.index() ? "> " : " ") + entry.site().name() + " — " + entry.status());
|
||||
}
|
||||
var entry = view.current();
|
||||
if (!view.finished() && (entry.status() == GuidedVoteSession.Status.AWAITING || entry.status() == GuidedVoteSession.Status.SKIPPED)) {
|
||||
String url = GuidedVoteSession.httpUrl(entry.site().url());
|
||||
if (url != null) button(player, "Open voting link: " + entry.site().name(), ClickEvent.Action.OPEN_URL, url);
|
||||
else player.sendMessage("This site has no valid HTTP/HTTPS link. Ask an administrator.");
|
||||
}
|
||||
for (String action : List.of("previous", "next", "skip", "check", "finish", "restart"))
|
||||
button(player, "[" + action + "]", ClickEvent.Action.RUN_COMMAND, sessionCommand(action));
|
||||
player.sendMessage("A link or click is not vote confirmation. Check again after the vote arrives.");
|
||||
}
|
||||
private String sessionCommand(String action) {
|
||||
return "/" + plugin.getCommandLoader().getVoteCommandName() + " session " + action;
|
||||
}
|
||||
private static void button(Player player, String title, ClickEvent.Action action, String value) {
|
||||
TextComponent component = new TextComponent(title);
|
||||
component.setClickEvent(new ClickEvent(action, value));
|
||||
player.spigot().sendMessage(component);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.bencodez.votingplugin.session;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** A player-owned native view; cleanup releases only this view's action handlers. */
|
||||
interface SessionDialogView {
|
||||
record Button(String label, String tooltip, String action, String url) { }
|
||||
Runnable show(Player player, String title, String body, List<Button> buttons,
|
||||
BiConsumer<UUID, String> action);
|
||||
}
|
||||
+11
-2
@@ -49,8 +49,15 @@ public class TimeQueueHandler implements Listener {
|
||||
* @param voteSiteName the vote site name
|
||||
*/
|
||||
public void addVote(String voteUsername, String voteSiteName) {
|
||||
timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName,
|
||||
LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +123,8 @@ public class TimeQueueHandler implements Listener {
|
||||
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()) {
|
||||
|
||||
@@ -27,6 +27,10 @@ public class VoteTimeQueue {
|
||||
@Getter
|
||||
@Setter
|
||||
private long time;
|
||||
/** Process-local original ingress order; intentionally excluded from persisted cache rows. */
|
||||
@Getter
|
||||
@Setter
|
||||
private transient long backendObservationOrder;
|
||||
@Getter
|
||||
@Setter
|
||||
private UUID voteId;
|
||||
|
||||
@@ -1003,16 +1003,45 @@ public class VotingPluginUser extends com.bencodez.advancedcore.api.user.Advance
|
||||
boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote,
|
||||
boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId,
|
||||
boolean targetedProxyVote) {
|
||||
bungeeVotePluginMessagingEvent(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote,
|
||||
proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote);
|
||||
bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote,
|
||||
proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, false);
|
||||
}
|
||||
|
||||
/** Dispatches the proxy vote and returns its final cancellation state; null when proxy mode is disabled. */
|
||||
public PlayerVoteEvent bungeeVotePluginMessagingEvent(String service, long time, VoteTotalsSnapshot text, boolean setTotals,
|
||||
/** Supplies guide-only provenance independently of the established reward/delay flags. */
|
||||
public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals,
|
||||
boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote,
|
||||
boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId,
|
||||
boolean targetedProxyVote) {
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord()) {
|
||||
boolean targetedProxyVote, boolean unconfirmedProxySessionDelivery) {
|
||||
bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote,
|
||||
proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote,
|
||||
unconfirmedProxySessionDelivery, true);
|
||||
}
|
||||
|
||||
/** Guide-only real/test classification; preserve the established normal vote pipeline semantics. */
|
||||
public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals,
|
||||
boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote,
|
||||
boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId,
|
||||
boolean targetedProxyVote, boolean unconfirmedProxySessionDelivery, boolean sessionRealVote) {
|
||||
bungeeVotePluginMessagingEvent(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote,
|
||||
proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote,
|
||||
unconfirmedProxySessionDelivery, sessionRealVote);
|
||||
}
|
||||
|
||||
/** Dispatches a proxy vote with legacy provenance defaults and returns its cancellation state. */
|
||||
public PlayerVoteEvent bungeeVotePluginMessagingEvent(String service, long time, VoteTotalsSnapshot text, boolean setTotals,
|
||||
boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote,
|
||||
boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId,
|
||||
boolean targetedProxyVote) {
|
||||
return bungeeVotePluginMessagingEvent(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote,
|
||||
proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, false, true);
|
||||
}
|
||||
|
||||
/** Preserves guide-only occurrence provenance while exposing the final cancellation state. */
|
||||
public PlayerVoteEvent bungeeVotePluginMessagingEvent(String service, long time, VoteTotalsSnapshot text, boolean setTotals,
|
||||
boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote,
|
||||
boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId,
|
||||
boolean targetedProxyVote, boolean unconfirmedProxySessionDelivery, boolean sessionRealVote) {
|
||||
if (plugin.getBungeeSettings().isUseBungeecoord()) {
|
||||
plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service);
|
||||
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true),
|
||||
@@ -1031,6 +1060,8 @@ public class VotingPluginUser extends com.bencodez.advancedcore.api.user.Advance
|
||||
voteEvent.setProxyQueueClassificationKnown(proxyQueueClassificationKnown);
|
||||
voteEvent.setProxyDelayValidationKnown(proxyDelayValidationKnown);
|
||||
voteEvent.setTargetedProxyVote(targetedProxyVote);
|
||||
voteEvent.setUnconfirmedProxySessionDelivery(unconfirmedProxySessionDelivery);
|
||||
voteEvent.setProxySessionRealVote(sessionRealVote);
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
return voteEvent;
|
||||
}
|
||||
@@ -1078,7 +1109,11 @@ public class VotingPluginUser extends com.bencodez.advancedcore.api.user.Advance
|
||||
* @return true, if the user can vote on the site
|
||||
*/
|
||||
public boolean canVoteSite(VoteSite voteSite) {
|
||||
long time = getTime(voteSite);
|
||||
return canVoteSite(voteSite, getTime(voteSite));
|
||||
}
|
||||
|
||||
/** Same cooldown decision using an already loaded last-vote snapshot; performs no storage read. */
|
||||
public boolean canVoteSite(VoteSite voteSite, long time) {
|
||||
if (time == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1265,3 +1265,9 @@ DisableUpdateChecking: false
|
||||
# Disable checking permissions on tab complete
|
||||
# Could increase performance slightly, but probably not
|
||||
DisableAdvancedTab: false
|
||||
|
||||
# Observation-only chat guide; normal /vote and reward processing remain unchanged.
|
||||
# Progress is local, expires after this interval, and resets on reload/restart.
|
||||
GuidedVotingSession:
|
||||
Enabled: false
|
||||
TimeoutMinutes: 30
|
||||
@@ -195,6 +195,9 @@ permissions:
|
||||
VotingPlugin.Login.RemindVotes.All:
|
||||
description: Remind if player can vote on all votesites, rather than any
|
||||
default: false
|
||||
VotingPlugin.Commands.Vote.Session:
|
||||
description: Start or navigate a guided voting session
|
||||
default: false
|
||||
|
||||
VotingPlugin.Commands.Vote.Streaks:
|
||||
description: View your read-only vote streak progression
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package com.bencodez.votingplugin.tests;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
import com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache;
|
||||
import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync;
|
||||
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter;
|
||||
import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager;
|
||||
import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync;
|
||||
import com.bencodez.votingplugin.config.BungeeSettings;
|
||||
import com.bencodez.votingplugin.core.vote.SharedVotePolicy;
|
||||
import com.bencodez.votingplugin.core.vote.SharedVoteProcessor;
|
||||
import com.bencodez.votingplugin.data.ServerData;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.proxy.BungeeMethod;
|
||||
import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginProxy;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler;
|
||||
import com.bencodez.votingplugin.proxy.multiproxy.MultiProxyHandler;
|
||||
import com.bencodez.votingplugin.user.UserManager;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
import com.bencodez.votingplugin.votesites.VoteSiteManager;
|
||||
|
||||
class MultiProxyCooldownCompatibilityTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> reliableForwardingKeepsNormalCooldownTimeLocalToReceiver() {
|
||||
return Stream.of(100L, System.currentTimeMillis() + 86_400_000L)
|
||||
.map(senderTime -> DynamicTest.dynamicTest("sender time " + senderTime,
|
||||
() -> assertReceiverCooldownTime(senderTime)));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void sourceFenceRepliesOnlyToConfiguredOriginAndOriginRelaysOnlyItsOwnSession() throws Exception {
|
||||
VotingPluginProxyTestImpl proxy = spy(new VotingPluginProxyTestImpl());
|
||||
proxy.setDataFolder(temporaryDirectory.toFile());
|
||||
when(proxy.getConfig().getProxyServerName()).thenReturn("Receiver");
|
||||
var backendReplies = new java.util.ArrayList<JsonEnvelope>();
|
||||
proxy.setGlobalMessageProxyHandlerForTest(new GlobalMessageProxyHandler() {
|
||||
@Override public void sendMessage(String server, int delay, JsonEnvelope envelope) {
|
||||
assertEquals("server1", server); backendReplies.add(envelope);
|
||||
}
|
||||
});
|
||||
proxy.loadMultiProxySupport();
|
||||
MultiProxyHandler handler = spy(proxy.getMultiProxyHandler());
|
||||
doReturn(com.bencodez.votingplugin.proxy.multiproxy.MultiProxyMethod.REDIS).when(handler).getMultiProxyMethod();
|
||||
doReturn(java.util.List.of("Origin")).when(handler).getProxyServers();
|
||||
doReturn("Receiver").when(handler).getMultiProxyServerName();
|
||||
var outgoing = new java.util.ArrayList<JsonEnvelope>();
|
||||
org.mockito.Mockito.doAnswer(i -> {
|
||||
assertEquals(java.util.List.of("Origin"), i.getArgument(1));
|
||||
outgoing.add(i.getArgument(0)); return true;
|
||||
}).when(handler).sendMultiProxyEnvelopeAccepted(any(JsonEnvelope.class), any(java.util.Collection.class));
|
||||
Method receive = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class);
|
||||
receive.setAccessible(true);
|
||||
var request = new VotingPluginWire.SessionFenceRequest(UUID.randomUUID(), UUID.randomUUID(), "server1", "Origin");
|
||||
receive.invoke(handler, VotingPluginWire.sessionFenceRequest(request));
|
||||
assertEquals(1, outgoing.size());
|
||||
var reply = VotingPluginWire.readSessionFenceReply(outgoing.get(0)); assertNotNull(reply);
|
||||
assertEquals(request, reply.request());
|
||||
assertTrue(reply.fence().newer(com.bencodez.votingplugin.core.session.ProxyVoteSequence.nextVoteId()));
|
||||
receive.invoke(handler, VotingPluginWire.sessionFenceRequest(new VotingPluginWire.SessionFenceRequest(
|
||||
request.player(), request.nonce(), request.server(), "Unknown")));
|
||||
assertEquals(1, outgoing.size(), "unknown origin must not receive a fence");
|
||||
receive.invoke(handler, outgoing.get(0));
|
||||
assertEquals(0, backendReplies.size(), "reply for another proxy must not be relayed");
|
||||
var local = new VotingPluginWire.SessionFenceRequest(request.player(), request.nonce(), request.server(), "Receiver");
|
||||
receive.invoke(handler, VotingPluginWire.sessionFenceReply(local, reply.fence()));
|
||||
assertEquals(1, backendReplies.size());
|
||||
assertEquals(local, VotingPluginWire.readSessionFenceReply(backendReplies.get(0)).request());
|
||||
}
|
||||
|
||||
private void assertReceiverCooldownTime(long senderTime) throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
UUID voteId = UUID.randomUUID();
|
||||
VotingPluginProxyTestImpl proxy = spy(new VotingPluginProxyTestImpl());
|
||||
proxy.setDataFolder(temporaryDirectory.toFile());
|
||||
VoteCacheHandler voteCache = mock(VoteCacheHandler.class);
|
||||
when(voteCache.markMultiProxyVoteCompletedDurably(any())).thenReturn(true);
|
||||
when(voteCache.getTimeChangeQueue()).thenReturn(new ConcurrentLinkedQueue<>());
|
||||
doReturn(voteCache).when(proxy).getVoteCacheHandler();
|
||||
doNothing().when(proxy).addVoteParty();
|
||||
proxy.setMethod(BungeeMethod.PLUGINMESSAGING);
|
||||
proxy.setGlobalMessageProxyHandlerForTest(new GlobalMessageProxyHandler() {
|
||||
@Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { }
|
||||
});
|
||||
|
||||
// Install the production callback while transport startup is disabled. Only
|
||||
// the transport acknowledgement is stubbed; decoding and vote production run.
|
||||
proxy.loadMultiProxySupport();
|
||||
MultiProxyHandler handler = spy(proxy.getMultiProxyHandler());
|
||||
doNothing().when(handler).acknowledgeMultiProxyVote(any(), anyString());
|
||||
proxy.setMultiProxyHandler(handler);
|
||||
when(proxy.getConfig().getMultiProxySupport()).thenReturn(true);
|
||||
when(proxy.getConfig().getPrimaryServer()).thenReturn(false);
|
||||
when(proxy.getConfig().getOnlineMode()).thenReturn(true);
|
||||
when(proxy.getConfig().getBungeeManageTotals()).thenReturn(true);
|
||||
when(proxy.getConfig().getSendVotesToAllServers()).thenReturn(false);
|
||||
Field legacyServers = VotingPluginProxy.class.getDeclaredField("legacyVoteDeliveryServers");
|
||||
legacyServers.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.Set<String> legacy = (java.util.Set<String>) legacyServers.get(proxy);
|
||||
legacy.add("server1");
|
||||
legacy.add("server2");
|
||||
|
||||
Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class);
|
||||
handleEnvelope.setAccessible(true);
|
||||
long beforeReceive = System.currentTimeMillis();
|
||||
handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVote("Player", playerId.toString(),
|
||||
"Service", senderTime, true, true, "", voteId, false, false, 1, 1, "Primary", true));
|
||||
long afterReceive = System.currentTimeMillis();
|
||||
|
||||
JsonEnvelope emitted = proxy.getLastVoteEnvelope();
|
||||
assertNotNull(emitted, "the actual receiver must produce a backend vote");
|
||||
VotingPluginWire.Vote forwarded = VotingPluginWire.readVote(emitted);
|
||||
assertTrue(forwarded.time >= beforeReceive && forwarded.time <= afterReceive,
|
||||
"ordinary backend LastVotes time must use the receiver clock");
|
||||
assertEquals(voteId, forwarded.voteId);
|
||||
assertEquals(playerId.toString(), forwarded.uuid);
|
||||
assertTrue(forwarded.delayValidationKnown);
|
||||
assertTrue(forwarded.delayValidated);
|
||||
assertTrue(forwarded.queuedDeliveryKnown);
|
||||
|
||||
// Exercise the real backend router and event adapter. No guide manager is
|
||||
// installed: provenance must not change ordinary vote/cooldown accounting.
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class);
|
||||
UserManager users = mock(UserManager.class);
|
||||
VotingPluginUser user = mock(VotingPluginUser.class);
|
||||
Field userPlugin = VotingPluginUser.class.getDeclaredField("plugin");
|
||||
userPlugin.setAccessible(true);
|
||||
userPlugin.set(user, plugin);
|
||||
when(user.getPlayerName()).thenReturn("Player");
|
||||
doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(anyString(), anyLong(),
|
||||
any(VoteTotalsSnapshot.class), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(),
|
||||
anyBoolean(), anyBoolean(), anyBoolean(), any(UUID.class), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
BungeeSettings settings = mock(BungeeSettings.class);
|
||||
when(settings.isUseBungeecoord()).thenReturn(true);
|
||||
when(plugin.getBungeeSettings()).thenReturn(settings);
|
||||
when(plugin.getVotingPluginUserManager()).thenReturn(users);
|
||||
when(users.getVotingPluginUser(playerId, "Player")).thenReturn(user);
|
||||
ServerData serverData = mock(ServerData.class);
|
||||
when(plugin.getServerData()).thenReturn(serverData);
|
||||
VoteSiteManager sites = mock(VoteSiteManager.class);
|
||||
VoteSite site = mock(VoteSite.class);
|
||||
when(plugin.getVoteSiteManager()).thenReturn(sites);
|
||||
when(sites.getVoteSite("Service", true)).thenReturn(site);
|
||||
Server server = mock(Server.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
ProcessedVoteCache processed = mock(ProcessedVoteCache.class);
|
||||
when(processed.reserveWithOutcome(voteId)).thenReturn(ProcessedVoteCache.Reservation.RESERVED);
|
||||
when(processed.complete(voteId)).thenReturn(true);
|
||||
BackendProxyMessageRouter router = new BackendProxyMessageRouter(plugin,
|
||||
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
|
||||
mock(BackendVotePartySync.class), processed);
|
||||
// Check completion afterwards so a fixture error retains its original
|
||||
// exception instead of being replaced by the quarantine callback assertion.
|
||||
AtomicReference<BackendProxyMessageRouter.OrderedVoteOutcome> outcome = new AtomicReference<>();
|
||||
router.handleOrderedVote(emitted, outcome::set);
|
||||
assertEquals(BackendProxyMessageRouter.OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
ArgumentCaptor<PlayerVoteEvent> event = ArgumentCaptor.forClass(PlayerVoteEvent.class);
|
||||
verify(events).callEvent(event.capture());
|
||||
assertEquals(forwarded.time, event.getValue().getTime());
|
||||
assertEquals(voteId, event.getValue().getProxyVoteId());
|
||||
assertEquals(!forwarded.manageTotals, event.getValue().isAddTotals());
|
||||
assertTrue(event.getValue().isUnconfirmedProxySessionDelivery());
|
||||
assertTrue(event.getValue().isProxyDelayValidationKnown());
|
||||
assertTrue(event.getValue().isProxyQueueClassificationKnown());
|
||||
assertEquals(forwarded.queuedDelivery, event.getValue().isQueuedProxyVote());
|
||||
|
||||
assertNormalAccountingTime(event.getValue(), site, user);
|
||||
}
|
||||
|
||||
private void assertNormalAccountingTime(PlayerVoteEvent event, VoteSite site, VotingPluginUser user) {
|
||||
@SuppressWarnings("unchecked")
|
||||
SharedVoteProcessor.Operations<VoteSite, VotingPluginUser> operations = mock(SharedVoteProcessor.Operations.class);
|
||||
when(operations.enabled()).thenReturn(true);
|
||||
when(operations.incomingName()).thenReturn("Player");
|
||||
when(operations.properName("Player")).thenReturn("Player");
|
||||
when(operations.validate("Player", false)).thenReturn(
|
||||
new SharedVoteProcessor.Validation(true, "Player", "CACHE", "ok", false));
|
||||
when(operations.resolveSite()).thenReturn(site);
|
||||
when(operations.siteEnabled(site)).thenReturn(true);
|
||||
when(operations.resolveUser("Player")).thenReturn(user);
|
||||
when(operations.proxyVote()).thenReturn(event.isBungee());
|
||||
when(operations.forceProxyRouting()).thenReturn(event.isForceBungee());
|
||||
when(operations.wasOnline()).thenReturn(event.isWasOnline());
|
||||
when(operations.realVote()).thenReturn(event.isRealVote());
|
||||
when(operations.addTotals()).thenReturn(event.isAddTotals());
|
||||
when(operations.proxyVoteId()).thenReturn(event.getProxyVoteId());
|
||||
when(operations.proxyQueueClassificationKnown()).thenReturn(event.isProxyQueueClassificationKnown());
|
||||
when(operations.proxyDelayValidationKnown()).thenReturn(event.isProxyDelayValidationKnown());
|
||||
when(operations.incomingTime()).thenReturn(event.getTime());
|
||||
when(operations.countingPolicy()).thenReturn(new SharedVotePolicy(false, true, false, false, false));
|
||||
SharedVoteProcessor.process(operations);
|
||||
verify(operations).setTime(user, site, event.getTime());
|
||||
verify(operations).cooldown(user, site);
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -857,7 +857,7 @@ class BackendProxyMessageRouterTest {
|
||||
var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(user, plugin);
|
||||
org.mockito.Mockito.doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(
|
||||
any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(),
|
||||
anyBoolean(), anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
var manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(manager);
|
||||
@@ -897,7 +897,7 @@ class BackendProxyMessageRouterTest {
|
||||
.thenReturn(user);
|
||||
doThrow(new IllegalStateException("partial reward")).when(user).bungeeVotePluginMessagingEvent(
|
||||
any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), any(), anyBoolean(), eq(true), anyBoolean());
|
||||
BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin,
|
||||
mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class),
|
||||
mock(BackendVotePartySync.class), cache);
|
||||
@@ -943,7 +943,7 @@ class BackendProxyMessageRouterTest {
|
||||
assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get());
|
||||
verify(cache, times(2)).complete(voteId);
|
||||
verify(user, times(1)).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false));
|
||||
anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false), eq(true), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -964,7 +964,7 @@ class BackendProxyMessageRouterTest {
|
||||
LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), ignored -> { });
|
||||
|
||||
verify(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), eq(false), eq(false), eq(true), eq(voteId), eq(false));
|
||||
anyBoolean(), anyInt(), eq(false), eq(false), eq(true), eq(voteId), eq(false), eq(true), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -984,7 +984,7 @@ class BackendProxyMessageRouterTest {
|
||||
LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), ignored -> { });
|
||||
|
||||
verify(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true));
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true), eq(true), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1006,7 +1006,7 @@ class BackendProxyMessageRouterTest {
|
||||
assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get());
|
||||
verify(cache, never()).complete(voteId);
|
||||
verify(user, never()).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), eq(true), anyBoolean());
|
||||
verify(cache).cancelReservation(voteId);
|
||||
}
|
||||
|
||||
@@ -1026,7 +1026,7 @@ class BackendProxyMessageRouterTest {
|
||||
|
||||
assertEquals(OrderedVoteOutcome.RETRY, outcome.get());
|
||||
verify(user, never()).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), eq(true), anyBoolean());
|
||||
verify(cache, never()).complete(voteId);
|
||||
}
|
||||
|
||||
@@ -1249,7 +1249,7 @@ class BackendProxyMessageRouterTest {
|
||||
voteRouter.handleOrderedVote(queued, ignored -> { });
|
||||
|
||||
verify(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true));
|
||||
anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true), eq(true), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -266,7 +266,7 @@ class BackendProxyVoteDelayReplayTest {
|
||||
pluginField.set(user, plugin);
|
||||
doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(VoteTotalsSnapshot.class),
|
||||
anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(), anyBoolean(),
|
||||
anyBoolean(), any(), anyBoolean());
|
||||
anyBoolean(), any(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
PlayerVoteListener listener = new PlayerVoteListener(plugin);
|
||||
var eventManager = plugin.getServer().getPluginManager();
|
||||
doAnswer(call -> {
|
||||
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
package com.bencodez.votingplugin.tests.commands;
|
||||
|
||||
import com.bencodez.votingplugin.commands.CommandLoader;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.config.Config;
|
||||
|
||||
class GuidedSessionCommandRegistrationTest {
|
||||
@Test void disabledGuidePreservesTheExistingCustomSessionHandler() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.register();
|
||||
assertEquals(1, f.sessionHandlers().size());
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
assertFalse(f.plugin.getVoteCommand().stream().anyMatch(h -> h.getArgs().length == 2
|
||||
&& h.getArgs()[0].equalsIgnoreCase("Session")));
|
||||
}
|
||||
|
||||
@Test void enabledGuideReservesSessionAndAllNavigationHandlers() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
assertEquals(8, f.sessionHandlers().size()); // seven built-ins plus the owner custom command
|
||||
assertEquals("Guided voting session", f.sessionHandlers().get(0).getHelpMessage());
|
||||
for (String action : List.of("next", "previous", "skip", "check", "finish", "restart")) {
|
||||
assertTrue(f.sessionHandlers().stream().anyMatch(h -> h.getArgs().length == 2
|
||||
&& h.getArgs()[1].equals(action)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disablingAndReloadingRestoresCustomRoutingWithoutDuplicateHandlers() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertEquals(1, f.sessionHandlers().size());
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
}
|
||||
|
||||
@Test void enablingOnReloadAddsOnlyGuideHandlersAndPreservesProductionIdentity() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.register();
|
||||
var production = new ArrayList<>(f.plugin.getVoteCommand());
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertEquals(production.size() + 7, f.plugin.getVoteCommand().size());
|
||||
for (CommandHandler original : production) assertTrue(f.plugin.getVoteCommand().contains(original));
|
||||
assertEquals("Guided voting session", f.sessionHandlers().get(0).getHelpMessage());
|
||||
}
|
||||
|
||||
@Test void reloadHonorsDisabledGuidePermissionWithoutRemovingCustomHandler() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
when(f.config.getDisabledCommands()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertEquals(1, f.sessionHandlers().size());
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
}
|
||||
|
||||
@Test void permissionLookupTracksEnableDisableAndRetainsUnrelatedCommands() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
org.bukkit.entity.Player owner = mock(org.bukkit.entity.Player.class);
|
||||
when(owner.hasPermission("owner.session")).thenReturn(true);
|
||||
org.bukkit.entity.Player voter = mock(org.bukkit.entity.Player.class);
|
||||
when(voter.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
var index = new java.util.HashMap<String, CommandHandler>();
|
||||
CommandHandler custom = f.sessionHandlers().get(0);
|
||||
CommandHandler unrelated = f.plugin.getVoteCommand().get(0);
|
||||
index.put("voteSession", custom);
|
||||
index.put("voteHelp", unrelated);
|
||||
f.loader.setCommands(index);
|
||||
assertTrue(f.loader.hasPermission(owner, "voteSession"));
|
||||
assertFalse(f.loader.hasPermission(voter, "voteSession"));
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.hasPermission(owner, "voteSession"));
|
||||
assertTrue(f.loader.hasPermission(voter, "votingplugin:voteSession"));
|
||||
assertSame(unrelated, index.get("voteHelp"));
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertSame(custom, index.get("voteSession"));
|
||||
assertTrue(f.loader.hasPermission(owner, "voteSession"));
|
||||
assertFalse(f.loader.hasPermission(voter, "voteSession"));
|
||||
}
|
||||
|
||||
@Test void initialAliasIndexUsesGuideRatherThanCustomOrNavigationHandler() throws Exception {
|
||||
assertInitialAliasIndexUsesGuide("vote");
|
||||
}
|
||||
|
||||
@Test void renamedPlayerRootKeepsTheGuidedSessionPermissionIndex() throws Exception {
|
||||
assertInitialAliasIndexUsesGuide("ballot");
|
||||
}
|
||||
|
||||
public interface CommandMapAccess { org.bukkit.command.CommandMap getCommandMap(); }
|
||||
|
||||
private void assertInitialAliasIndexUsesGuide(String commandName) throws Exception {
|
||||
Fixture f = new Fixture(commandName);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
when(f.plugin.getAdminVoteCommand()).thenReturn(new ArrayList<>());
|
||||
when(f.plugin.getDescription().getCommands()).thenReturn(java.util.Map.of());
|
||||
when(f.plugin.getDescription().getName()).thenReturn("VotingPlugin");
|
||||
when(f.plugin.getName()).thenReturn("VotingPlugin");
|
||||
org.bukkit.Server server = mock(org.bukkit.Server.class, withSettings().extraInterfaces(CommandMapAccess.class));
|
||||
var map = new org.bukkit.command.SimpleCommandMap(server);
|
||||
when(((CommandMapAccess) server).getCommandMap()).thenReturn(map);
|
||||
when(f.plugin.getServer()).thenReturn(server);
|
||||
var constructor = org.bukkit.command.PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
|
||||
constructor.setAccessible(true);
|
||||
var root = constructor.newInstance("vote", f.plugin);
|
||||
map.register("votingplugin", root);
|
||||
when(f.plugin.getCommand("vote")).thenReturn(root);
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
f.loader.loadAliases();
|
||||
}
|
||||
assertSame(root, map.getCommand(commandName));
|
||||
assertTrue(f.loader.isVoteCommandEnabled());
|
||||
CommandHandler indexed = f.loader.getCommands().get("voteSession");
|
||||
assertEquals("Guided voting session", indexed.getHelpMessage());
|
||||
assertArrayEquals(new String[] {"Session"}, indexed.getArgs());
|
||||
org.bukkit.entity.Player voter = mock(org.bukkit.entity.Player.class);
|
||||
when(voter.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.hasPermission(voter, "voteSession"));
|
||||
}
|
||||
|
||||
@Test void enablingOnReloadHonorsDisabledDefaultPermission() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
when(f.plugin.getOptions().isMultiplePermissionChecks()).thenReturn(true);
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.setCommands(new java.util.HashMap<>());
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
assertFalse(f.loader.hasPermission(player, "voteSession"));
|
||||
assertTrue(f.sessionHandlers().stream().filter(h -> h.getHelpMessage().equals("Guided voting session"))
|
||||
.allMatch(h -> h.getPerm().equals("VotingPlugin.Commands.Vote.Session")));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.hasPermission(player, "voteSession"));
|
||||
}
|
||||
|
||||
@Test void guidedSessionUseRequiresTheRegisteredRootHandlerPolicy() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
when(f.plugin.getOptions().isMultiplePermissionChecks()).thenReturn(true);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
assertTrue(f.loader.canUseGuidedSession(player));
|
||||
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.canUseGuidedSession(player));
|
||||
|
||||
when(f.config.getDisabledCommands()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
}
|
||||
|
||||
@Test void guidedSessionUseRejectsMultiplePermissionFallbackAndCustomOnlyOrDisabledGuide() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.register();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.hasPermission("VotingPlugin.Player")).thenReturn(true);
|
||||
when(f.plugin.getOptions().isMultiplePermissionChecks()).thenReturn(false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
when(player.hasPermission("VotingPlugin.Commands.Vote.Session")).thenReturn(true);
|
||||
assertTrue(f.loader.canUseGuidedSession(player));
|
||||
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
when(f.config.getDisabledCommands()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
assertFalse(f.loader.canUseGuidedSession(player));
|
||||
}
|
||||
|
||||
@Test void reloadReconcilesOnlyGuidePermissionInheritanceAndInvalidatesStaleTasks() throws Exception {
|
||||
Fixture f = new Fixture(); f.register();
|
||||
java.util.List<Runnable> tasks = new ArrayList<>();
|
||||
var scheduler = f.plugin.getBukkitScheduler();
|
||||
doAnswer(call -> { tasks.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(f.plugin), any(Runnable.class));
|
||||
var manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(manager);
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
var parent = new org.bukkit.permissions.Permission("VotingPlugin.Player");
|
||||
parent.getChildren().put("owner.unrelated", true);
|
||||
when(manager.getPermission("VotingPlugin.Player")).thenReturn(parent);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertEquals(true, parent.getChildren().get("VotingPlugin.Commands.Vote.Session"));
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertFalse(parent.getChildren().containsKey("VotingPlugin.Commands.Vote.Session"));
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>());
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertEquals(true, parent.getChildren().get("VotingPlugin.Commands.Vote.Session"));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
Runnable staleEnable = tasks.remove(0);
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
Runnable staleDisable = tasks.remove(0);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
staleEnable.run();
|
||||
staleDisable.run();
|
||||
assertFalse(parent.getChildren().containsKey("VotingPlugin.Commands.Vote.Session"));
|
||||
assertEquals(true, parent.getChildren().get("owner.unrelated"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disablingGuideRetainsCustomSessionInheritanceUnlessOwnerDisablesDefaultPermission() throws Exception {
|
||||
Fixture f = new Fixture();
|
||||
f.data.set("CustomCommands.owner-session.Permission", "VotingPlugin.Commands.Vote.Session|VotingPlugin.Player");
|
||||
f.register();
|
||||
java.util.List<Runnable> tasks = new ArrayList<>();
|
||||
var scheduler = f.plugin.getBukkitScheduler();
|
||||
doAnswer(call -> { tasks.add(call.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(f.plugin), any(Runnable.class));
|
||||
var manager = mock(org.bukkit.plugin.PluginManager.class);
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
var server = mock(org.bukkit.Server.class);
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(manager);
|
||||
var parent = new org.bukkit.permissions.Permission("VotingPlugin.Player");
|
||||
when(manager.getPermission("VotingPlugin.Player")).thenReturn(parent);
|
||||
parent.getChildren().put("VotingPlugin.Commands.Vote.Session", true);
|
||||
f.data.set("GuidedVotingSession.Enabled", true);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
f.data.set("GuidedVotingSession.Enabled", false);
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertEquals(true, parent.getChildren().get("VotingPlugin.Commands.Vote.Session"));
|
||||
assertEquals("existing owner command", f.sessionHandlers().get(0).getHelpMessage());
|
||||
when(f.config.getDisabledDefaultPermissions()).thenReturn(new ArrayList<>(List.of("VotingPlugin.Commands.Vote.Session")));
|
||||
f.loader.refreshGuidedSessionCommands();
|
||||
tasks.remove(0).run();
|
||||
assertFalse(parent.getChildren().containsKey("VotingPlugin.Commands.Vote.Session"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final Config config = mock(Config.class);
|
||||
final CommandLoader loader;
|
||||
final YamlConfiguration data = new YamlConfiguration();
|
||||
final AtomicReference<ArrayList<CommandHandler>> handlers = new AtomicReference<>();
|
||||
Fixture() { this("vote"); }
|
||||
Fixture(String commandName) {
|
||||
when(plugin.getConfigFile()).thenReturn(config);
|
||||
when(config.isVoteCommandEnabled()).thenReturn(true);
|
||||
when(config.getVoteCommandName()).thenReturn(commandName);
|
||||
loader = new CommandLoader(plugin);
|
||||
when(config.getData()).thenReturn(data);
|
||||
when(config.getDisabledCommands()).thenReturn(new ArrayList<>());
|
||||
when(config.isAddCustomCommands()).thenReturn(true);
|
||||
when(config.getCustomCommands()).thenReturn(Set.of("owner-session"));
|
||||
var custom = data.createSection("CustomCommands.owner-session");
|
||||
custom.set("Args", new ArrayList<>(List.of("Session")));
|
||||
custom.set("HelpMessage", "existing owner command");
|
||||
custom.set("Permission", "owner.session");
|
||||
when(config.getCustomCommands("owner-session")).thenReturn(custom);
|
||||
doAnswer(call -> { handlers.set(call.getArgument(0)); return null; })
|
||||
.when(plugin).setVoteCommand(any(ArrayList.class));
|
||||
when(plugin.getVoteCommand()).thenAnswer(call -> handlers.get());
|
||||
}
|
||||
void register() throws Exception {
|
||||
Method load = CommandLoader.class.getDeclaredMethod("loadVoteCommand");
|
||||
load.setAccessible(true);
|
||||
try (var singleton = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) {
|
||||
var core = mock(com.bencodez.advancedcore.command.CommandLoader.class);
|
||||
singleton.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(core);
|
||||
when(core.getBasicCommands("VotingPlugin")).thenReturn(new ArrayList<>());
|
||||
load.invoke(loader);
|
||||
}
|
||||
}
|
||||
List<CommandHandler> sessionHandlers() {
|
||||
return plugin.getVoteCommand().stream().filter(h -> h.getArgs().length > 0
|
||||
&& h.getArgs()[0].equalsIgnoreCase("Session")).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package com.bencodez.votingplugin.tests.core.session;
|
||||
|
||||
import com.bencodez.votingplugin.core.session.GuidedVoteSession;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GuidedVoteSessionTest {
|
||||
@Test void guideCreationAndIngressShareAStrictSequenceWithoutDependingOnClockResolution() {
|
||||
var old = new com.bencodez.votingplugin.events.PlayerVoteEvent(null, "Alex", "a", true);
|
||||
var session = new GuidedVoteSession(100); session.refresh(List.of(site("a", true)));
|
||||
session.acceptedObserved("a", UUID.randomUUID(), old.getBackendObservationOrder()); assertEquals(0, session.view("check").received());
|
||||
var fresh = new com.bencodez.votingplugin.events.PlayerVoteEvent(null, "Alex", "a", true);
|
||||
assertTrue(fresh.getBackendObservationOrder() > old.getBackendObservationOrder());
|
||||
session.acceptedObserved("a", UUID.randomUUID(), fresh.getBackendObservationOrder()); assertEquals(1, session.view("check").received());
|
||||
}
|
||||
@Test void concurrentIngressOrdersAreUniqueAndStrictWithinEachProducer() throws Exception {
|
||||
var orders = java.util.concurrent.ConcurrentHashMap.<Long>newKeySet();
|
||||
try (var pool = java.util.concurrent.Executors.newFixedThreadPool(8)) {
|
||||
var futures = new java.util.ArrayList<java.util.concurrent.Future<?>>();
|
||||
for (int thread = 0; thread < 8; thread++) futures.add(pool.submit(() -> {
|
||||
long previous = 0;
|
||||
for (int i = 0; i < 128; i++) {
|
||||
long order = new com.bencodez.votingplugin.events.PlayerVoteEvent(null, "Alex", "a", true).getBackendObservationOrder();
|
||||
assertTrue(order > previous); assertTrue(orders.add(order)); previous = order;
|
||||
}
|
||||
}));
|
||||
for (var future : futures) future.get();
|
||||
}
|
||||
assertEquals(1024, orders.size());
|
||||
}
|
||||
private GuidedVoteSession.Site site(String key, boolean eligible) {
|
||||
return new GuidedVoteSession.Site(key, key, "https://example.org/" + key, eligible, 0);
|
||||
}
|
||||
@Test void onlyRealNewMatchingAcceptedVotesConfirmSites() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true)));
|
||||
UUID id = UUID.randomUUID();
|
||||
s.accepted("unrelated", id, 101, true, false);
|
||||
s.accepted("a", id, 99, true, false);
|
||||
s.accepted("a", id, 101, false, false);
|
||||
s.accepted("a", id, 101, true, true);
|
||||
assertEquals(0, s.view("check").received());
|
||||
s.accepted("a", id, 101, true, false);
|
||||
s.accepted("a", id, 101, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 100, true, false);
|
||||
assertEquals(1, s.view("check").received());
|
||||
assertFalse(s.view("check").complete());
|
||||
}
|
||||
@Test void delayedNewVoteAndOutOfOrderDeliveryDoNotRegressProgress() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true)));
|
||||
s.accepted("a", UUID.randomUUID(), 200, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 150, true, false);
|
||||
assertTrue(s.view("check").complete());
|
||||
}
|
||||
@Test void permissionCooldownChangesAndReopeningRetainObservedProgress() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true), site("cooldown", false)));
|
||||
s.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
s.refresh(List.of(site("a", false)));
|
||||
var v = s.view("check");
|
||||
assertEquals(GuidedVoteSession.Status.RECEIVED, v.entries().get(0).status());
|
||||
assertEquals(GuidedVoteSession.Status.UNAVAILABLE, v.entries().get(1).status());
|
||||
assertEquals(2, v.entries().size());
|
||||
assertEquals(1, s.view("check").received());
|
||||
}
|
||||
@Test void notificationBeforeInitialSnapshotIsRetained() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 101)));
|
||||
assertTrue(s.view("check").complete());
|
||||
}
|
||||
@Test void preSessionIngressProcessedLaterCannotAdmitAnInitiallyCoolingSite() {
|
||||
for (long order : new long[] {99, 100}) {
|
||||
var s = new GuidedVoteSession(100, 100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.acceptedObserved("a", UUID.randomUUID(), order);
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 101)));
|
||||
assertTrue(s.view("check").entries().isEmpty(), "processing time cannot replace ingress order " + order);
|
||||
assertEquals(0, s.view("check").received());
|
||||
assertFalse(s.view("check").complete());
|
||||
}
|
||||
}
|
||||
@Test void freshObservedReceiptBeforeInitialCooldownSampleAdmitsItsBoundSite() {
|
||||
var s = new GuidedVoteSession(100, 100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.acceptedObserved("a", UUID.randomUUID(), 101);
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 100)));
|
||||
assertEquals(1, s.view("check").entries().size());
|
||||
assertTrue(s.view("check").complete());
|
||||
}
|
||||
@Test void olderCachedVoteCannotEraseAnEarlyConfirmedReceipt() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.bindCandidates(List.of(site("a", true)));
|
||||
s.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 99, true, false);
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 99)));
|
||||
assertTrue(s.view("check").complete());
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 99)));
|
||||
assertEquals(1, s.view("check").received());
|
||||
}
|
||||
@Test void absentFirstSampleCannotDeleteConfirmedProgressOrOccurrenceIdentity() {
|
||||
var s = new GuidedVoteSession(100); UUID occurrence = UUID.randomUUID();
|
||||
s.bindCandidates(List.of(site("a", true), site("b", true)));
|
||||
s.accepted("a", occurrence, 101, true, false);
|
||||
s.refresh(List.of(site("b", true)));
|
||||
assertEquals(1, s.view("check").received());
|
||||
s.accepted("b", occurrence, 102, true, false);
|
||||
s.refresh(List.of(site("a", false), site("b", true)));
|
||||
assertEquals(1, s.view("check").received());
|
||||
assertEquals(GuidedVoteSession.Status.RECEIVED, s.view("check").entries().stream()
|
||||
.filter(e -> e.site().key().equals("a")).findFirst().orElseThrow().status());
|
||||
}
|
||||
@Test void receiptAfterFirstIneligibleSampleRestoresBoundCandidate() {
|
||||
var s = new GuidedVoteSession(100, 100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 101)));
|
||||
assertTrue(s.view("check").entries().isEmpty());
|
||||
for (int i = 0; i < 101; i++) s.acceptedObserved("unrelated" + i, UUID.randomUUID(), 101);
|
||||
s.acceptedObserved("a", UUID.randomUUID(), 101);
|
||||
assertTrue(s.view("check").complete());
|
||||
assertEquals(1, s.view("check").entries().size());
|
||||
}
|
||||
@Test void originalTimestampReceiptAfterFirstIneligibleSampleRestoresOnlyItsBoundCandidate() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.bindCandidates(List.of(site("a", false), site("b", false)));
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 101), site("b", false)));
|
||||
assertTrue(s.view("check").entries().isEmpty());
|
||||
UUID occurrence = UUID.randomUUID();
|
||||
s.accepted("a", occurrence, 99, true, false);
|
||||
s.accepted("a", occurrence, 100, true, false);
|
||||
s.accepted("a", null, 101, true, false);
|
||||
s.accepted("a", occurrence, 101, false, false);
|
||||
s.accepted("a", occurrence, 101, true, true);
|
||||
s.accepted("unrelated", occurrence, 101, true, false);
|
||||
assertTrue(s.view("check").entries().isEmpty());
|
||||
s.accepted("a", occurrence, 101, true, false);
|
||||
s.accepted("a", occurrence, 101, true, false);
|
||||
s.accepted("b", occurrence, 102, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 99, true, false);
|
||||
assertTrue(s.view("check").complete());
|
||||
assertEquals(1, s.view("check").entries().size());
|
||||
assertEquals("a", s.view("check").current().site().key());
|
||||
}
|
||||
@Test void initiallyCoolingDownSiteCannotJoinFromPreSessionTimestampReceipts() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.bindCandidates(List.of(site("a", false)));
|
||||
s.refresh(List.of(new GuidedVoteSession.Site("a", "a", "https://example.org/a", false, 99)));
|
||||
s.accepted("a", UUID.randomUUID(), 99, true, false);
|
||||
s.accepted("a", UUID.randomUUID(), 100, true, false);
|
||||
assertTrue(s.view("check").entries().isEmpty());
|
||||
}
|
||||
@Test void oneCanonicalOccurrenceCannotConfirmTwoSites() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true)));
|
||||
UUID occurrence = UUID.randomUUID();
|
||||
s.accepted("a", occurrence, 101, true, false);
|
||||
s.accepted("b", occurrence, 102, true, false);
|
||||
assertEquals(1, s.view("check").received());
|
||||
}
|
||||
@Test void emptyAndFinishedSessionsDoNotInventCompletion() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", false)));
|
||||
assertNull(s.view("check").current());
|
||||
assertFalse(s.view("finish").complete());
|
||||
assertTrue(s.view("check").finished());
|
||||
}
|
||||
@Test void navigationAndSkipDoNotCountVotes() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true)));
|
||||
assertEquals("b", s.view("next").current().site().key());
|
||||
assertEquals("a", s.view("previous").current().site().key());
|
||||
assertEquals("b", s.view("skip").current().site().key());
|
||||
assertEquals(GuidedVoteSession.Status.SKIPPED, s.view("check").entries().get(0).status());
|
||||
assertEquals(0, s.view("finish").received());
|
||||
}
|
||||
@Test void automaticSelectionStartsAtFirstEligibleSiteAndAdvancesReceivedCurrent() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true), site("c", true)));
|
||||
assertEquals("a", s.view("check").current().site().key());
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
s.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
assertTrue(s.selectNextAwaiting());
|
||||
assertEquals("b", s.view("check").current().site().key());
|
||||
}
|
||||
@Test void unrelatedReceiptDoesNotMoveCurrentAndEligibilityChangesSkipBlockedSites() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true), site("c", true)));
|
||||
s.accepted("c", UUID.randomUUID(), 101, true, false);
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
assertEquals("a", s.view("check").current().site().key());
|
||||
s.accepted("a", UUID.randomUUID(), 102, true, false);
|
||||
assertTrue(s.selectNextAwaiting());
|
||||
assertEquals("b", s.view("check").current().site().key());
|
||||
s.refresh(List.of(site("a", false), site("b", true), site("c", true)));
|
||||
s.view("previous");
|
||||
assertTrue(s.selectNextAwaiting());
|
||||
assertEquals("b", s.view("check").current().site().key());
|
||||
}
|
||||
@Test void skippedReceivedUnavailableAndDuplicateDeliveryCannotStallOrRepeatAutoStep() {
|
||||
var s = new GuidedVoteSession(100);
|
||||
s.refresh(List.of(site("a", true), site("b", true), site("c", true)));
|
||||
assertEquals("b", s.view("skip").current().site().key());
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
s.accepted("b", UUID.randomUUID(), 101, true, false);
|
||||
assertTrue(s.selectNextAwaiting());
|
||||
assertEquals("c", s.view("check").current().site().key());
|
||||
s.accepted("c", UUID.randomUUID(), 102, true, false);
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
assertEquals("c", s.view("check").current().site().key());
|
||||
UUID duplicate = UUID.randomUUID();
|
||||
s.accepted("c", duplicate, 103, true, false);
|
||||
s.accepted("c", duplicate, 104, true, false);
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
assertFalse(s.selectNextAwaiting());
|
||||
}
|
||||
@Test void automaticSelectionHandlesEmptyAndAllDoneSessions() {
|
||||
var empty = new GuidedVoteSession(100);
|
||||
empty.refresh(List.of(site("a", false)));
|
||||
assertFalse(empty.selectNextAwaiting());
|
||||
var done = new GuidedVoteSession(100);
|
||||
done.refresh(List.of(site("a", true)));
|
||||
done.accepted("a", UUID.randomUUID(), 101, true, false);
|
||||
assertFalse(done.selectNextAwaiting());
|
||||
assertTrue(done.view("check").complete());
|
||||
var finished = new GuidedVoteSession(100);
|
||||
finished.refresh(List.of(site("a", true), site("b", true)));
|
||||
finished.view("finish");
|
||||
assertFalse(finished.selectNextAwaiting());
|
||||
}
|
||||
@Test void urlValidationUsesExactSelectedUrlWithoutUnsafeSchemes() {
|
||||
assertEquals("https://example.org/a", GuidedVoteSession.httpUrl("https://example.org/a"));
|
||||
String authenticatedUrl = "https://" + UUID.randomUUID() + ":" + UUID.randomUUID() + "@example.org";
|
||||
for (String bad : List.of("", "javascript:alert(1)", "https://", authenticatedUrl, "not a url"))
|
||||
assertNull(GuidedVoteSession.httpUrl(bad));
|
||||
assertNull(GuidedVoteSession.httpUrl(null));
|
||||
assertEquals("https://example.org/vote", GuidedVoteSession.httpUrl("[Text=\"Vote here\",url=\"https://example.org/vote\"]"));
|
||||
assertNull(GuidedVoteSession.httpUrl("[Text=\"Vote here\",url=\"javascript:alert(1)\"]"));
|
||||
assertNull(GuidedVoteSession.httpUrl("[Text=\"Vote here\",url=\"" + authenticatedUrl + "\"]"));
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.bencodez.votingplugin.tests.core.session;
|
||||
|
||||
import com.bencodez.votingplugin.core.session.GuidedVoteSession;
|
||||
import com.bencodez.votingplugin.core.session.ProxyVoteSequence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
|
||||
class ProxyVoteSequenceTest {
|
||||
private GuidedVoteSession session() {
|
||||
var session = new GuidedVoteSession(Long.MAX_VALUE, Long.MAX_VALUE);
|
||||
session.bindCandidates(List.of(new GuidedVoteSession.Site("ore", "Site", "https://example.org", true, 0)));
|
||||
session.refresh(List.of(new GuidedVoteSession.Site("ore", "Site", "https://example.org", true, 0)));
|
||||
return session;
|
||||
}
|
||||
@Test void oldInFlightAndNewDelayedVotesUseOriginalOrderNotClocksOrDeliveryOrder() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID());
|
||||
UUID old = source.next(); var fence = source.fence(); UUID fresh = source.next();
|
||||
var guide = session(); guide.bindProxyFence(fence);
|
||||
guide.acceptedProxy("ore", old); assertEquals(0, guide.view("").received());
|
||||
guide.acceptedProxy("ore", fresh); guide.acceptedProxy("ore", fresh);
|
||||
assertEquals(1, guide.view("").received());
|
||||
assertEquals(8, fresh.version()); assertEquals(2, fresh.variant());
|
||||
assertEquals(fresh, UUID.fromString(fresh.toString()));
|
||||
}
|
||||
@Test void newReceiptMayOvertakeItsHandshakeAndWrongSourceCannotConfirm() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID()); var fence = source.fence();
|
||||
UUID fresh = source.next(); var guide = session(); guide.acceptedProxy("ore", fresh);
|
||||
guide.bindProxyFence(new ProxyVoteSequence(UUID.randomUUID()).fence());
|
||||
assertEquals(0, guide.view("").received());
|
||||
guide.bindProxyFence(fence); assertEquals(1, guide.view("").received());
|
||||
}
|
||||
@Test void outOfOrderNewVotesAndSiteIdentityDoNotDoubleCount() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID()); var fence = source.fence();
|
||||
UUID first = source.next(), later = source.next(); var guide = session(); guide.bindProxyFence(fence);
|
||||
guide.acceptedProxy("unrelated", later); assertEquals(0, guide.view("").received());
|
||||
guide.acceptedProxy("ore", later); guide.acceptedProxy("ore", first);
|
||||
assertEquals(1, guide.view("").received());
|
||||
}
|
||||
@Test void restartIncarnationAndLegacyIdsStayUnconfirmed() {
|
||||
var oldSource = new ProxyVoteSequence(UUID.randomUUID()); UUID old = oldSource.next();
|
||||
var current = new ProxyVoteSequence(UUID.randomUUID()); var guide = session(); guide.bindProxyFence(current.fence());
|
||||
guide.acceptedProxy("ore", old); guide.acceptedProxy("ore", UUID.randomUUID());
|
||||
assertEquals(0, guide.view("").received()); guide.acceptedProxy("ore", current.next());
|
||||
assertEquals(1, guide.view("").received());
|
||||
}
|
||||
@Test void wireNegotiationPreservesFullPrecisionAndIgnoresUnknownVersion() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID()); source.next();
|
||||
var request = new VotingPluginWire.SessionFenceRequest(UUID.randomUUID(), UUID.randomUUID(), "backend", "origin");
|
||||
var envelope = VotingPluginWire.sessionFenceReply(request, source.fence());
|
||||
var decoded = VotingPluginWire.readSessionFenceReply(envelope);
|
||||
assertNotNull(decoded); assertEquals(request, decoded.request()); assertEquals(source.fence(), decoded.fence());
|
||||
var unknown = com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder(envelope.getSubChannel())
|
||||
.schema(envelope.getSchema());
|
||||
envelope.getFields().forEach(unknown::put); unknown.put(VotingPluginWire.K_SESSION_FENCE_VERSION, 2);
|
||||
assertNull(VotingPluginWire.readSessionFenceReply(unknown.build()));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProxyVoteSequence.Fence(UUID.randomUUID(), 0));
|
||||
}
|
||||
@Test void statusReplyDoesNotAdvanceAnExistingSessionFence() {
|
||||
var source = new ProxyVoteSequence(UUID.randomUUID()); var guide = session(); guide.bindProxyFence(source.fence());
|
||||
UUID occurrence = source.next(); guide.bindProxyFence(source.fence()); guide.acceptedProxy("ore", occurrence);
|
||||
assertEquals(1, guide.view("").received());
|
||||
}
|
||||
}
|
||||
+5
@@ -5,10 +5,15 @@ import com.bencodez.votingplugin.listeners.*;
|
||||
import com.bencodez.votingplugin.listeners.PlayerVoteListener.*;
|
||||
import java.util.UUID;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
|
||||
/** Typed test-only access to package-private PlayerVoteListener seams. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public final class PlayerVoteListenerAccess {
|
||||
private PlayerVoteListenerAccess() { }
|
||||
public static void copySessionObservation(PlayerPostVoteEvent post, PlayerVoteEvent input) {
|
||||
ReflectiveAccess.call("com.bencodez.votingplugin.listeners.PlayerVoteListener", null,
|
||||
"copySessionObservation", new String[]{"PlayerPostVoteEvent", "PlayerVoteEvent"}, new Object[]{post, input});
|
||||
}
|
||||
public static UUID resolveProxyVoteId(PlayerVoteEvent event) { return (UUID) ReflectiveAccess.call("com.bencodez.votingplugin.listeners.PlayerVoteListener", null, "resolveProxyVoteId", new String[]{"PlayerVoteEvent"}, new Object[]{event}); }
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.bencodez.votingplugin.tests.listeners;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
class SessionObservationMetadataTest {
|
||||
@Test void acceptedAdapterForwardsLocalOrderAndProxyClassificationWithoutChangingOccurrenceTime() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
input.setBungee(true); input.setProxyQueueClassificationKnown(true); input.setQueuedProxyVote(true);
|
||||
input.setUnconfirmedProxySessionDelivery(true);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, 1, false, "a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertEquals(input.getBackendObservationOrder(), post.getBackendObservationOrder());
|
||||
assertFalse(post.isLiveLocalSessionDelivery()); assertTrue(post.isProxySessionDelivery()); assertFalse(post.isBungee()); assertTrue(post.isProxyQueueClassificationKnown()); assertTrue(post.isQueuedProxyVote());
|
||||
assertEquals(1, post.getVoteTime()); assertTrue(post.isUnconfirmedProxySessionDelivery());
|
||||
}
|
||||
@Test void freshLocalIngressIsClassifiedWithoutReclassifyingHistoricalLocalDelivery() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, 100, true, "a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertTrue(post.isLiveLocalSessionDelivery()); assertFalse(post.isProxySessionDelivery());
|
||||
assertEquals(input.getBackendObservationOrder(), post.getBackendObservationOrder());
|
||||
assertEquals(100, post.getVoteTime()); // Cached rewards do not imply a historical local occurrence.
|
||||
input.setTime(99);
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertFalse(post.isLiveLocalSessionDelivery()); assertEquals(100, post.getVoteTime());
|
||||
}
|
||||
@Test void recoveredZeroTimestampLocalVoteDoesNotInventCurrentObservationOrder() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
input.setBackendObservationOrder(0L);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, System.currentTimeMillis(), false,
|
||||
"a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post,input);
|
||||
assertFalse(post.isLiveLocalSessionDelivery()); assertTrue(post.isUnconfirmedLocalSessionDelivery());
|
||||
assertEquals(0,post.getBackendObservationOrder()); assertTrue(post.getVoteTime()>0);
|
||||
}
|
||||
@Test void timeQueuedLiveVoteRetainsOrderDespiteHistoricalWallClock() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
input.setTime(1L); input.setBackendObservationOrder(42L); input.setTimeQueuedLocalSessionDelivery(true);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, 1L, false, "a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertTrue(post.isLiveLocalSessionDelivery()); assertFalse(post.isUnconfirmedLocalSessionDelivery());
|
||||
assertEquals(42L, post.getBackendObservationOrder()); assertEquals(1L, post.getVoteTime());
|
||||
}
|
||||
@Test void recoveredTimeQueueCannotInventFreshnessFromFutureTimestamp() {
|
||||
var input = new PlayerVoteEvent(null, "Alex", "a", true);
|
||||
input.setTime(Long.MAX_VALUE); input.setBackendObservationOrder(0L); input.setTimeQueuedLocalSessionDelivery(true);
|
||||
var post = new PlayerPostVoteEvent(null, null, true, false, Long.MAX_VALUE, false, "a", UUID.randomUUID(), "Alex", UUID.randomUUID());
|
||||
PlayerVoteListenerAccess.copySessionObservation(post, input);
|
||||
assertFalse(post.isLiveLocalSessionDelivery()); assertTrue(post.isUnconfirmedLocalSessionDelivery());
|
||||
assertEquals(0L, post.getBackendObservationOrder()); assertEquals(Long.MAX_VALUE, post.getVoteTime());
|
||||
}
|
||||
}
|
||||
+35
-2
@@ -156,15 +156,48 @@ public class VotiferEventDisabledVoteSiteTest {
|
||||
verify(pluginManager).callEvent(any(PlayerVoteEvent.class));
|
||||
}
|
||||
|
||||
@Test public void voteAdmissionBeforeGuideKeepsItsOrderWhenProcessingRunsAfterGuideOpens() {
|
||||
var pending=new java.util.ArrayDeque<Runnable>();
|
||||
doAnswer(call->{pending.add(call.getArgument(0));return CompletableFuture.completedFuture(null);})
|
||||
.when(voteTimer).submit(any(Runnable.class));
|
||||
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE));
|
||||
var guide=new com.bencodez.votingplugin.core.session.GuidedVoteSession(System.currentTimeMillis());
|
||||
guide.bindCandidates(java.util.List.of(new com.bencodez.votingplugin.core.session.GuidedVoteSession.Site("site","Site","https://example.org",true,0)));
|
||||
guide.refresh(java.util.List.of(new com.bencodez.votingplugin.core.session.GuidedVoteSession.Site("site","Site","https://example.org",true,0)));
|
||||
pending.remove().run();
|
||||
var events=org.mockito.ArgumentCaptor.forClass(PlayerVoteEvent.class);
|
||||
verify(pluginManager).callEvent(events.capture());
|
||||
long original=events.getValue().getBackendObservationOrder();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(original>0);
|
||||
guide.acceptedObserved("site",java.util.UUID.randomUUID(),original);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(0,guide.view("").received());
|
||||
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); pending.remove().run();
|
||||
verify(pluginManager,org.mockito.Mockito.times(2)).callEvent(events.capture());
|
||||
guide.acceptedObserved("site",java.util.UUID.randomUUID(),events.getValue().getBackendObservationOrder());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(1,guide.view("").received());
|
||||
}
|
||||
@Test
|
||||
public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() {
|
||||
doThrow(new RejectedExecutionException("capacity exhausted"))
|
||||
.when(voteTimer).submit(any(Runnable.class));
|
||||
when(overflowQueue.enqueue("Steve", SERVICE_SITE)).thenReturn(true);
|
||||
when(overflowQueue.enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong())).thenReturn(true);
|
||||
|
||||
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE));
|
||||
|
||||
verify(overflowQueue).enqueue("Steve", SERVICE_SITE);
|
||||
var order=org.mockito.ArgumentCaptor.forClass(Long.class);
|
||||
verify(overflowQueue).enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), order.capture());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(order.getValue()>0);
|
||||
verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class));
|
||||
}
|
||||
@Test public void timeChangeQueueReceivesCapturedIngressOrderBeforeDeferredProcessing() {
|
||||
when(plugin.getTimeChecker().isActiveProcessing()).thenReturn(true);
|
||||
when(plugin.getConfigFile().isQueueVotesDuringTimeChange()).thenReturn(true);
|
||||
var queue = mock(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class);
|
||||
when(plugin.getTimeQueueHandler()).thenReturn(queue);
|
||||
listener.onVotiferEvent(createVoteEvent(SERVICE_SITE));
|
||||
var order = org.mockito.ArgumentCaptor.forClass(Long.class);
|
||||
verify(queue).addVote(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), order.capture());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(order.getValue() > 0);
|
||||
verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class));
|
||||
}
|
||||
}
|
||||
+22
@@ -29,6 +29,28 @@ import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue;
|
||||
|
||||
class VotifierVoteOverflowQueueTest {
|
||||
@Test void overflowRetainsProcessLocalOrderButRestartNeverRestoresIt(@TempDir Path dataFolder) throws Exception {
|
||||
var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS);
|
||||
var timer=mock(ScheduledExecutorService.class);
|
||||
when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); when(plugin.getVoteTimer()).thenReturn(timer);
|
||||
var callbacks=new java.util.concurrent.LinkedBlockingQueue<Runnable>();
|
||||
doAnswer(call->{callbacks.add(call.getArgument(0));return null;}).when(timer).submit(any(Runnable.class));
|
||||
var observed=new java.util.ArrayList<Long>();
|
||||
var queue=VotifierVoteOverflowQueue.withObservations(plugin,(site,user,order)->observed.add(order));
|
||||
try {
|
||||
assertTrue(queue.enqueue("Steve","example.org",123L)); queue.start();
|
||||
java.util.Objects.requireNonNull(callbacks.poll(5,TimeUnit.SECONDS)).run();
|
||||
assertEquals(java.util.List.of(123L),observed);
|
||||
assertTrue(queue.enqueue("Alex","other.example.org",456L));
|
||||
java.util.Objects.requireNonNull(callbacks.poll(5,TimeUnit.SECONDS)); // Deliberately unacknowledged.
|
||||
} finally {queue.close();}
|
||||
var restarted=VotifierVoteOverflowQueue.withObservations(plugin,(site,user,order)->observed.add(order));
|
||||
try {
|
||||
assertEquals(1,restarted.size()); restarted.start();
|
||||
java.util.Objects.requireNonNull(callbacks.poll(5,TimeUnit.SECONDS)).run();
|
||||
assertEquals(java.util.List.of(123L,0L),observed);
|
||||
} finally {restarted.close();}
|
||||
}
|
||||
@Test
|
||||
void enqueueCannotChangeVersionDuringDurableAdmission(@TempDir Path dataFolder) throws Exception {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
|
||||
+22
@@ -23,6 +23,28 @@ class ReliableVoteDeliveryOutboxTest {
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void retainedVotesLoseLiveFreshnessWithoutMutatingImmediateEnvelopeAndOldRowsAreSanitized() throws Exception {
|
||||
for (boolean oldRow : new boolean[] {false, true}) {
|
||||
Path file=directory.resolve(oldRow ? "old.dat" : "new.dat"); UUID id=UUID.randomUUID();
|
||||
JsonEnvelope live=VotingPluginWire.vote("Player",UUID.randomUUID().toString(),"site",10L,true,true,"",id,false,false,1,1);
|
||||
var previous=com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder(live.getSubChannel()).schema(live.getSchema());
|
||||
for(var field:live.getFields().entrySet()) previous.put(field.getKey(),field.getValue());
|
||||
live=previous.put(VotingPluginWire.K_SESSION_DELIVERY_FRESH,true).build();
|
||||
if(oldRow) {
|
||||
var encode=java.util.Base64.getUrlEncoder().withoutPadding();
|
||||
String server=encode.encodeToString("Survival".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
String envelope=encode.encodeToString(com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(live).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
Files.writeString(file,"VP-VOTE-OUTBOX-2\nA\t"+server+"\t"+envelope+"\n");
|
||||
} else assertTrue(new ReliableVoteDeliveryOutboxAccess(file).offer("Survival",live));
|
||||
var retained=new ReliableVoteDeliveryOutboxAccess(file).snapshot().getFirst().envelope();
|
||||
assertEquals("true",live.getFields().get(VotingPluginWire.K_SESSION_DELIVERY_FRESH));
|
||||
assertEquals("false",retained.getFields().get(VotingPluginWire.K_SESSION_DELIVERY_FRESH));
|
||||
var expected=new java.util.HashMap<>(live.getFields());expected.put(VotingPluginWire.K_SESSION_DELIVERY_FRESH,"false");
|
||||
assertEquals(expected,retained.getFields());assertEquals(live.getSchema(),retained.getSchema());assertEquals(live.getSubChannel(),retained.getSubChannel());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateQuotaDoesNotConsumeTheRewardEntryAllowance() throws Exception {
|
||||
Path file = directory.resolve("updates.dat");
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.bencodez.votingplugin.tests.session;
|
||||
|
||||
import com.bencodez.votingplugin.session.GuidedVotingSessions;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
|
||||
class GuidedSessionConnectionTest {
|
||||
@Test void queuedOldCommandCannotDisplaceReopenedGuide() { checkRetiredConnection(false); }
|
||||
@Test void queuedOldAutomaticRefreshCannotDisplaceReopenedGuide() { checkRetiredConnection(true); }
|
||||
|
||||
private void checkRetiredConnection(boolean automatic) {
|
||||
{
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
UUID uuid = UUID.randomUUID();
|
||||
Player old = mock(Player.class), current = mock(Player.class);
|
||||
var user = mock(VotingPluginUser.class);
|
||||
var site = mock(VoteSite.class);
|
||||
var timer = mock(ScheduledExecutorService.class);
|
||||
var config = new YamlConfiguration(); config.set("GuidedVotingSession.Enabled", true);
|
||||
when(plugin.getConfigFile().getData()).thenReturn(config);
|
||||
when(plugin.getOptions().isOnlineMode()).thenReturn(true);
|
||||
when(plugin.getTimer()).thenReturn(timer);
|
||||
when(plugin.getServer().getPlayer(uuid)).thenReturn(old);
|
||||
for (Player player : List.of(old, current)) {
|
||||
when(player.getUniqueId()).thenReturn(uuid);
|
||||
when(player.getName()).thenReturn("test-player");
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.hasPermission(anyString())).thenReturn(true);
|
||||
when(player.spigot()).thenReturn(mock(Player.Spigot.class));
|
||||
}
|
||||
when(plugin.getCommandLoader().canUseGuidedSession(any(Player.class))).thenReturn(true);
|
||||
when(site.getKey()).thenReturn("site-a"); when(site.getDisplayName()).thenReturn("Site A");
|
||||
when(site.getPermissionToView()).thenReturn(""); when(site.isEnabled()).thenReturn(true);
|
||||
when(site.getVoteURL(true)).thenReturn("https://example.org/vote");
|
||||
when(plugin.getVoteSiteManager().getVoteSites()).thenReturn(List.of(site));
|
||||
when(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, "test-player")).thenReturn(user);
|
||||
when(user.getLastVotes()).thenReturn(new java.util.HashMap<>());
|
||||
when(user.getJavaUUID()).thenReturn(uuid);
|
||||
when(user.getPlayerName()).thenReturn("test-player");
|
||||
when(user.canVoteSite(eq(site), anyLong())).thenReturn(true);
|
||||
var owners = new ArrayDeque<Runnable>(); var workers = new ArrayDeque<Runnable>();
|
||||
doAnswer(i -> { workers.add(i.getArgument(0)); return null; }).when(timer).execute(any());
|
||||
try (var scheduler = mockStatic(BukkitCompletionScheduler.class)) {
|
||||
scheduler.when(() -> BukkitCompletionScheduler.run(eq(plugin), any(Player.class), any(Runnable.class),
|
||||
any(Runnable.class), any(Runnable.class))).thenAnswer(i -> { owners.add(i.getArgument(2)); return null; });
|
||||
var sessions = SessionDialogViewAccess.sessions(plugin, () -> null);
|
||||
sessions.command(old, ""); owners.remove().run(); workers.remove().run(); owners.remove().run();
|
||||
if (automatic) {
|
||||
var event = new PlayerPostVoteEvent(site, user, true, false, Long.MAX_VALUE, true,
|
||||
"site-a", uuid, "test-player", UUID.randomUUID());
|
||||
sessions.credited(event);
|
||||
} else sessions.command(old, "check");
|
||||
assertEquals(1, owners.size()); Runnable stale = owners.remove();
|
||||
sessions.disconnected(new PlayerQuitEvent(old, ""));
|
||||
when(plugin.getServer().getPlayer(uuid)).thenReturn(current);
|
||||
sessions.command(current, "check"); owners.remove().run(); workers.remove().run(); owners.remove().run();
|
||||
clearInvocations(old, current, user);
|
||||
stale.run();
|
||||
assertTrue(workers.isEmpty(), "stale callback admitted work for a retired connection: " + automatic);
|
||||
assertTrue(owners.isEmpty());
|
||||
verifyNoInteractions(current, user);
|
||||
verify(old, never()).sendMessage(anyString());
|
||||
sessions.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.bencodez.votingplugin.tests.session;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
/** Contract coverage for the real bundled Spigot UniDialog adapter. */
|
||||
class GuidedSessionDialogsAdapterTest {
|
||||
@Test
|
||||
void showsNativeDialogRegistersItsEventBridgeAndReleasesOwnedActions() {
|
||||
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
Server server = mock(Server.class, RETURNS_DEEP_STUBS);
|
||||
PluginManager plugins = mock(PluginManager.class);
|
||||
Player player = mock(Player.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getPluginManager()).thenReturn(plugins);
|
||||
|
||||
SessionDialogViewAccess adapter = SessionDialogViewAccess.nativeAdapter(plugin);
|
||||
verify(plugins).registerEvent(any(), any(), any(), any(), eq(plugin), eq(false));
|
||||
|
||||
BiConsumer<UUID, String> callback = mock(BiConsumer.class);
|
||||
Runnable cleanup = adapter.show(player, "Voting", "Choose a site", List.of(
|
||||
new SessionDialogViewAccess.Button("Vote", "Open", null, "https://example.test/vote"),
|
||||
new SessionDialogViewAccess.Button("Check", "Refresh", "check", null)), callback);
|
||||
assertNotNull(cleanup);
|
||||
verify(player).showDialog(any(net.md_5.bungee.api.dialog.Dialog.class));
|
||||
|
||||
cleanup.run();
|
||||
cleanup.run();
|
||||
verifyNoInteractions(callback);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.bencodez.votingplugin.tests.session;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
|
||||
class GuidedSessionWireProvenanceTest {
|
||||
@Test
|
||||
public void observationProvenancePreservesAllRewardFieldsAndEnvelopeIdentity() {
|
||||
JsonEnvelope live = VotingPluginWire.voteOnline("Player", UUID.randomUUID().toString(), "Service",
|
||||
100L, true, true, "totals", UUID.randomUUID(), true, false, 1, 1, true);
|
||||
for (String origin : new String[] { "Primary", "" }) {
|
||||
JsonEnvelope forwarded = VotingPluginWire.unconfirmedSessionDelivery(live, origin);
|
||||
assertEquals(live.getSchema(), forwarded.getSchema());
|
||||
assertEquals(live.getSubChannel(), forwarded.getSubChannel());
|
||||
java.util.Map<String, String> fields = new java.util.LinkedHashMap<>(forwarded.getFields());
|
||||
assertEquals(origin, fields.remove(VotingPluginWire.K_MULTI_PROXY_ORIGIN));
|
||||
fields.put(VotingPluginWire.K_SESSION_DELIVERY_FRESH, "false");
|
||||
assertEquals(live.getFields(), fields);
|
||||
}
|
||||
assertFalse(live.getFields().containsKey(VotingPluginWire.K_MULTI_PROXY_ORIGIN));
|
||||
}
|
||||
|
||||
}
|
||||
+1469
File diff suppressed because it is too large.
Load diff
+68
@@ -0,0 +1,68 @@
|
||||
package com.bencodez.votingplugin.tests.session;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.session.GuidedVotingSessions;
|
||||
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
|
||||
|
||||
/** Test-only view of the private dialog seam; production visibility is unchanged. */
|
||||
public interface SessionDialogViewAccess {
|
||||
String OWNER = "com.bencodez.votingplugin.session.SessionDialogView";
|
||||
String ADAPTER = "com.bencodez.votingplugin.session.GuidedSessionDialogs";
|
||||
|
||||
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);
|
||||
|
||||
static GuidedVotingSessions sessions(VotingPluginMain plugin, Supplier<SessionDialogViewAccess> views) {
|
||||
Supplier<Object> nativeViews = () -> {
|
||||
SessionDialogViewAccess view = views.get();
|
||||
if (view == null) return null;
|
||||
Class<?> type = ReflectiveAccess.type(OWNER);
|
||||
return Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, (proxy, method, args) -> {
|
||||
if (method.getDeclaringClass() == Object.class) {
|
||||
return switch (method.getName()) {
|
||||
case "equals" -> proxy == args[0];
|
||||
case "hashCode" -> System.identityHashCode(proxy);
|
||||
case "toString" -> "Test dialog callback";
|
||||
default -> throw new AssertionError(method);
|
||||
};
|
||||
}
|
||||
if (!method.getName().equals("show")) throw new AssertionError(method);
|
||||
List<?> nativeButtons = (List<?>) args[3];
|
||||
List<Button> buttons = nativeButtons.stream().map(button -> new Button(
|
||||
property(button, "label"), property(button, "tooltip"),
|
||||
property(button, "action"), property(button, "url"))).toList();
|
||||
@SuppressWarnings("unchecked")
|
||||
BiConsumer<UUID, String> callback = (BiConsumer<UUID, String>) args[4];
|
||||
return view.show((Player) args[0], (String) args[1], (String) args[2], buttons, callback);
|
||||
});
|
||||
};
|
||||
return (GuidedVotingSessions) ReflectiveAccess.construct(GuidedVotingSessions.class.getName(),
|
||||
new String[] {"VotingPluginMain", "Supplier"}, new Object[] {plugin, nativeViews});
|
||||
}
|
||||
|
||||
static SessionDialogViewAccess nativeAdapter(VotingPluginMain plugin) {
|
||||
Object adapter = ReflectiveAccess.construct(ADAPTER, new String[] {"VotingPluginMain"}, new Object[] {plugin});
|
||||
return (player, title, body, buttons, callback) -> {
|
||||
List<Object> nativeButtons = buttons.stream().map(button -> ReflectiveAccess.construct(OWNER + "$Button",
|
||||
new String[] {"String", "String", "String", "String"},
|
||||
new Object[] {button.label(), button.tooltip(), button.action(), button.url()})).toList();
|
||||
return (Runnable) ReflectiveAccess.call(ADAPTER, adapter, "show",
|
||||
new String[] {"Player", "String", "String", "List", "BiConsumer"},
|
||||
new Object[] {player, title, body, nativeButtons, callback});
|
||||
};
|
||||
}
|
||||
|
||||
private static String property(Object button, String name) {
|
||||
return (String) ReflectiveAccess.call(OWNER + "$Button", button, name, new String[0], new Object[0]);
|
||||
}
|
||||
}
|
||||
+17
@@ -92,6 +92,8 @@ class TimeQueueHandlerRejectionTest {
|
||||
verify(manager).callEvent(event.capture());
|
||||
assertNotNull(event.getValue());
|
||||
assertEquals(123L, event.getValue().getTime());
|
||||
assertEquals(0L, event.getValue().getBackendObservationOrder());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(event.getValue().isTimeQueuedLocalSessionDelivery());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,4 +110,19 @@ class TimeQueueHandlerRejectionTest {
|
||||
org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS));
|
||||
assertEquals(1, handler.getTimeChangeQueue().size());
|
||||
}
|
||||
@Test void freshTimeQueuePreservesOriginalIngressOrderAndTimestamp() {
|
||||
when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of());
|
||||
var handler = new TimeQueueHandler(plugin);
|
||||
handler.addVote("Alex", "example.org", 42L);
|
||||
var queued = handler.getTimeChangeQueue().peek();
|
||||
long originalTime = queued.getTime();
|
||||
var server = mock(Server.class); var manager = mock(PluginManager.class);
|
||||
when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(manager);
|
||||
var event = org.mockito.ArgumentCaptor.forClass(PlayerVoteEvent.class);
|
||||
handler.processQueue(); verify(manager).callEvent(event.capture());
|
||||
assertEquals(originalTime, event.getValue().getTime());
|
||||
assertEquals(42L, event.getValue().getBackendObservationOrder());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(event.getValue().isTimeQueuedLocalSessionDelivery());
|
||||
assertEquals(0, handler.getTimeChangeQueue().size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Guided voting session
|
||||
|
||||
The opt-in `/vote session` dialog guide helps players work through sites that are enabled, visible, permitted and eligible under VotingPlugin's existing cooldown rules. It does not replace `/vote`, `/vote gui`, inventory menus or rewards.
|
||||
|
||||
```yaml
|
||||
GuidedVotingSession:
|
||||
Enabled: false
|
||||
TimeoutMinutes: 30
|
||||
```
|
||||
|
||||
When disabled, the guide registers no command handlers, so existing custom `/vote session` commands retain their behavior. Enabling the feature reserves `/vote session` and its navigation subcommands; disabling it and reloading restores custom routing.
|
||||
|
||||
Enable it in `Config.yml`, reload, and grant `VotingPlugin.Commands.Vote.Session` or the established `VotingPlugin.Player` permission. The timeout is bounded to 1–120 minutes. At most 2,048 local sessions and 100 visible sites per session are retained.
|
||||
|
||||
## Player controls
|
||||
|
||||
- `/vote session`: start or reopen the current session.
|
||||
- `/vote session next`, `previous`: select another site.
|
||||
- `/vote session skip`: move on without confirming the site; a later real vote can still confirm it.
|
||||
- `/vote session check`: refresh current eligibility and observed progress.
|
||||
- `/vote session finish`: finish viewing without granting credit or any additional reward.
|
||||
- `/vote session restart`: begin a fresh guide with the currently eligible sites.
|
||||
|
||||
On hosts exposing supported native dialog APIs, the guide opens a dialog for the first eligible site. Its navigation buttons use the same controller decisions as the commands. The selected site's valid HTTP/HTTPS link (including the documented `[Text="...",url="..."]` wrapper) is a native URL button using Minecraft's normal URL action and confirmation. A newly confirmed current-site vote automatically reopens the guide on the next eligible, unskipped site; a duplicate receipt does not reopen it. An unrelated-site receipt updates progress without moving an awaiting current target. Existing ForceLinks normalization and stored player-name placeholders are applied before HTTP/HTTPS validation; working site configuration does not need to be rewritten. Invalid/missing links are explained. Link display/clicks do not prove a browser opened or a vote occurred.
|
||||
|
||||
Eligible sites start as `AWAITING`; sites already cooling down at the initial sample stay outside the guide unless a positively identified new accepted receipt restores that visible candidate. Matching real accepted notifications mark a site `RECEIVED` using the asynchronously resolved storage UUID, configured site key and canonical occurrence ID. Fresh local ingress uses backend-local strictly increasing process sequence, including receipt in the same wall-clock millisecond as session creation. Historical local deliveries with a supplied original timestamp retain the original backend-time check. A bounded early receipt is provisional until its storage UUID matches. Duplicate IDs cannot confirm two sites or increase progress. Fake, cancelled and unrelated notifications never confirm a site. Hidden/disabled/removed/no-longer-permitted sites are redacted and have no link; received progress is retained internally. `SKIPPED` is navigation state, not credit.
|
||||
|
||||
## Observable timing and lifecycle
|
||||
|
||||
Original proxy occurrence timestamps are never compared with the backend clock. On a proxy-enabled backend, a new guide sends an additive version-1 fence request over the configured transport. A supporting originating proxy acknowledges its current ingress watermark. Only a canonical occurrence accepted after that source fence can confirm a site, and only after the ordinary backend credited-vote pipeline emits its notification. Status checks do not advance the fence. A link being displayed or clicked is not confirmation.
|
||||
|
||||
New Votifier ingress uses an opaque UUIDv8 canonical ID with a random 60-bit source incarnation and a monotonic 62-bit sequence. The ID is assigned before asynchronous handoff and retained unchanged by existing journals, caches, retries and multi-proxy forwarding; no cache/database schema migration is required. The source belongs to the proxy plugin classloader across runtime replacement. Restarting/reloading the plugin classloader creates a different incarnation, so old recovery IDs cannot become new occurrences. Existing external/legacy occurrence IDs remain valid for ordinary accounting but cannot establish this new ordering proof.
|
||||
|
||||
A genuinely new occurrence may arrive late, queued, or out of order and still confirm once credited. Pre-fence cached or in-flight votes do not confirm even if processed later. Fake/test classification is guide-only and never adds a reward path. A bounded provisional receipt can wait for its asynchronous source reply and exact resolved storage UUID. A session retains at most 32 source fences and 400 pending source receipts; first replies for each source are fixed and duplicate replies cannot move the boundary. Handshake replies expire after 30 seconds and must match the player, random session nonce and backend identity.
|
||||
|
||||
Multi-proxy negotiation requests a separate fence from configured origins; each original ID must match a successfully acknowledged source. Unsupported or unavailable peers safely leave that source unconfirmed while ordinary votes and rewards continue. The guide reports pending/unavailable confirmation rather than inventing freshness from arrival time. Votes accepted before a source acknowledges the guide are outside its confirmation window. After a proxy restart or failed negotiation, `/vote session restart` obtains a fresh fence; reopening the old session does not silently reset its boundary. Upgrading only a backend or only a proxy preserves normal voting; this optional cross-node confirmation requires supporting peers.
|
||||
|
||||
Closing the dialog or disconnecting does not erase session progress. A newly accepted matching vote may reopen the guide while its owner is online, until the session is explicitly finished. Reopen/check to see current progress. Finishing prevents automatic reopening; restarting begins a new guide. Sessions are local, reset on reload/plugin stop/server restart, and expire from their original start; actual vote totals, cooldowns and rewards remain authoritative in existing storage. A player moving to another backend starts that backend's local guide. There is no cross-server session synchronization.
|
||||
|
||||
Eligibility storage reads run on VotingPlugin's existing worker. Each request reads/parses one last-vote snapshot and reuses the existing cooldown decision for every site. Player output and permission snapshots run on the player's supported owner scheduler; a retired entity fallback never accesses the player. Request/lifecycle/deadline fences discard stale responses after replacement, reload or timeout. Expiration releases the owner admission even if its old read is stalled; that outstanding read retains its global slot until completion. Only one storage check per player and 64 total outstanding checks are admitted; repeated requests receive a busy message rather than filling the shared executor. Site identities are resolved again before sampling and checked again before rendering to handle configuration reloads. There are no entity spawns, inventory interception, vote submissions, completion rewards or new stored vote data.
|
||||
|
||||
Reliable backend outbox records retain freshness=false, including pre-upgrade stored envelopes. Sender admission, negotiation, rejected sends and restart retries never establish cross-node guide ordering. Normal vote processing is unchanged.
|
||||
|
||||
Local Votifier ingress captures backend observation order before executor admission. In-process overflow preserves that order, so a vote queued before a guide opens cannot appear as a fresh guide receipt. Recovered overflow and direct legacy processing callbacks have unknown original order and remain unconfirmed by the guide; normal vote totals, cooldowns and rewards still process normally. The order is process-local and is never reused from a persisted queue after restart.
|
||||
|
||||
Offline-mode backends resolve the established cached network storage UUID on the player owner before reading cooldowns asynchronously. The guide itself remains owned by the Bukkit player UUID; name/UUID resolution does not grant rewards or confirm proxy vote occurrences.
|
||||
|
||||
## Native compatibility and cleanup
|
||||
|
||||
Native dialogs use the already bundled UniDialog library through lazy public API capability checks. Paper introduced its developer dialog API in 1.21.7; compatible Spigot releases expose their own dialog API. The plugin does not raise its minimum server or Java version for this feature. Hosts without the required classes/methods, or where opening a native dialog fails, receive an explicit explanation and the existing chat guide. Minecraft still controls browser opening and URL confirmation; no browser state is observed.
|
||||
|
||||
Each native view owns random, private action IDs. Owner identity, current guide, view nonce, permissions and online state are rechecked on the player scheduler; repeated or stale callbacks cannot operate on a replacement guide. Replacing a view, using its Close button, disconnecting, expiring or clearing the guide releases only its own callbacks. Escape/client dismissal does not reliably notify the server; bounded callback state remains until replacement or expiration, and session progress remains available. One 30-second worker sweep expires bounded session callback state; repeated navigation does not allocate accumulating per-view timer tasks. Reload/disable cancels that sweep. Cleanup does not close an unrelated production dialog, unregister production handlers, change inventories or spawn entities. A client may keep a displayed expired dialog until closing it, but its actions are inert.
|
||||
|
||||
Storage snapshots remain on the existing worker; final visibility and UI operations use the player owner scheduler. Accepted votes racing a status read are coalesced into its final owner callback, which reads the latest confirmed session progress. This documentation describes the implementation and automated unit behavior; native server/client verification is a separate validation gate.
|
||||
Reference in new issue
Block a user