diff --git a/AGENTS.md b/AGENTS.md index 5654f1981b..449175c380 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -324,6 +324,7 @@ Control behavior in production. - Trace whether the code runs on the connector worker, proxy thread, Bukkit primary thread, or a SQL executor. - Preserve queued votes across saturation, reload, runtime replacement, scheduler rejection/cancellation, shutdown, and restart; overflow handling must be bounded, durable when promised, and observable rather than silently dropping work. Scheduler admission is never proof of durable ownership. +- Local Votifier captures remain owned after STARTED until normal callback completion or confirmed overflow transfer. Transfer execution stays fenced while the original callback runs; successful non-interrupted completion reconciles only its exact occurrence, while interruption/exception or failed transfer retains bounded recovery ownership. A callback completing after the final overflow snapshot is an explicit uncertain reconciliation case; a retired queue must not overwrite a successor, and ordinary partial external effects retain the existing at-least-once boundary. - Proxy-to-backend guaranteed delivery is capability negotiated and at least once. Journal a reward-bearing envelope before reporting transport acceptance, retain it until the matching backend completion acknowledgement is durable, persist completed IDs before acknowledgement for restart-safe deduplication, retire receipts only through the durable diff --git a/README.md b/README.md index 644944b9dc..bd40a9ba12 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,5 @@ or disabling it; inspections gate disabled state immediately and report a newly ``` `LATEST` resolves to the latest stable release. + +Owner-defined [DateVoteMilestones](docs/date-vote-milestones.md) count qualifying accepted votes within explicit date windows, independently of normal totals. This optional feature is disabled until events are configured. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 619eae4de5..ea0bd05ed5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -331,7 +331,12 @@ private synchronized AtomicBoolean basicBungeeUpdateAdmission() { private VotingPluginConfigHealth configHealth; private VotifierIntegration votifierIntegration; @Getter - private VotifierVoteOverflowQueue votifierVoteOverflowQueue; + private volatile VotifierVoteOverflowQueue votifierVoteOverflowQueue; + private VotiferEvent localVotifierListener; + private boolean acceptedVoteIngressRegistered; + private boolean timeQueueIngressRegistered; + private volatile boolean localVotifierIngressReady; + private volatile boolean localVotifierIngressClosed; private VoteLogManager voteLogManager; private VotingPluginWebhookManager webhookManager; @@ -613,6 +618,11 @@ private void loadBungeeHandler() { @Getter private VoteStreakHandler voteStreakHandler; + @Getter + private final com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones dateVoteMilestones = + new com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones(this); + + /** * Registers a directly editable rewards path from SpecialRewards.yml. * @@ -626,6 +636,7 @@ public void loadDirectlyDefined() { VoteSiteRewardRegistrar.register(this); VotePartyRewardRegistrar.register(this); VoteMilestoneRewardRegistrar.register(this); + dateVoteMilestones.reload(); VoteStreakRewardRegistrar.register(this); TopVoterRewardRegistrar.register(this); VoteShopRewardRegistrar.register(this); @@ -697,8 +708,36 @@ private void metrics() { new VotingPluginMetrics().load(plugin); } + /** Publish accepted and transport ingress only after the complete vote pipeline is initialized. */ + void initializeDateVoteIngress(Runnable openProxy) { + if (localVotifierIngressClosed) throw new IllegalStateException("Vote ingress is shutting down"); + if (localVotifierIngressReady) return; + if (voteMilestonesManager == null || voteStreakHandler == null || voteParty == null || specialRewards == null + || topVoterHandler == null || voteShopManager == null || placeholders == null) { + throw new IllegalStateException("Accepted-vote handlers must initialize before transport ingress"); + } + getDateVoteMilestones().reload(); + if (!acceptedVoteIngressRegistered) { + getServer().getPluginManager().registerEvents(new PlayerVoteListener(this), this); + acceptedVoteIngressRegistered = true; + } + // Construction itself schedules persisted replay. Open it only after its consumer. + if (!timeQueueIngressRegistered && (!getBungeeSettings().isUseBungeecoord() || !getBungeeSettings().isGloblalDataEnabled())) { + timeQueueHandler = new TimeQueueHandler(this); + getServer().getPluginManager().registerEvents(timeQueueHandler, this); + timeQueueIngressRegistered = true; + } + if (getBungeeSettings().isUseBungeecoord()) openProxy.run(); + localVotifierIngressReady = true; + VotifierVoteOverflowQueue overflow = votifierVoteOverflowQueue; + if (overflow != null) overflow.start(); + } + @Override public void onPostLoad() { + loadVoteTimer(); + checkVotifier(); + registerEarlyVotifierIngress(); ensureCommunicationSecret(); // auto conversion for Shop.yml if (plugin.getShopFile().isJustCreated()) { @@ -726,7 +765,6 @@ public void onPostLoad() { voteTester = new VoteTester(plugin); - loadVoteTimer(); BoundedScheduledExecutor setupReminderWorker = new BoundedScheduledExecutor(1, 4); setupReminderWorker.setThreadFactory(runnable -> { Thread worker = new Thread(runnable, "VotingPlugin-SiteSetupReminder"); @@ -739,13 +777,8 @@ public void onPostLoad() { autoCreateVoteSitesSetupReminder.start(); getVotingPluginUserManager().startSharedPointTransferRecovery(); - if (bungeeSettings.isUseBungeecoord()) { - loadBungeeHandler(); - } + // Proxy ingress opens below, after every accepted-vote handler is initialized. - if (!bungeeSettings.isUseBungeecoord() || !bungeeSettings.isGloblalDataEnabled()) { - this.timeQueueHandler = new TimeQueueHandler(this); - } if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) { if (getConfigFile().isLoadInteralExpansion()) { @@ -760,7 +793,6 @@ public void onPostLoad() { voteStreakHandler.reload(); registerCommands(); - checkVotifier(); registerEvents(); refreshPlaceholderPlayerPresence(); @@ -850,11 +882,7 @@ public Object getObject(OfflinePlayer player) { } VotingPluginRewardRegistrar.register(this); - // Recovered Votifier votes may now traverse the fully initialized vote, - // reward, placeholder, shop, and vote-party pipeline. - if (votifierVoteOverflowQueue != null) { - votifierVoteOverflowQueue.start(); - } + initializeDateVoteIngress(this::loadBungeeHandler); plugin.getLogger().info("Enabled VotingPlugin " + plugin.getDescription().getVersion()); if (plugin.getDescription().getVersion().contains("SNAPSHOT")) { @@ -1995,6 +2023,9 @@ private void shutdownVoteReminders() { @Override public void onUnLoad() { + localVotifierIngressClosed = true; + localVotifierIngressReady = false; + if (localVotifierListener != null) localVotifierListener.stop(); stopAutoCreateVoteSitesSetupReminder(); placeholderPlayerPresence.clear(); stopBackendHostedControlLifecycle(); @@ -2076,19 +2107,24 @@ private void registerCommands() { } - /** - * Register events. - */ + /** Capture external events early; only the existing bounded workers may buffer/process them. */ + void registerEarlyVotifierIngress() { + if (localVotifierIngressClosed || !isVotifierLoaded() || localVotifierListener != null) return; + VotiferEvent listener = new VotiferEvent(this, () -> localVotifierIngressReady); + // Publish the memory owner before capture. Its independent worker loads + // disk state; vote-executor cancellation cannot discard queue setup. + votifierVoteOverflowQueue = VotifierVoteOverflowQueue.initializeConfirmedAsync(this, + listener::processVoteConfirmed, () -> localVotifierIngressReady && !localVotifierIngressClosed); + localVotifierListener = listener; + if (localVotifierIngressReady) votifierVoteOverflowQueue.start(); + getServer().getPluginManager().registerEvents(listener, this); + } + private void registerEvents() { PluginManager pm = getServer().getPluginManager(); pm.registerEvents(new PlayerJoinEvent(this), this); - if (isVotifierLoaded()) { - VotiferEvent votifierEvent = new VotiferEvent(this); - votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, votifierEvent::processVote); - pm.registerEvents(votifierEvent, this); - } - pm.registerEvents(new PlayerVoteListener(this), this); + pm.registerEvents(new com.bencodez.votingplugin.listeners.DateVoteMenuOpenGuard(), this); pm.registerEvents(new PlayerPostVoteLoggerListener(this), this); pm.registerEvents(new PlayerSpecialRewardLoggerListener(this), this); pm.registerEvents(new VoteShopPurchaseLoggerListener(this), this); @@ -2102,9 +2138,6 @@ private void registerEvents() { pm.registerEvents(new PlayerInteract(this), this); } - if (timeQueueHandler != null) { - pm.registerEvents(timeQueueHandler, plugin); - } pm.registerEvents(new VotingPluginUpdateEvent(this), this); /* @@ -2182,6 +2215,8 @@ private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl, // Re-evaluate after storage has reloaded; UserManager keeps this lifecycle task unique. getVotingPluginUserManager().startSharedPointTransferRecovery(); + getOptions().setServer(bungeeSettings.getServer()); + getDateVoteMilestones().reload(); reloadBackendProxyRuntime(updateActiveBackendRuntime, userStorage); checkYMLError(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index 5241bef44a..2060c1a458 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -569,7 +569,8 @@ private void handleVoteBroadcast(JsonEnvelope msg) { } String totalsRaw = nvl(fields.get(VotingPluginWire.K_TOTALS)); - VoteTotalsSnapshot totals = totalsRaw.isEmpty() ? null : VoteTotalsSnapshot.parseStorage(totalsRaw); + VoteTotalsSnapshot totals = !com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.hasPayload(totalsRaw) + ? null : VoteTotalsSnapshot.parseStorage(totalsRaw); VoteSite voteSite = plugin.getVoteSiteManager() .getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, service), true); if (voteSite == null) { @@ -715,16 +716,29 @@ private WireVoteResult handleWireVote(JsonEnvelope msg) { user.cache(); boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : user.isOnline(); boolean queuedDelivery = vote.queuedDeliveryKnown ? vote.queuedDelivery : vote.delayValidated; - 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()) { + com.bencodez.votingplugin.events.PlayerVoteEvent dispatchedEvent = null; + VotingPluginUser.DateMilestoneAccountingException dateAccountingFailure = null; + try { + // Keep totals correlation for replay fencing, but only the original wire ID + // identifies a canonical transport occurrence in the native event. + dispatchedEvent = user.bungeeVotePluginMessagingEvent(vote.service, vote.time, totals, !vote.manageTotals, + wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown, + vote.queuedDeliveryKnown, vote.voteId, + VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()), vote.realVote, + vote.canonicalOccurrenceTime); + } catch (VotingPluginUser.DateMilestoneAccountingException incomplete) { + dateAccountingFailure = incomplete; + } + if ((dispatchedEvent == null || !dispatchedEvent.isCancelled()) + && plugin.getBungeeSettings().isPerServerPoints()) { user.addPoints(plugin.getConfigFile().getPointsOnVote()); } if (vote.service != null && !vote.service.isEmpty()) { plugin.getServerData().addServiceSite(vote.service); } + // Date accounting cannot suppress normal points/service observations. Keep + // the reservation incomplete so reconciliation cannot replay normal rewards. + if (dateAccountingFailure != null) throw dateAccountingFailure; return new WireVoteResult(voteId, true, false); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index bfbb23af86..de0a7430bf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -3486,7 +3486,15 @@ public void updateReplacements() { */ private void loadVoteCommand() { plugin.setVoteCommand(new ArrayList<>()); - plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" }, + plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "DateEvents" }, + "VotingPlugin.Commands.Vote.DateEvents|" + playerPerm, "View date-window vote milestone progress") { + @Override public void execute(CommandSender sender, String[] args) { + if (sender instanceof Player player) plugin.getDateVoteMilestones().progress(player); + else sender.sendMessage("Run /vote dateevents as a player."); + } + }); + + plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" }, "VotingPlugin.Commands.Vote.Help|" + playerPerm, "View help page") { @Override diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/DateVoteEventsMenu.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/DateVoteEventsMenu.java new file mode 100644 index 0000000000..9f4f3091bd --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/DateVoteEventsMenu.java @@ -0,0 +1,170 @@ +package com.bencodez.votingplugin.commands.gui.player; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.function.BooleanSupplier; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.entity.HumanEntity; + +import com.bencodez.advancedcore.api.inventory.BInventory; +import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent; +import com.bencodez.advancedcore.api.inventory.BInventoryButton; +import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Availability; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Event; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Milestone; + +/** Entity-thread presentation of an immutable storage-worker result. */ +public final class DateVoteEventsMenu { + private static final int PAGE_SIZE = 45; + private static final int LIMIT = 64; + private static final String OPEN_GUARD = "DateVoteEventsOpenGuard"; + private record OpenGuard(java.util.UUID owner, BooleanSupplier eligible) { } + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss"); + + private final VotingPluginMain plugin; + private final Player player; + private final DateVoteProgressSnapshot snapshot; + private final BooleanSupplier current; + + private DateVoteEventsMenu(VotingPluginMain plugin, Player player, DateVoteProgressSnapshot snapshot, + BooleanSupplier current) { + this.plugin = plugin; + this.player = player; + this.snapshot = snapshot; + this.current = current; + } + + public static void open(VotingPluginMain plugin, Player player, DateVoteProgressSnapshot snapshot, + BooleanSupplier current) { + new DateVoteEventsMenu(plugin, player, snapshot, current).list(0); + } + + private boolean eligible() { + return current.getAsBoolean() && plugin.isEnabled() && player.isOnline() + && (player.hasPermission("VotingPlugin.Commands.Vote.DateEvents") + || player.hasPermission("VotingPlugin.Player")); + } + + private BInventory inventory(String title) { + BInventory inventory = new BInventory(title); + inventory.setMaxInvSize(54); + inventory.setPages(false); + inventory.runClicksSync(); + inventory.dontClose(); + inventory.addData(OPEN_GUARD, new OpenGuard(player.getUniqueId(), this::eligible)); + return inventory; + } + + /** Called at Bukkit's final InventoryOpenEvent, after AdvancedCore's deferred callback. */ + public static boolean permitsOpen(BInventory inventory, HumanEntity viewer) { + Object value = inventory.getData(OPEN_GUARD); + return !(value instanceof OpenGuard guard) + || guard.owner().equals(viewer.getUniqueId()) && guard.eligible().getAsBoolean(); + } + + private void list(int page) { + if (!eligible()) return; + List events = snapshot.events().stream().limit(LIMIT).toList(); + BInventory inventory = inventory("Date vote events " + (page + 1) + "/" + pages(events.size())); + int end = Math.min(events.size(), (page + 1) * PAGE_SIZE); + for (int index = page * PAGE_SIZE; index < end; index++) { + Event event = events.get(index); + inventory.addButton(index % PAGE_SIZE, button(Material.PAPER, + event.definition().displayName(), eventLore(event), () -> detail(event, 0, page))); + } + List summary = new ArrayList<>(); + if (events.isEmpty()) summary.add("No date vote events configured."); + else if (events.stream().noneMatch(event -> event.definition().enabled())) + summary.add("No enabled date vote events."); + else summary.add("Select an event to view milestones."); + summary.add("Snapshot: " + Instant.ofEpochMilli(snapshot.capturedAt())); + inventory.addButton(49, button(Material.BOOK, "Date vote events", summary, () -> {})); + if (page > 0) inventory.addButton(45, button(Material.ARROW, "Previous page", List.of(), () -> list(page - 1))); + if (end < events.size()) inventory.addButton(53, button(Material.ARROW, "Next page", List.of(), () -> list(page + 1))); + if (eligible()) inventory.openInventory(player); + } + + private void detail(Event event, int page, int listPage) { + if (!eligible()) return; + List milestones = event.milestones().stream().limit(LIMIT).toList(); + BInventory inventory = inventory("Date vote milestones " + (page + 1) + "/" + pages(milestones.size())); + int end = Math.min(milestones.size(), (page + 1) * PAGE_SIZE); + for (int index = page * PAGE_SIZE; index < end; index++) { + Milestone milestone = milestones.get(index); + List lore = new ArrayList<>(); + lore.add("ID: " + milestone.id()); + lore.add("Enabled: " + milestone.enabled()); + lore.add("Award state: " + event.awardState(milestone.threshold())); + List types = milestone.rewardTypes().stream().limit(LIMIT) + .map(type -> type.matches("[A-Za-z0-9_-]{1,64}") ? type : "Other").distinct().toList(); + lore.add("Reward types: " + (types.isEmpty() ? "none" : String.join(", ", types))); + inventory.addButton(index % PAGE_SIZE, button(Material.PAPER, milestone.threshold() + " votes", lore, () -> {})); + } + List summary = eventLore(event); + if (milestones.isEmpty()) summary.add("No milestones configured."); + inventory.addButton(48, button(Material.BOOK, event.definition().displayName(), summary, () -> {})); + inventory.addButton(49, button(Material.ARROW, "Back to events", List.of(), () -> list(listPage))); + if (page > 0) inventory.addButton(45, button(Material.ARROW, "Previous page", List.of(), () -> detail(event, page - 1, listPage))); + if (end < milestones.size()) inventory.addButton(53, button(Material.ARROW, "Next page", List.of(), () -> detail(event, page + 1, listPage))); + if (eligible()) inventory.openInventory(player); + } + + private List eventLore(Event event) { + var definition = event.definition(); + List lore = new ArrayList<>(); + lore.add("ID: " + definition.id()); + lore.add("Status: " + event.status(snapshot.capturedAt())); + ZoneId zone = ZoneId.of(definition.timezone()); + lore.add("Start: " + TIME.format(Instant.ofEpochMilli(definition.start()).atZone(zone))); + lore.add("End (exclusive): " + TIME.format(Instant.ofEpochMilli(definition.end()).atZone(zone))); + lore.add("Timezone: " + definition.timezone()); + if (event.availability() == Availability.OWNED) { + lore.add("Votes: " + event.votes()); + lore.add("Next milestone: " + (event.nextThreshold().isPresent() ? event.nextThreshold().getAsInt() + " votes" : "none")); + } else if (event.availability() == Availability.REMOTE) { + lore.add("Progress owned by: " + definition.accountingServer()); + lore.add("Progress unavailable on this server."); + } else { + lore.add("Progress unavailable: accounting read failed."); + } + return lore; + } + + private static int pages(int size) { + return Math.max(1, (size + PAGE_SIZE - 1) / PAGE_SIZE); + } + + private BInventoryButton button(Material material, String name, List lore, Runnable action) { + List lines = List.copyOf(lore); + return new BInventoryButton(new ItemBuilder(material)) { + // ItemBuilder evaluates display Javascript and formatting while rendering. Render only + // the blank material through it, then apply snapshot text literally to Bukkit metadata. + @Override public ItemStack getItem() { + ItemStack item = new ItemBuilder(material).toItemStack(); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + meta.setLore(lines); + item.setItemMeta(meta); + } + return item; + } + @Override public ItemStack getItem(Player ignored) { return getItem(); } + @Override public ItemStack getItem(Player ignored, HashMap placeholders) { return getItem(); } + @Override public void onClick(ClickEvent click) { + if (eligible()) action.run(); + } + }.dontClose(); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java new file mode 100644 index 0000000000..0555093fc5 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java @@ -0,0 +1,64 @@ +package com.bencodez.votingplugin.core.datemilestones; + +import java.nio.charset.StandardCharsets; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +/** Immutable owner-defined event accounting contract; display text is not identity. */ +public record DateVoteEvent(String id, String displayName, boolean enabled, long start, long end, + String timezone, List thresholds, Set sites, String accountingServer) { + public DateVoteEvent { + if (id == null || !id.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalArgumentException("Invalid event ID"); + if (start <= 0 || end <= start) throw new IllegalArgumentException("End must be after start"); + ZoneId.of(timezone); + if (thresholds.isEmpty() || thresholds.size() > 64 || thresholds.stream().anyMatch(n -> n <= 0 || n > 4096) + || thresholds.stream().distinct().count() != thresholds.size()) throw new IllegalArgumentException("Invalid thresholds (1–4096, unique, max 64)"); + thresholds = thresholds.stream().sorted().toList(); + if (sites.size() > 100 || sites.stream().anyMatch(s -> s == null || s.length() > 128 || s.isBlank())) + throw new IllegalArgumentException("Invalid site filters"); + sites = Set.copyOf(sites); + if (accountingServer == null || accountingServer.length() > 128) throw new IllegalArgumentException("Invalid accounting server"); + if (displayName == null || displayName.length() > 256) throw new IllegalArgumentException("Invalid display name"); + } + public boolean matches(long occurredAt, String site, boolean real, boolean proxy, String server) { + return enabled && real && occurredAt >= start && occurredAt < end && (sites.isEmpty() || sites.contains(site)) + && (!proxy || !accountingServer.isBlank() && accountingServer.equals(server)); + } + public String fingerprint() { + try { + var bytes = new ByteArrayOutputStream(); + try (var data = new DataOutputStream(bytes)) { + data.writeLong(start); data.writeLong(end); data.writeUTF(timezone); + data.writeInt(thresholds.size()); + for (int threshold : thresholds) data.writeInt(threshold); + data.writeInt(sites.size()); + for (String site : sites.stream().sorted().toList()) data.writeUTF(site); + data.writeUTF(accountingServer); + } + return hash(bytes.toByteArray()); + } catch (java.io.IOException impossible) { throw new IllegalStateException(impossible); } + } + /** Portable case-sensitive identity, including on case-insensitive filesystems. */ + public String fileId() { return fileId(id); } + public static String fileId(String id) { + if (id == null || !id.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalArgumentException("Invalid event ID"); + return hash(id.getBytes(StandardCharsets.UTF_8)); + } + private static String hash(byte[] bytes) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + public static long timestamp(String localIso, String timezone) { + LocalDateTime time = LocalDateTime.parse(localIso); + ZoneId zone = ZoneId.of(timezone); + if (zone.getRules().getValidOffsets(time).size() != 1) + throw new IllegalArgumentException("Ambiguous/nonexistent local time; choose an unambiguous boundary"); + return time.atZone(zone).toInstant().toEpochMilli(); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteProgressSnapshot.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteProgressSnapshot.java new file mode 100644 index 0000000000..cf0776d825 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteProgressSnapshot.java @@ -0,0 +1,36 @@ +package com.bencodez.votingplugin.core.datemilestones; + +import java.util.List; +import java.util.OptionalInt; +import java.util.Set; + +/** Immutable worker result; viewing this snapshot never mutates accounting or rewards. */ +public record DateVoteProgressSnapshot(long capturedAt, List events) { + public DateVoteProgressSnapshot { events = List.copyOf(events); } + public enum Availability { OWNED, REMOTE, UNAVAILABLE } + public record Milestone(int threshold, String id, boolean enabled, List rewardTypes) { + public Milestone { rewardTypes = List.copyOf(rewardTypes); } + } + public record Event(DateVoteEvent definition, Availability availability, int votes, + Set submitted, Set deferred, Set reserved, List milestones) { + public Event { + submitted = Set.copyOf(submitted); deferred = Set.copyOf(deferred); reserved = Set.copyOf(reserved); + milestones = List.copyOf(milestones); + } + public String status(long now) { + return !definition.enabled() ? "disabled" : now < definition.start() ? "upcoming" + : now >= definition.end() ? "ended" : "active"; + } + public OptionalInt nextThreshold() { + return availability == Availability.OWNED ? milestones.stream().filter(m -> m.enabled() && m.threshold() > votes) + .mapToInt(Milestone::threshold).min() : OptionalInt.empty(); + } + public String awardState(int threshold) { + if (availability != Availability.OWNED) return "progress unavailable"; + if (reserved.contains(threshold)) return "pending review"; + if (submitted.contains(threshold)) return "submitted"; + if (deferred.contains(threshold)) return "deferred"; + return votes >= threshold ? "reached" : "not reached"; + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index c64b20f13d..d19510bf61 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -1,7 +1,5 @@ package com.bencodez.votingplugin.core.vote; -import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.UUID; /** The accepted vote sequence. Platform operations are supplied by the receiving adapter. */ @@ -67,12 +65,15 @@ public interface Operations { boolean forceProxyRouting(); boolean wasOnline(); boolean realVote(); + default long currentTimeMillis() { return System.currentTimeMillis(); } boolean addTotals(); boolean broadcastEnabled(); boolean hasBroadcastHandler(); void broadcast(UUID uuid, String name, String siteDisplayName, boolean online); boolean hasProxyTextTotals(); UUID proxyVoteId(); + /** Local receipt identity, without implying proxy provenance. */ + default UUID localOccurrenceId() { return null; } default boolean identifiedQueuedProxyVote() { return false; } default boolean proxyQueueClassificationKnown() { return false; } default boolean proxyDelayValidationKnown() { return false; } @@ -105,6 +106,7 @@ public interface Operations { int enabledSiteCount(); void setMonthTotal(U user, int total); void milestones(U user, UUID voteId, boolean forceProxyRouting); + default void dateMilestones(U user, S site, long voteTime, UUID voteId) { } void cooldown(U user, S site); void voteStreak(U user, long voteTime, UUID voteId); void postVote(S site, U user, String playerName, long voteTime, UUID voteId, boolean cached); @@ -186,10 +188,8 @@ public static void process(Operations ops) { + (legacyRecordedProxyVote ? "; legacy timestamp matches LastVotes: " + ops.incomingTime() : "; stable vote ID identifies this queued delivery: " + ops.proxyVoteId())); } - UUID voteId = UUID.randomUUID(); - if (ops.proxyVote() && ops.proxyVoteId() != null) { - voteId = ops.proxyVoteId(); - } + UUID voteId = ops.proxyVote() ? ops.proxyVoteId() : ops.localOccurrenceId(); + if (voteId == null) voteId = UUID.randomUUID(); String userId = ops.userId(user); ops.cache(user); ops.updateName(user); @@ -217,7 +217,7 @@ public static void process(Operations ops) { voteTime = ops.incomingTime(); } else { ops.setTimeNow(user, site); - voteTime = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + voteTime = ops.currentTimeMillis(); } boolean cached = false; if (SharedVoteDelivery.shouldDeliverNow(ops::proxyVote, () -> ops.userOnline(user), @@ -251,6 +251,7 @@ public static void process(Operations ops) { } } ops.milestones(user, voteId, ops.forceProxyRouting()); + ops.dateMilestones(user, site, voteTime, voteId); ops.cooldown(user, site); ops.voteStreak(user, voteTime, voteId); ops.postVote(site, user, playerName, voteTime, voteId, cached); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index a255b8d581..11c89e1068 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -90,6 +90,10 @@ public void addTimeVoted(int num, VoteTimeQueue vote) { getData().set("TimedVoteCache." + num + ".Name", vote.getName()); getData().set("TimedVoteCache." + num + ".Service", vote.getService()); getData().set("TimedVoteCache." + num + ".Time", vote.getTime()); + getData().set("TimedVoteCache." + num + ".LocalOccurrenceId", + vote.getLocalOccurrenceId() == null ? null : vote.getLocalOccurrenceId().toString()); + getData().set("TimedVoteCache." + num + ".CanonicalOccurrenceTime", + vote.getCanonicalOccurrenceTime() == 0L ? null : vote.getCanonicalOccurrenceTime()); saveData(); } @@ -102,6 +106,12 @@ public void addVoteShopPurchase(String ident) { setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1)); } + /** Assign a legacy local cache row its identity before replay is scheduled. */ + public void setTimedVoteLocalOccurrenceId(String key, UUID occurrenceId) { + getData().set("TimedVoteCache." + key + ".LocalOccurrenceId", occurrenceId.toString()); + saveData(); + } + /** * Clears the timed vote cache. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java index 4902564ead..fe5d9dc7c8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java @@ -13,6 +13,11 @@ import lombok.Setter; public class PlayerPostVoteEvent extends Event { + + /** Original accepted occurrence time; ordinary cooldown/event time remains separate. */ + @Getter + @Setter + private Long canonicalOccurrenceTime; /** The Constant handlers. */ private static final HandlerList handlers = new HandlerList(); @@ -77,6 +82,11 @@ public static HandlerList getHandlerList() { @Setter private UUID voteUUID; + /** Original explicit proxy transport ID; voteUUID retains the legacy correlation fallback. */ + @Getter + @Setter + private UUID proxyVoteId; + /** * Constructs a new PlayerPostVoteEvent. * diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index ce1bbe60d2..e941e970d3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -19,7 +19,17 @@ *

Cancellation follows Bukkit's {@code ignoreCancelled} dispatch contract. * Integrations must cancel before the MONITOR vote consumer runs.

*/ -public class PlayerVoteEvent extends Event implements Cancellable { +public class PlayerVoteEvent extends Event implements Cancellable { + + /** Stable local receipt identity, separate from proxy transport provenance. */ + @Getter + @Setter + private UUID localOccurrenceId; + + /** Original accepted occurrence time; ordinary cooldown/event time remains separate. */ + @Getter + @Setter + private Long canonicalOccurrenceTime; /** The Constant handlers. */ private static final HandlerList handlers = new HandlerList(); @@ -45,7 +55,7 @@ public static HandlerList getHandlerList() { @Setter private VoteTotalsSnapshot bungeeTextTotals; - /** Stable identity supplied by a proxy delivery, independent of totals. */ + /** Original explicit proxy transport ID; null for legacy totals-only correlation. */ @Getter @Setter private UUID proxyVoteId; @@ -86,6 +96,20 @@ public static HandlerList getHandlerList() { @Setter private boolean wasOnline; + /** Native receipt ownership is released only after the accepted consumer returns. */ + @Getter + @Setter + private boolean nativeProcessingStarted; + + @Getter + @Setter + private boolean nativeProcessingCompleted; + + /** Missing date accounting is surfaced after event dispatch so durable proxy envelopes are not acknowledged. */ + @Getter + @Setter + private boolean dateMilestoneAccountingFailed; + /** Whether this is an identified queued proxy delivery. */ @Getter @Setter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/DateVoteMenuOpenGuard.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/DateVoteMenuOpenGuard.java new file mode 100644 index 0000000000..bd0e6fd65f --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/DateVoteMenuOpenGuard.java @@ -0,0 +1,18 @@ +package com.bencodez.votingplugin.listeners; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryOpenEvent; +import com.bencodez.advancedcore.api.inventory.GUISession; +import com.bencodez.votingplugin.commands.gui.player.DateVoteEventsMenu; + +/** Fence the actual platform open, including AdvancedCore's additional scheduling handoff. */ +public final class DateVoteMenuOpenGuard implements Listener { + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onOpen(InventoryOpenEvent event) { + GUISession session = GUISession.extractSession(event.getInventory()); + if (session != null && !DateVoteEventsMenu.permitsOpen(session.getInventoryGUI(), event.getPlayer())) + event.setCancelled(true); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index a9124dac8d..04ad1d3943 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -35,7 +35,9 @@ public void onplayerVote(PlayerVoteEvent event) { // Keep the production boundary defensive for direct/custom dispatchers // as well as Bukkit's Cancellable-aware event dispatcher. if (event.isCancelled()) return; - SharedVoteProcessor.process(new BukkitOperations(plugin, event)); + BukkitOperations operations = new BukkitOperations(plugin, event); + SharedVoteProcessor.process(operations); + if (event.isNativeProcessingStarted()) event.setNativeProcessingCompleted(true); } private static final class BukkitOperations implements SharedVoteProcessor.Operations { @@ -47,7 +49,11 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { this.event = event; } - @Override public boolean enabled() { return plugin.isEnabled(); } + @Override public boolean enabled() { + boolean enabled = plugin.isEnabled(); + if (enabled) event.setNativeProcessingStarted(true); + return enabled; + } @Override public String incomingName() { return event.getPlayer(); } @Override public String serviceSite() { return event.getServiceSite(); } @Override public void debug(String message) { plugin.debug(message); } @@ -131,6 +137,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { } @Override public boolean hasProxyTextTotals() { return event.getBungeeTextTotals() != null; } @Override public UUID proxyVoteId() { return resolveProxyVoteId(event); } + @Override public UUID localOccurrenceId() { return event.getLocalOccurrenceId(); } @Override public boolean identifiedQueuedProxyVote() { return event.isQueuedProxyVote(); } @Override public boolean proxyQueueClassificationKnown() { return event.isProxyQueueClassificationKnown(); } @Override public boolean proxyDelayValidationKnown() { return event.isProxyDelayValidationKnown(); } @@ -176,6 +183,20 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { plugin.getVoteMilestonesManager().handleVote(user, event.getBungeeTextTotals(), forceProxyRouting, voteId, new HashMap()); } + @Override public void dateMilestones(VotingPluginUser user, VoteSite site, long voteTime, UUID voteId) { + long occurrenceTime = event.getCanonicalOccurrenceTime() == null + ? proxyVote() ? incomingTime() : voteTime : event.getCanonicalOccurrenceTime(); + if (com.bencodez.votingplugin.util.BukkitCompletionScheduler.isPlatformOwnedThread()) { + // An asynchronous event flag does not identify the thread that dispatched it. + // A proxy caller cannot acknowledge accounting that has only been admitted. + event.setDateMilestoneAccountingFailed(!plugin.getDateVoteMilestones().deferAccepted(user, site.getKey(), voteId, occurrenceTime, + realVote(), proxyVote(), event.getProxyVoteId() != null && occurrenceTime > 0, + targetedProxyVote(), forceProxyRouting())); + return; + } + if (!plugin.getDateVoteMilestones().accepted(user, site.getKey(), voteId, occurrenceTime, realVote(), proxyVote(), + event.getProxyVoteId() != null && occurrenceTime > 0, targetedProxyVote(), forceProxyRouting())) event.setDateMilestoneAccountingFailed(true); + } @Override public void cooldown(VotingPluginUser user, VoteSite site) { plugin.getCoolDownCheck().vote(user, site); } @Override public void voteStreak(VotingPluginUser user, long voteTime, UUID voteId) { plugin.getVoteStreakHandler().processVote(user, voteTime, voteId); @@ -184,6 +205,9 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { long voteTime, UUID voteId, boolean cached) { PlayerPostVoteEvent post = new PlayerPostVoteEvent(site, user, event.isRealVote(), event.isForceBungee(), voteTime, cached, site.getServiceSite(), user.getJavaUUID(), playerName, voteId); + post.setProxyVoteId(event.getProxyVoteId()); + post.setCanonicalOccurrenceTime(event.getCanonicalOccurrenceTime() == null + ? event.isBungee() ? event.getTime() : voteTime : event.getCanonicalOccurrenceTime()); plugin.getServer().getPluginManager().callEvent(post); } @Override public boolean placeholderCacheAlways() { return plugin.getConfigFile().getPlaceholderCacheLevel().isCacheAlways(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 5e7188a5f9..9c75b7ee55 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -1,6 +1,9 @@ package com.bencodez.votingplugin.listeners; import java.util.concurrent.RejectedExecutionException; +import java.util.UUID; +import java.util.LinkedHashMap; +import java.util.Map; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -17,16 +20,129 @@ public class VotiferEvent implements Listener { - private VotingPluginMain plugin; + private final VotingPluginMain plugin; + private final java.util.function.BooleanSupplier ready; + private volatile boolean accepting = true; + private static final int MAX_CAPTURED = VotifierVoteOverflowQueue.MAX_CAPTURED; + private boolean retirementQueued; + private final Object captureLock = new Object(); + // Receipt ownership only. Entries leave at completed processing or confirmed + // overflow transfer; STARTED work remains owned until then. + private final Map captured = new LinkedHashMap<>(); + private static final class CapturedVote { + final String username, site; + final long occurredAt; + final UUID id; + boolean transferring, transferFailureReported; + volatile boolean started, completed, finished, transferred; + VotifierVoteOverflowQueue recoveryOwner; + CapturedVote(String username, String site, long occurredAt, UUID id) { + this.username = username; this.site = site; this.occurredAt = occurredAt; this.id = id; + } + } /** * Instantiates a new votifer event. * * @param plugin the plugin */ - public VotiferEvent(VotingPluginMain plugin) { - this.plugin = plugin; - } + public VotiferEvent(VotingPluginMain plugin) { this(plugin, () -> true); } + + public VotiferEvent(VotingPluginMain plugin, java.util.function.BooleanSupplier ready) { + this.plugin = plugin; this.ready = ready; + } + /** Retire admission and reconcile tasks before the vote executor is cancelled. */ + public void stop() { + synchronized (captureLock) { + accepting = false; + if (retirementQueued) return; + retirementQueued = true; + var overflow = plugin.getVotifierVoteOverflowQueue(); + if (overflow == null) { + for (CapturedVote vote : java.util.List.copyOf(captured.values())) transferLocked(vote); + return; + } + var snapshot = captured.values().stream().map(vote -> new VotifierVoteOverflowQueue.RetiringCapture( + vote.username, vote.site, vote.occurredAt, vote.id, + () -> !vote.started || vote.finished && !vote.completed, () -> vote.completed, + admitted -> transferred(vote, admitted))).toList(); + overflow.retireCaptures(snapshot); + } + } + + public int getPendingCaptureCount() { + synchronized (captureLock) { return captured.size(); } + } + + private void runCaptured(CapturedVote vote) { + synchronized (captureLock) { + if (!captured.containsKey(vote.id) || vote.transferring || vote.started) return; + if (!accepting || !ready.getAsBoolean()) { + if (accepting || !retirementQueued) transferLocked(vote); + return; + } + vote.started = true; + } + boolean completed = false; + try { completed = processCapturedVote(vote.site, vote.username, vote.occurredAt, vote.id) + && !Thread.currentThread().isInterrupted(); } + finally { + VotifierVoteOverflowQueue acknowledge = null; + synchronized (captureLock) { + vote.completed = completed; + vote.finished = true; + if (completed) { + captured.remove(vote.id); + if (vote.transferred) acknowledge = vote.recoveryOwner; + } + } + if (completed && vote.recoveryOwner != null) vote.recoveryOwner.releaseCapture(vote.id); + if (acknowledge != null) acknowledge.acknowledgeOccurrence(vote.id); + else if (vote.transferred && vote.recoveryOwner != null) vote.recoveryOwner.resumeTransfers(); + } + } + + private void transferLocked(CapturedVote vote) { + if (vote.transferring) return; + vote.transferring = true; + VotifierVoteOverflowQueue overflow = vote.recoveryOwner != null + ? vote.recoveryOwner : plugin.getVotifierVoteOverflowQueue(); + vote.recoveryOwner = overflow; + if (overflow == null) { transferred(vote, false); return; } + overflow.enqueueAfterInitialization(vote.username, vote.site, vote.occurredAt, vote.id, + () -> !vote.started || vote.finished && !vote.completed, + accepted -> transferred(vote, accepted)); + } + + private void transferred(CapturedVote vote, boolean admitted) { + boolean completed, retry, reportFailure; + synchronized (captureLock) { + if (vote.transferred && !admitted) return; // Older failure cannot revoke confirmed queue ownership. + vote.transferred = admitted; completed = vote.completed; + if (!admitted) vote.transferring = false; + if (admitted || completed) captured.remove(vote.id); + retry = !admitted && !completed && captured.get(vote.id) == vote; + reportFailure = !admitted && !completed && !vote.transferFailureReported; + if (reportFailure) vote.transferFailureReported = true; + } + if (admitted && completed) vote.recoveryOwner.acknowledgeOccurrence(vote.id); + if (completed) return; // Current normal processing won; do not leave an ordinary duplicate retry. + if (reportFailure) plugin.getLogger().severe("Votifier vote queue unavailable or full; " + + (retry ? "captured receipt retained for retry for " : "vote was not admitted for ") + + MinecraftUsernameValidator.sanitizeForLog(vote.username)); + else if (admitted) plugin.getLogger().warning("Vote pipeline unavailable or retiring; queued Votifier vote for retry"); + if (retry && vote.recoveryOwner != null) vote.recoveryOwner.retryCaptureTransfers(this::retryCapturedTransfers); + } + + private void retryCapturedTransfers() { + synchronized (captureLock) { + for (CapturedVote vote : java.util.List.copyOf(captured.values())) { + // Ordinary queued/running callbacks retain their original execution. + // Only receipts which already attempted a failed handoff need retry. + if (vote.transferFailureReported && !vote.transferring) transferLocked(vote); + } + } + } /** * Processes a validated vote. The overflow queue invokes this callback from @@ -35,7 +151,23 @@ public VotiferEvent(VotingPluginMain plugin) { * @param voteSite the validated service site * @param voteUsername the validated player name */ - public void processVote(String voteSite, String voteUsername) { + public void processVote(String voteSite, String voteUsername) { processVote(voteSite, voteUsername, 0L); } + + public void processVote(String voteSite, String voteUsername, long occurredAt) { + processVote(voteSite, voteUsername, occurredAt, UUID.randomUUID()); + } + + public void processVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) { + processCapturedVote(voteSite, voteUsername, occurredAt, localOccurrenceId); + } + + /** False retains overflow ownership; partial ordinary effects may still have occurred. */ + public boolean processVoteConfirmed(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) { + return processCapturedVote(voteSite, voteUsername, occurredAt, localOccurrenceId); + } + + private boolean processCapturedVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) { + java.util.Objects.requireNonNull(localOccurrenceId, "localOccurrenceId"); try { plugin.getServerData().addServiceSite(voteSite); if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() @@ -46,7 +178,7 @@ public void processVote(String voteSite, String voteUsername) { || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) { plugin.getLogger().severe( "Ignoring vote from votifier since a proxy vote transport is enabled; receive votes on the proxy or enable VotifierBypass, then check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups"); - return; + return true; } String matchSite = ""; @@ -77,22 +209,29 @@ public void processVote(String voteSite, String voteUsername) { if (plugin.getTimeChecker().isActiveProcessing() && plugin.getConfigFile().isQueueVotesDuringTimeChange()) { plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite); - plugin.getTimeQueueHandler().addVote(voteUsername, voteSite); - return; + plugin.getTimeQueueHandler().addVote(voteUsername, voteSite, occurredAt, localOccurrenceId); + return true; } String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite); PlayerVoteEvent voteEvent = new PlayerVoteEvent( plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true); + voteEvent.setLocalOccurrenceId(localOccurrenceId); + if (occurredAt > 0) voteEvent.setCanonicalOccurrenceTime(occurredAt); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isCancelled()) { plugin.debug("Vote cancelled"); } + // Bukkit may skip disabled listeners or catch their exceptions while + // returning normally. Dispatch alone is not confirmed processing. + return voteEvent.isNativeProcessingCompleted() + || voteEvent.isCancelled() && !voteEvent.isNativeProcessingStarted(); } catch (Exception e) { plugin.getLogger().severe("Error occured during vote processing"); e.printStackTrace(); + return false; // An exception does not prove ordinary accounting/rewards completed. } } @@ -103,6 +242,9 @@ public void processVote(String voteSite, String voteUsername) { */ @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onVotiferEvent(VotifierEvent event) { + if (!accepting) return; + final long occurredAt = System.currentTimeMillis(); + final UUID localOccurrenceId = UUID.randomUUID(); Vote vote = event.getVote(); String str = vote.getServiceName(); @@ -135,16 +277,52 @@ public void onVotiferEvent(VotifierEvent event) { plugin.debug("VoteSite: " + voteSite); plugin.debug("IP: " + IP); - try { - plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername)); - } catch (RejectedExecutionException rejected) { - VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); - if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) { - plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for " - + MinecraftUsernameValidator.sanitizeForLog(voteUsername)); - } else { - plugin.getLogger().warning("Vote executor saturated; queued Votifier vote for retry"); - } - } + CapturedVote capturedVote = new CapturedVote(voteUsername, voteSite, occurredAt, localOccurrenceId); + VotifierVoteOverflowQueue.CaptureAdmission admission; + synchronized (captureLock) { + if (!accepting) return; + var overflow = plugin.getVotifierVoteOverflowQueue(); + capturedVote.recoveryOwner = overflow; + if (captured.size() >= MAX_CAPTURED) { + // The overflow owns its independent bounded admission lane. + transferLocked(capturedVote); + return; + } + admission = overflow == null ? VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED + : overflow.reserveCapture(localOccurrenceId); + if (admission == VotifierVoteOverflowQueue.CaptureAdmission.REJECTED) { + plugin.getLogger().severe("Votifier capture capacity unavailable; vote was not admitted"); + return; + } + // Reserve known capacity and publish capture ownership together with + // respect to stop's snapshot. Unknown capacity remains provisional. + captured.put(localOccurrenceId, capturedVote); + } + if (admission == VotifierVoteOverflowQueue.CaptureAdmission.WAITING_FOR_LOAD) { + capturedVote.recoveryOwner.reserveCaptureAfterInitialization(localOccurrenceId, + admitted -> admitCaptured(capturedVote, admitted)); + } else admitCaptured(capturedVote, true); } + + private void admitCaptured(CapturedVote vote, boolean admitted) { + synchronized (captureLock) { + if (captured.get(vote.id) != vote) { + if (admitted && vote.recoveryOwner != null) vote.recoveryOwner.releaseCapture(vote.id); + return; + } + if (!admitted) { + captured.remove(vote.id); + plugin.getLogger().severe("Votifier capture capacity unavailable; vote was not admitted"); + return; + } + if (!accepting) { + if (!retirementQueued) transferLocked(vote); + return; + } + } + try { plugin.getVoteTimer().submit(() -> runCaptured(vote)); } + catch (RejectedExecutionException rejected) { + synchronized (captureLock) { transferLocked(vote); } + } + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 7ec1cb4c82..ecb8e70310 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -4,6 +4,9 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.SeekableByteChannel; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; @@ -17,10 +20,15 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.Properties; +import java.util.UUID; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; +import java.util.function.Consumer; import org.bukkit.configuration.file.YamlConfiguration; @@ -37,22 +45,65 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { /* Keep the overflow bounded independently of the executor's 256 admissions. */ private static final int MAX_ENTRIES = 256; + public static final int MAX_CAPTURED = 256; + private static final int MAX_RETAINED_ENTRIES = MAX_ENTRIES + MAX_CAPTURED; private static final long MAX_FILE_BYTES = 4L * 1024L * 1024L; private static final long RETRY_DELAY_MILLIS = 250L; private static final String QUEUE_FILE = "VotifierVoteQueue.yml"; + private static final String OWNER_FILE = "VotifierVoteQueue.yml.owner"; + private static final String SHARED_OWNER_PROPERTY_PREFIX = "com.bencodez.votingplugin.VotifierVoteOverflowQueue.owner."; + private static final int MAX_OWNER_RESERVATIONS = 1024; private final VotingPluginMain plugin; - private final BiConsumer processor; + @FunctionalInterface + public interface VoteProcessor { void process(String serviceSite, String username, long occurredAt); } + @FunctionalInterface + public interface OccurrenceProcessor { + void process(String serviceSite, String username, long occurredAt, UUID localOccurrenceId); + /** Legacy callbacks confirm completion by returning normally. */ + default boolean processConfirmed(String serviceSite, String username, long occurredAt, UUID localOccurrenceId) { + process(serviceSite, username, occurredAt, localOccurrenceId); + return true; + } + } + @FunctionalInterface + public interface ConfirmedOccurrenceProcessor { + boolean processConfirmed(String serviceSite, String username, long occurredAt, UUID localOccurrenceId); + } + private final OccurrenceProcessor processor; + private final java.util.function.BooleanSupplier processingAllowed; private final Path file; + private final Path ownerFile; + private FileChannel ownerChannel; + private FileLock ownerLock; + private String ownerKey; + private String ownerToken; + private Properties ownerProperties; private final ScheduledThreadPoolExecutor worker; private final Object lock = new Object(); private final Object persistenceWriteLock = new Object(); private final ArrayDeque entries = new ArrayDeque<>(); private boolean drainScheduled; + private boolean drainRetryScheduled; + private boolean captureRetryScheduled; private boolean persistenceScheduled; private boolean persistenceDirty; private boolean started; private boolean closed; + private boolean closing; + private final java.util.concurrent.CompletableFuture closePublication = new java.util.concurrent.CompletableFuture<>(); + private boolean closeFailureReported; + private volatile boolean loaded; + private volatile boolean loadFailed; + private int pendingIngress; + private int pendingCaptureAdmissions; + private int pendingRetirementCaptures; + private boolean retirementRequested; + private boolean initializationRetryScheduled; + private boolean initializationFailureReported; + private boolean queueFileObserved; + private final Set captureReservations = new java.util.HashSet<>(); + private final boolean asynchronousInitialization; private long stateVersion; private long durableVersion; @@ -63,16 +114,56 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { * @param processor callback receiving service site and player name */ public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer processor) { + this(plugin, (site, user, occurredAt, id) -> processor.accept(site, user), () -> true); + } + + /** Additive callback retains receipt occurrence and fences a retired startup generation. */ + public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processor, + java.util.function.BooleanSupplier processingAllowed) { + this(plugin, (site, user, time, id) -> processor.process(site, user, time), processingAllowed); + } + + public VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor, + java.util.function.BooleanSupplier processingAllowed) { + this(plugin, processor, processingAllowed, false); + } + + /** Memory-only construction; all loading and handoffs run on the existing queue worker. */ + public static VotifierVoteOverflowQueue initializeAsync(VotingPluginMain plugin, OccurrenceProcessor processor, + java.util.function.BooleanSupplier processingAllowed) { + return new VotifierVoteOverflowQueue(plugin, processor, processingAllowed, true); + } + + /** Named factory preserves existing void callback/lambda contracts without ambiguous overloads. */ + public static VotifierVoteOverflowQueue initializeConfirmedAsync(VotingPluginMain plugin, + ConfirmedOccurrenceProcessor processor, java.util.function.BooleanSupplier processingAllowed) { + java.util.Objects.requireNonNull(processor, "processor"); + return initializeAsync(plugin, new OccurrenceProcessor() { + @Override public void process(String site, String user, long time, UUID id) { + processor.processConfirmed(site, user, time, id); + } + @Override public boolean processConfirmed(String site, String user, long time, UUID id) { + return processor.processConfirmed(site, user, time, id); + } + }, processingAllowed); + } + + private VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor, + java.util.function.BooleanSupplier processingAllowed, boolean asynchronousInitialization) { + this.asynchronousInitialization = asynchronousInitialization; this.plugin = plugin; this.processor = processor; + this.processingAllowed = processingAllowed; this.file = new File(plugin.getDataFolder(), QUEUE_FILE).toPath(); + this.ownerFile = new File(plugin.getDataFolder(), OWNER_FILE).toPath(); this.worker = new ScheduledThreadPoolExecutor(1, runnable -> { Thread thread = new Thread(runnable, "VotingPlugin-Votifier-Overflow"); thread.setDaemon(true); return thread; }); this.worker.setRemoveOnCancelPolicy(true); - load(); + if (asynchronousInitialization) worker.execute(this::load); + else load(); } /** @@ -97,10 +188,88 @@ public void start() { * @return false when the bounded overflow is full or shutting down */ public boolean enqueue(String username, String serviceSite) { - if (username == null || serviceSite == null) return false; + return enqueue(username, serviceSite, System.currentTimeMillis()); + } + + public boolean enqueue(String username, String serviceSite, long occurredAt) { + return enqueue(username, serviceSite, occurredAt, UUID.randomUUID()); + } + + public boolean enqueue(String username, String serviceSite, long occurredAt, UUID localOccurrenceId) { + return enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, false); + } + + /** Transfer stays behind initial loading and is bounded independently of executor admissions. */ + public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, + Consumer completion) { + enqueueAfterInitialization(username, serviceSite, occurredAt, localOccurrenceId, () -> true, completion); + } + + /** A STARTED original callback retains execution ownership until its outcome is known. */ + public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, + java.util.function.BooleanSupplier executionReleased, Consumer completion) { + if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) { + completion.accept(false); + return; + } + var finished = new java.util.concurrent.atomic.AtomicBoolean(); + class Transfer implements Runnable { + void finish(boolean accepted) { + if (!finished.compareAndSet(false, true)) return; + synchronized (lock) { pendingIngress--; } + completion.accept(accepted); + } + @Override public void run() { + try { + synchronized (lock) { + if (!closed && !loaded) { + // Admission owns this receipt, including the saturated + // path which has no entry in the listener's capture map. + scheduleInitializationRetryLocked(); + worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + return; + } + } + } catch (RejectedExecutionException rejected) { finish(false); return; } + boolean accepted = false; + try { accepted = enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, true, executionReleased); } + finally { finish(accepted); } + } + } + boolean submitted = false; + synchronized (lock) { + if (!closed && !closing && pendingIngress < MAX_ENTRIES) { + pendingIngress++; + try { + worker.execute(new Transfer()); + submitted = true; + } catch (RejectedExecutionException rejected) { pendingIngress--; } + } + } + if (!submitted) completion.accept(false); + } + + private boolean enqueueStored(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, + boolean admittedTransfer) { + return enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, admittedTransfer, () -> true); + } + + private boolean enqueueStored(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, + boolean admittedTransfer, java.util.function.BooleanSupplier executionReleased) { + if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) return false; synchronized (lock) { - if (closed || entries.size() >= MAX_ENTRIES) return false; - entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis())); + if (closed || closing && !admittedTransfer || !loaded || loadFailed) return false; + var existing = occurrence(localOccurrenceId); + if (existing != null) { + boolean same = matches(existing, username, serviceSite, occurredAt); + if (same) captureReservations.remove(localOccurrenceId); + return same; + } + int entryLimit = closing && admittedTransfer ? MAX_RETAINED_ENTRIES : MAX_ENTRIES; + if (entries.size() >= entryLimit || !captureReservations.contains(localOccurrenceId) + && entries.size() + captureReservations.size() >= MAX_RETAINED_ENTRIES) return false; + entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId, executionReleased)); + captureReservations.remove(localOccurrenceId); stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); @@ -108,6 +277,162 @@ public boolean enqueue(String username, String serviceSite) { } } + public enum CaptureAdmission { ACCEPTED, WAITING_FOR_LOAD, REJECTED } + + /** Reserve final-snapshot capacity before the normal executor owns this receipt. */ + public CaptureAdmission reserveCapture(UUID id) { + java.util.Objects.requireNonNull(id); + synchronized (lock) { + if (closed || closing || loadFailed) return CaptureAdmission.REJECTED; + if (!loaded) return CaptureAdmission.WAITING_FOR_LOAD; + if (captureReservations.contains(id)) return CaptureAdmission.ACCEPTED; + if (captureReservations.size() >= MAX_CAPTURED + || entries.size() + captureReservations.size() >= MAX_RETAINED_ENTRIES) + return CaptureAdmission.REJECTED; + captureReservations.add(id); + return CaptureAdmission.ACCEPTED; + } + } + + /** Unknown disk capacity is provisional; keep at most one deferred admission per capture. */ + public void reserveCaptureAfterInitialization(UUID id, Consumer completion) { + java.util.Objects.requireNonNull(id); java.util.Objects.requireNonNull(completion); + boolean admitted; + synchronized (lock) { + admitted = !closed && !closing && pendingCaptureAdmissions < MAX_CAPTURED; + if (admitted) pendingCaptureAdmissions++; + } + if (!admitted) { completion.accept(false); return; } + var finished = new java.util.concurrent.atomic.AtomicBoolean(); + class Admission implements Runnable { + void finish(boolean accepted) { + if (!finished.compareAndSet(false, true)) return; + synchronized (lock) { pendingCaptureAdmissions--; } + completion.accept(accepted); + } + @Override public void run() { + var result = reserveCapture(id); + if (result != CaptureAdmission.WAITING_FOR_LOAD) { + finish(result == CaptureAdmission.ACCEPTED); + return; + } + try { worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); } + catch (RejectedExecutionException rejected) { finish(false); } + } + } + var admission = new Admission(); + try { worker.execute(admission); } + catch (RejectedExecutionException rejected) { admission.finish(false); } + } + + public void releaseCapture(UUID id) { + synchronized (lock) { captureReservations.remove(id); } + } + + public record RetiringCapture(String username, String serviceSite, long time, UUID id, + java.util.function.BooleanSupplier executionReleased, + java.util.function.BooleanSupplier completed, Consumer completion) { + public RetiringCapture { + java.util.Objects.requireNonNull(username); java.util.Objects.requireNonNull(serviceSite); + java.util.Objects.requireNonNull(id); java.util.Objects.requireNonNull(executionReleased); + java.util.Objects.requireNonNull(completed); java.util.Objects.requireNonNull(completion); + if (time <= 0) throw new IllegalArgumentException("capture receipt time must be positive"); + } + } + + /** One FIFO batch, before close's final snapshot; normal overflow admission stays capped at256. */ + public void retireCaptures(List captures) { + var snapshot = List.copyOf(captures); + if (snapshot.size() > MAX_CAPTURED) throw new IllegalArgumentException("capture retirement exceeds capacity"); + if (snapshot.isEmpty()) return; + class Transfer implements Runnable { + @Override public void run() { + synchronized (lock) { + if (!closed && (!loaded || loadFailed)) { + scheduleInitializationRetryLocked(); + worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + return; + } + } + boolean[] results = new boolean[snapshot.size()]; + try { + for (int index = 0; index < snapshot.size(); index++) { + var capture = snapshot.get(index); + boolean completed = false; + try { completed = capture.completed().getAsBoolean(); } + catch (RuntimeException | Error unknown) { /* Retain unconfirmed completion. */ } + synchronized (lock) { + if (!closed && loaded && !loadFailed) { + var existing = occurrence(capture.id()); + if (completed) { + captureReservations.remove(capture.id()); + results[index] = true; + } else if (existing != null) { + results[index] = matches(existing, capture.username(), capture.serviceSite(), capture.time()); + if (results[index]) captureReservations.remove(capture.id()); + } else if (entries.size() < MAX_RETAINED_ENTRIES + && (captureReservations.contains(capture.id()) + || entries.size() + captureReservations.size() < MAX_RETAINED_ENTRIES)) { + entries.addLast(new PendingVote(capture.username(), capture.serviceSite(), capture.time(), + capture.id(), capture.executionReleased())); + captureReservations.remove(capture.id()); + stateVersion++; requestPersistenceLocked(); + results[index] = true; + } + } + } + } + // Retain the whole batch before any external completion callback. + for (int index = 0; index < snapshot.size(); index++) { + try { snapshot.get(index).completion().accept(results[index]); } + catch (RuntimeException | Error failure) { + try { plugin.getLogger().warning("Votifier retirement callback failed; queue ownership is retained"); } + catch (RuntimeException | Error ignored) { } + } + } + } finally { + synchronized (lock) { pendingRetirementCaptures -= snapshot.size(); } + } + } + } + boolean submitted = false; + synchronized (lock) { + if (!closed && !closing && pendingRetirementCaptures + snapshot.size() <= MAX_CAPTURED) { + pendingRetirementCaptures += snapshot.size(); + try { worker.execute(new Transfer()); retirementRequested = true; submitted = true; } + catch (RejectedExecutionException rejected) { pendingRetirementCaptures -= snapshot.size(); } + } + } + if (!submitted) for (var capture : snapshot) { + try { capture.completion().accept(false); } + catch (RuntimeException | Error ignored) { /* Reject every bounded caller, including the suffix. */ } + } + } + + private void scheduleInitializationRetryLocked() { + if (closed || initializationRetryScheduled || loaded && !loadFailed) return; + initializationRetryScheduled = true; + worker.schedule(() -> { + synchronized (lock) { + initializationRetryScheduled = false; + if (closed) return; + } + load(); + }, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + } + + private PendingVote occurrence(UUID id) { + for (var vote : entries) if (vote.localOccurrenceId.equals(id)) return vote; + return null; + } + + private static boolean matches(PendingVote vote, String username, String site, long time) { + return vote.username.equals(username) && vote.serviceSite.equals(site) && vote.time == time; + } + + /** Completion only; transfers separately report load/capacity admission failure. */ + public boolean isInitializationComplete() { return loaded; } + /** * Returns the number of votes waiting for admission or completion. * @@ -126,7 +451,7 @@ private void scheduleDrain() { } private void scheduleDrainLocked() { - if (closed || !started || drainScheduled) return; + if (closed || closing || !loaded || loadFailed || !started || drainScheduled) return; drainScheduled = true; try { worker.schedule(this::drain, 0, TimeUnit.MILLISECONDS); @@ -137,7 +462,14 @@ private void scheduleDrainLocked() { private void drain() { while (true) { + // A generation fence parks its receipts instead of repeatedly submitting + // callbacks which are forbidden to execute (including inline submitters). + if (!processingAllowed.getAsBoolean()) { + synchronized (lock) { drainScheduled = false; } + return; + } synchronized (lock) { + if (closed || closing || !loaded || loadFailed) { drainScheduled = false; return; } if (durableVersion != stateVersion) { drainScheduled = false; return; @@ -152,10 +484,23 @@ private void drain() { // Serialize admission with enqueue so the version proven durable // above cannot change in the gap before submit accepts this vote. plugin.getVoteTimer().submit(() -> { + boolean attempted = false; + boolean completed = false; try { - processor.accept(pending.serviceSite, pending.username); + if (!processingAllowed.getAsBoolean()) return; + synchronized (lock) { + if (closed || closing || !entries.contains(pending)) return; + } + attempted = true; + completed = processor.processConfirmed(pending.serviceSite, pending.username, + pending.time, pending.localOccurrenceId) && !Thread.currentThread().isInterrupted(); + } catch (RuntimeException | Error failure) { + plugin.getLogger().warning("Queued Votifier processing failed; retained receipt for retry: " + + failure.getClass().getSimpleName()); + throw failure; } finally { - acknowledge(pending); + if (completed) acknowledge(pending); + else retainFailedAttempt(pending, attempted); } }); } catch (RejectedExecutionException rejected) { @@ -168,27 +513,102 @@ private void drain() { // The queue is closing; the persisted entry remains for restart. } return; + } catch (RuntimeException | Error failure) { + // An inline submitter can expose callback failure on this worker. + // Retain retry ownership without leaving drainScheduled stuck true. + drainScheduled = false; + retainFailedAttempt(pending, true); + throw failure; } } } } private PendingVote nextUnsubmittedLocked() { + long now = System.nanoTime(); + boolean retryWaiting = false; for (PendingVote pending : entries) { - if (!pending.submitted) return pending; + if (pending.submitted || !pending.executionReleased.getAsBoolean()) continue; + if (pending.retryNotBeforeNanos != 0 && now - pending.retryNotBeforeNanos < 0) { + retryWaiting = true; + continue; + } + return pending; } + if (retryWaiting) scheduleDrainRetryLocked(); return null; } + private void retainFailedAttempt(PendingVote pending, boolean attempted) { + synchronized (lock) { + pending.submitted = false; + if (closed || closing || !entries.contains(pending) || !attempted) return; + // Inline submitters must not retry this same receipt in the current drain. + pending.retryNotBeforeNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RETRY_DELAY_MILLIS); + scheduleDrainRetryLocked(); + } + } + + private void scheduleDrainRetryLocked() { + if (closed || closing || !loaded || loadFailed || !started || drainRetryScheduled) return; + drainRetryScheduled = true; + try { + worker.schedule(() -> { + synchronized (lock) { + drainRetryScheduled = false; + scheduleDrainLocked(); + } + }, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException rejected) { drainRetryScheduled = false; } + } + private void acknowledge(PendingVote pending) { synchronized (lock) { - entries.remove(pending); + if (closed) { + plugin.getLogger().warning("Completed Votifier callback outlived queue shutdown; retained occurrence requires reconciliation"); + return; + } + if (!entries.remove(pending)) return; stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); } } + /** Reconcile a receipt whose original normal callback completed during transfer. */ + public void acknowledgeOccurrence(UUID localOccurrenceId) { + synchronized (lock) { + if (closed) { + // A nonterminating callback outlived the final snapshot. A retired + // writer cannot overwrite a successor's queue; retain uncertainty. + plugin.getLogger().warning("Completed Votifier callback outlived queue shutdown; retained occurrence requires reconciliation"); + return; + } + if (!entries.removeIf(pending -> pending.localOccurrenceId.equals(localOccurrenceId))) return; + stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); + } + } + /** Wake the same bounded drain when an original transferred callback finishes uncertainly. */ + public void resumeTransfers() { scheduleDrain(); } + + /** One wakeup for the ingress listener's bounded failed-transfer captures. + * Retry runs outside the queue monitor and never adds another executor or waiter list. */ + public void retryCaptureTransfers(Runnable retry) { + synchronized (lock) { + if (closed || closing || captureRetryScheduled) return; + captureRetryScheduled = true; + try { + worker.schedule(() -> { + synchronized (lock) { + captureRetryScheduled = false; + if (closed || closing) return; + } + retry.run(); + }, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException rejected) { captureRetryScheduled = false; } + } + } + private void requestPersistenceLocked() { persistenceDirty = true; if (persistenceScheduled) return; @@ -239,18 +659,32 @@ private void persistLoop() { } private void load() { + ArrayDeque recovered = new ArrayDeque<>(); + boolean successful = false; try { - if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return; + synchronized (lock) { if (closed || loaded && !loadFailed) return; } + if (!acquireOwner()) { + synchronized (lock) { + if (asynchronousInitialization && !closing || pendingRetirementCaptures > 0 || pendingIngress > 0) { + scheduleInitializationRetryLocked(); + return; + } + } + throw new IOException("queue file is owned by another active instance"); + } + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + if (queueFileObserved) throw new IOException("queue file disappeared before recovery"); + successful = true; + return; + } + queueFileObserved = true; YamlConfiguration yaml = new YamlConfiguration(); yaml.loadFromString(readQueueFile()); Object raw = yaml.get("Votes"); - if (!(raw instanceof List values)) return; + if (!(raw instanceof List values)) throw new IOException("queue votes are not a list"); + if (values.size() > MAX_RETAINED_ENTRIES) throw new IOException("queue exceeds entry capacity"); boolean skipped = false; for (Object value : values) { - if (entries.size() >= MAX_ENTRIES) { - skipped = true; - break; - } if (!(value instanceof Map map)) { skipped = true; continue; @@ -265,16 +699,111 @@ private void load() { skipped = true; continue; } - entries.addLast(new PendingVote(name, site, timestamp.longValue())); + UUID localOccurrenceId; + if (!map.containsKey("LocalOccurrenceId")) { + localOccurrenceId = UUID.randomUUID(); + skipped = true; // Publish the migration before admitting any recovered row. + } else { + Object rawId = map.get("LocalOccurrenceId"); + try { + if (!(rawId instanceof String id)) throw new IllegalArgumentException(); + localOccurrenceId = UUID.fromString(id); + if (!localOccurrenceId.toString().equalsIgnoreCase(id)) throw new IllegalArgumentException(); + } catch (IllegalArgumentException malformed) { + skipped = true; + continue; // Explicit corrupt IDs cannot become fresh occurrences. + } + } + recovered.addLast(new PendingVote(name, site, timestamp.longValue(), localOccurrenceId)); } + synchronized (lock) { entries.clear(); entries.addAll(recovered); } if (skipped) { synchronized (lock) { stateVersion++; requestPersistenceLocked(); } } + successful = true; } catch (Exception failure) { - plugin.getLogger().warning("Unable to load queued Votifier votes: " + failure.getClass().getSimpleName()); + loadFailed = true; + if (!initializationFailureReported) { + initializationFailureReported = true; + plugin.getLogger().warning("Unable to load queued Votifier votes: " + failure.getClass().getSimpleName()); + } + } finally { + if (ownerLock == null && !loadFailed) return; + if (successful) loadFailed = false; + loaded = true; + synchronized (lock) { + scheduleDrainLocked(); + if (loadFailed && pendingRetirementCaptures > 0) scheduleInitializationRetryLocked(); + } + } + } + + private boolean acquireOwner() throws IOException { + if (ownerLock != null && ownerLock.isValid()) return true; + Path parent = ownerFile.toAbsolutePath().normalize().getParent(); + if (parent == null || Files.isSymbolicLink(ownerFile)) throw new IOException("queue ownership file is unsafe"); + Files.createDirectories(parent); + String key = SHARED_OWNER_PROPERTY_PREFIX + hashOwnerPath(parent.toRealPath().resolve(ownerFile.getFileName())); + String token = UUID.randomUUID().toString(); + Properties properties = System.getProperties(); + synchronized (properties) { + if (properties.getProperty(key) != null) return false; + int reservations = 0; + for (Object property : properties.keySet()) + if (property instanceof String name && name.startsWith(SHARED_OWNER_PROPERTY_PREFIX)) reservations++; + if (reservations >= MAX_OWNER_RESERVATIONS) return false; + properties.setProperty(key, token); + } + try { + FileChannel channel = FileChannel.open(ownerFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS); + try { + FileLock acquired; + try { acquired = channel.tryLock(); } + catch (OverlappingFileLockException busy) { acquired = null; } + if (acquired == null) { channel.close(); clearOwnerReservation(properties, key, token); return false; } + ownerKey = key; ownerToken = token; ownerProperties = properties; ownerChannel = channel; ownerLock = acquired; + return true; + } catch (IOException | RuntimeException failure) { + channel.close(); + clearOwnerReservation(properties, key, token); + throw failure; + } + } catch (IOException | RuntimeException failure) { + clearOwnerReservation(properties, key, token); + throw failure; + } + } + + private void releaseOwner() { + try { if (ownerLock != null) ownerLock.release(); } catch (IOException ignored) { } + try { if (ownerChannel != null) ownerChannel.close(); } catch (IOException ignored) { } + clearOwnerReservation(ownerProperties, ownerKey, ownerToken); + ownerLock = null; + ownerChannel = null; + ownerKey = null; + ownerToken = null; + ownerProperties = null; + } + + private static void clearOwnerReservation(Properties properties, String key, String token) { + if (properties == null || key == null || token == null) return; + synchronized (properties) { + if (token.equals(properties.getProperty(key))) properties.remove(key); + } + } + + private static String hashOwnerPath(Path path) throws IOException { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(path.toString().getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte value : digest) result.append(String.format("%02x", value)); + return result.toString(); + } catch (NoSuchAlgorithmException impossible) { + throw new IOException("SHA-256 unavailable", impossible); } } @@ -298,6 +827,8 @@ private void writeSnapshot(List snapshot) throws IOException { } private void writeSnapshotLocked(List snapshot) throws IOException { + if (ownerLock == null || !ownerLock.isValid()) throw new IOException("queue file ownership is unavailable"); + if (snapshot.size() > MAX_RETAINED_ENTRIES) throw new IOException("queue snapshot exceeds entry capacity"); YamlConfiguration yaml = new YamlConfiguration(); List> values = new ArrayList<>(); for (PendingVote pending : snapshot) { @@ -305,6 +836,7 @@ private void writeSnapshotLocked(List snapshot) throws IOException value.put("Username", pending.username); value.put("ServiceSite", pending.serviceSite); value.put("Time", pending.time); + value.put("LocalOccurrenceId", pending.localOccurrenceId.toString()); values.add(value); } yaml.set("Votes", values); @@ -330,21 +862,26 @@ private void writeSnapshotLocked(List snapshot) throws IOException @Override public void close() { - List snapshot; + List snapshot = null; + boolean initializedWorker; synchronized (lock) { - if (closed) return; - closed = true; - // At-least-once delivery: retain any unacknowledged callback. A callback - // interrupted during shutdown must be replayed rather than silently lost. - snapshot = new ArrayList<>(entries); - persistenceScheduled = false; + initializedWorker = asynchronousInitialization || retirementRequested || pendingIngress > 0; + if (!initializedWorker) { + if (closed) return; + closed = true; + // Legacy callers keep their existing close path until retirement admission. + snapshot = new ArrayList<>(entries); + persistenceScheduled = false; + } } + if (initializedWorker) { closeInitializedWorker(); return; } worker.shutdownNow(); try { worker.awaitTermination(1, TimeUnit.SECONDS); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + if (loadFailed) { releaseOwner(); return; } try { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); @@ -353,18 +890,87 @@ public void close() { plugin.getLogger().warning("Unable to persist queued Votifier votes during shutdown: " + failure.getClass().getSimpleName()); } + releaseOwner(); } + private void closeInitializedWorker() { + synchronized (lock) { + if (!closing && !closed) { + closing = true; + // Keep the existing worker and file owner through failed final publication. + worker.execute(this::finishClosePublication); + } + } + try { closePublication.get(1, TimeUnit.SECONDS); } + catch (java.util.concurrent.TimeoutException pending) { + plugin.getLogger().warning("Votifier queue shutdown publication is still pending on its worker; recovery is unconfirmed"); + } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + catch (java.util.concurrent.ExecutionException failure) { + throw new IllegalStateException("Votifier queue shutdown publication failed", failure.getCause()); + } + } + + private void finishClosePublication() { + List snapshot; + long version; + synchronized (lock) { + if (closed) return; + if (pendingRetirementCaptures > 0 || pendingIngress > 0) { + if (!loaded || loadFailed) scheduleInitializationRetryLocked(); + worker.schedule(this::finishClosePublication, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + return; + } + snapshot = new ArrayList<>(entries); + version = stateVersion; + } + if (loadFailed || !loaded) { + // Unreadable/unowned storage is unknown; never replace it with an empty guess. + synchronized (lock) { closed = true; } + releaseOwner(); worker.shutdown(); closePublication.complete(null); + return; + } + try { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); } } + catch (IOException failure) { + if (!closeFailureReported) { + closeFailureReported = true; + try { plugin.getLogger().warning("Unable to publish final Votifier queue; its worker retains ownership for retry"); } + catch (RuntimeException | Error ignored) { } + } + worker.schedule(this::finishClosePublication, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + return; + } + synchronized (lock) { + // A confirmed original callback may have removed a receipt during IO. + if (version != stateVersion) { + worker.execute(this::finishClosePublication); + return; + } + closed = true; + durableVersion = version; + } + releaseOwner(); worker.shutdown(); closePublication.complete(null); + } + private static final class PendingVote { private final String username; private final String serviceSite; private final long time; + private final UUID localOccurrenceId; + private final java.util.function.BooleanSupplier executionReleased; private boolean submitted; + private long retryNotBeforeNanos; + + private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId) { + this(username, serviceSite, time, localOccurrenceId, () -> true); + } - private PendingVote(String username, String serviceSite, long time) { + private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId, + java.util.function.BooleanSupplier executionReleased) { this.username = username; this.serviceSite = serviceSite; this.time = time; + this.localOccurrenceId = localOccurrenceId; + this.executionReleased = java.util.Objects.requireNonNull(executionReleased); } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java index 5733ed35ac..8007df6675 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java @@ -40,6 +40,19 @@ public class OfflineBungeeVote { @Getter @Setter private String text; + + public long getCanonicalOccurrenceTime() { + Long original = VoteOccurrenceMetadata.read(text); + return original == null ? 0L : original; + } + + public void setCanonicalOccurrenceTime(long occurredAt) { + text = VoteOccurrenceMetadata.store(text, occurredAt); + } + + public void setText(String updated) { + text = VoteOccurrenceMetadata.preserve(text, updated); + } @Getter private long time; @Getter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteOccurrenceMetadata.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteOccurrenceMetadata.java new file mode 100644 index 0000000000..3ce808ac79 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteOccurrenceMetadata.java @@ -0,0 +1,46 @@ +package com.bencodez.votingplugin.proxy; + +/** Optional occurrence metadata in existing opaque cache payloads, separate from cooldown time. */ +public final class VoteOccurrenceMetadata { + private static final String MARKER = "//vp-occurrence-time:"; + + private VoteOccurrenceMetadata() { } + + /** Null means an older payload without metadata; -1 means explicitly invalid metadata. */ + public static Long read(String payload) { + if (payload == null) return null; + int marker = payload.lastIndexOf(MARKER); + if (marker < 0) return null; + if (marker != payload.indexOf(MARKER)) return -1L; + return parse(payload.substring(marker + MARKER.length(), + Math.min(payload.length(), marker + MARKER.length() + 21))); + } + + public static long parse(String raw) { + if (raw == null || raw.isEmpty() || raw.length() > 20) return -1L; + try { + long time = Long.parseLong(raw); + return time > 0 ? time : -1L; + } catch (NumberFormatException invalid) { + return -1L; + } + } + + /** Existing v1/v2 totals parsers ignore this tagged trailing token. */ + public static String store(String payload, long occurredAt) { + String text = payload == null ? "" : payload; + int marker = text.indexOf(MARKER); + if (marker >= 0) text = text.substring(0, marker); + return text + MARKER + (occurredAt > 0 ? occurredAt : -1L); + } + + /** An occurrence tag alone does not represent a processed totals snapshot. */ + public static boolean hasPayload(String payload) { + return payload != null && !payload.isEmpty() && !payload.startsWith(MARKER); + } + + public static String preserve(String oldPayload, String updatedPayload) { + Long original = read(oldPayload); + return original == null ? updatedPayload : store(updatedPayload, original); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index bee05e7294..a7e3a0ea90 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -181,6 +181,7 @@ private static final class LiveVoteRetryState { private String service; private String uuid; private long time; + private long canonicalOccurrenceTime; private boolean realVote; private boolean playerOnline; private boolean delayValidated; @@ -200,6 +201,7 @@ private enum Phase { private final boolean realVote; private final boolean timeQueue; private final long queueTime; + private final long canonicalOccurrenceTime; private final VoteTotalsSnapshot totals; private final String uuid; private final UUID voteId; @@ -212,7 +214,8 @@ private enum Phase { private MultiProxyVoteRetry(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID voteId, String origin, boolean delayValidated, - boolean delayValidationKnown) { + boolean delayValidationKnown, long canonicalOccurrenceTime) { + this.canonicalOccurrenceTime = canonicalOccurrenceTime; this.player = player; this.service = service; this.realVote = realVote; @@ -2895,7 +2898,8 @@ public void setEncryptionHandler(EncryptionHandler encryptionHandler1) { @Override public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid) { - vote(player, service, realVote, timeQueue, queueTime, text, uuid); + // Origin-less forwarding cannot establish an original canonical occurrence. + vote(player, service, realVote, timeQueue, queueTime, text, uuid, null, null, false, false, -1L); } @Override @@ -2918,6 +2922,14 @@ public void triggerVote(String player, String service, boolean realVote, boolean delayValidated, delayValidationKnown); } + @Override + public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId, String origin, boolean delayValidated, + boolean delayValidationKnown, long canonicalOccurrenceTime) { + receiveMultiProxyVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin, + delayValidated, delayValidationKnown, canonicalOccurrenceTime); + } + @Override public void onMultiProxyVoteAcknowledged(UUID voteId, String recipient) { handleMultiProxyVoteAcknowledgement(voteId, recipient); @@ -2954,6 +2966,13 @@ protected synchronized void receiveMultiProxyVote(String player, String service, protected synchronized void receiveMultiProxyVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID wireVoteId, String origin, boolean delayValidated, boolean delayValidationKnown) { + receiveMultiProxyVote(player, service, realVote, timeQueue, queueTime, totals, uuid, wireVoteId, origin, + delayValidated, delayValidationKnown, queueTime); + } + + protected synchronized void receiveMultiProxyVote(String player, String service, boolean realVote, + boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID wireVoteId, String origin, + boolean delayValidated, boolean delayValidationKnown, long canonicalOccurrenceTime) { UUID voteId = wireVoteId == null ? UUID.randomUUID() : wireVoteId; if (completedMultiProxyVotes.containsKey(voteId)) { acknowledgeCompletedMultiProxyVote(voteId, origin); @@ -2972,7 +2991,7 @@ protected synchronized void receiveMultiProxyVote(String player, String service, // this process heap and is loaded again after a restart. It also lets the // normal queue processor drain the spill as retry slots become available. if (retainForwardedVoteOverflow(player, service, realVote, totals, uuid, voteId, queueTime, origin, - delayValidated, delayValidationKnown)) { + delayValidated, delayValidationKnown, canonicalOccurrenceTime)) { scheduleTimeVoteRetry(); return; } @@ -2980,7 +2999,7 @@ protected synchronized void receiveMultiProxyVote(String player, String service, return; } retry = new MultiProxyVoteRetry(player, service, realVote, timeQueue, queueTime, totals, uuid, voteId, origin, - delayValidated, delayValidationKnown); + delayValidated, delayValidationKnown, canonicalOccurrenceTime); multiProxyVoteRetries.put(voteId, retry); } if (!retry.scheduled) attemptMultiProxyVote(retry); @@ -2989,7 +3008,7 @@ protected synchronized void receiveMultiProxyVote(String player, String service, /** Durably spills an over-capacity forwarded vote into the normal replay queue. */ private boolean retainForwardedVoteOverflow(String player, String service, boolean realVote, VoteTotalsSnapshot totals, String uuid, UUID voteId, long queueTime, String origin, boolean delayValidated, - boolean delayValidationKnown) { + boolean delayValidationKnown, long canonicalOccurrenceTime) { if (voteId == null || player == null || service == null || uuid == null) return false; for (VoteTimeQueue queued : getVoteCacheHandler().getTimeChangeQueue()) { if (voteId.equals(queued.getVoteId())) return true; @@ -2997,6 +3016,7 @@ private boolean retainForwardedVoteOverflow(String player, String service, boole long time = queueTime == 0L ? System.currentTimeMillis() : queueTime; VoteTimeQueue queued = new VoteTimeQueue(voteId, player, service, time, false, Collections.emptySet(), Collections.emptySet(), totals == null ? "" : totals.toString(), false, uuid); + queued.setCanonicalOccurrenceTime(canonicalOccurrenceTime); queued.setRealVote(realVote); queued.setMultiProxyOrigin(origin == null ? "" : origin); if (delayValidationKnown) queued.setDelayValidated(delayValidated); @@ -3074,6 +3094,7 @@ private synchronized void attemptMultiProxyVote(MultiProxyVoteRetry retry, boole private boolean persistDeferredMultiProxyReceipt(MultiProxyVoteRetry retry) { VoteTimeQueue queued = findUnprocessedQueuedVote(retry.voteId); if (queued == null) return false; + queued.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime); queued.setMultiProxyOrigin(retry.origin); queued.setRealVote(retry.realVote); if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated); @@ -3620,6 +3641,7 @@ private boolean retainCompletedMultiProxyRetry(VoteCacheHandler cache, MultiProx // completion fence: its origin is persisted by PERSIST_DEFERRED_RECEIPT. if (!queued.isProcessed()) return false; queued.setProcessed(true); + queued.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime); queued.setRealVote(retry.realVote); queued.setMultiProxyOrigin(retry.origin); if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated); @@ -3631,6 +3653,7 @@ private boolean retainCompletedMultiProxyRetry(VoteCacheHandler cache, MultiProx retry.queueTime == 0L ? System.currentTimeMillis() : retry.queueTime, false, Collections.emptySet(), Collections.emptySet(), retry.totals == null ? "" : retry.totals.toString(), true, retry.uuid); + quarantine.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime); quarantine.setRealVote(retry.realVote); quarantine.setMultiProxyOrigin(retry.origin); if (retry.delayValidationKnown) quarantine.setDelayValidated(retry.delayValidated); @@ -4062,7 +4085,7 @@ public synchronized void processQueue() { vote.setRealVote(queuedRealVote); vote.setDeliveryStateDirty(true); } - VoteTotalsSnapshot queuedTotals = forwardedTotals[1].isEmpty() ? null + VoteTotalsSnapshot queuedTotals = !VoteOccurrenceMetadata.hasPayload(forwardedTotals[1]) ? null : VoteTotalsSnapshot.parseStorage(forwardedTotals[1]); QueuedVoteResult result = replayQueuedVote(vote, queuedTotals, queuedRealVote); if (result == QueuedVoteResult.RETRY || result == QueuedVoteResult.RETRY_NONBLOCKING) { @@ -6051,6 +6074,9 @@ private boolean sendVoteDelayRejected(UUID voteId, String player, String uuid, S private JsonEnvelope cachedVoteEnvelope(OfflineBungeeVote vote, boolean online, boolean broadcast, int num, int numberOfVotes) { + // Older cache rows may have been timestamped by an intermediate receiver. + // Preserve their normal time, but do not invent date occurrence provenance. + String payload = VoteOccurrenceMetadata.store(vote.getText(), vote.getCanonicalOccurrenceTime()); if (vote.hasCachedVoteBatch()) { num = vote.getCachedBatchNumber(); numberOfVotes = vote.getCachedBatchSize(); @@ -6059,18 +6085,18 @@ private JsonEnvelope cachedVoteEnvelope(OfflineBungeeVote vote, boolean online, if (vote.isDelayValidationKnown()) { envelope = online ? VotingPluginWire.voteOnline(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(), - resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(), + resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(), getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes, vote.isDelayValidated()) : VotingPluginWire.vote(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(), - resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(), + resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(), getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes, vote.isDelayValidated()); } else { envelope = online ? VotingPluginWire.voteOnline(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(), - resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(), + resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(), getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes) : VotingPluginWire.vote(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(), - resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(), + resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(), getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes); } // A cache entry is only known to have passed the proxy's delay gate when @@ -6221,6 +6247,16 @@ public synchronized void vote(String player, String service, boolean realVote, b } } + /** Preserves captured receipt time independently of ordinary processing/cooldown time. */ + public synchronized void vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId, long occurredAt) { + VoteTimeQueue retained = findQueuedVote(voteId); + if (vote(player, service, realVote, timeQueue, queueTime, text, uuid, retained, voteId, + false, false, occurredAt) == QueuedVoteResult.RETRY) { + throw new VoteRetryException(); + } + } + /** Releases retry-only state after the bounded event-listener retries are exhausted. */ public synchronized void abandonLiveVoteRetry(UUID voteId) { if (voteId != null) liveVoteRetries.remove(voteId); @@ -6245,6 +6281,7 @@ public synchronized boolean retainIncomingVoteForRestart(PendingIncomingVote pen public synchronized VoteTimeQueue snapshotIncomingVoteForRestart(PendingIncomingVote pending) { VoteTimeQueue queued = new VoteTimeQueue(pending.getVoteId(), pending.getPlayer(), pending.getService(), pending.getAcceptedAt()); + queued.setCanonicalOccurrenceTime(pending.getAcceptedAt()); LiveVoteRetryState retry = liveVoteRetries.get(pending.getVoteId()); if (retry != null) { queued.setUuid(retry.uuid == null ? "" : retry.uuid); @@ -6275,6 +6312,7 @@ public synchronized VoteTimeQueue snapshotIncomingVoteForRestart(PendingIncoming queued.setMultiProxyForwardingHandled(retry.multiProxyForwardingHandled); if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated); if (retry.totals != null) queued.setTotals(retry.totals.toString()); + if (retry.canonicalOccurrenceTime != 0L) queued.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime); } return queued; } @@ -6299,7 +6337,7 @@ public synchronized boolean retainIncomingVoteForRestart(VoteTimeQueue recovery) if (retry == null && !recovery.isVotePartyApplied() && !recovery.isTotalsApplied() && recovery.isRealVote() && !recovery.isWasOnlineKnown() && (recovery.getUuid() == null || recovery.getUuid().isEmpty()) - && (recovery.getTotals() == null || recovery.getTotals().isEmpty())) return true; + && !VoteOccurrenceMetadata.hasPayload(recovery.getTotals())) return true; VoteTimeQueue state = retry == null ? recovery : snapshotIncomingVoteForRestart(new PendingIncomingVote(recovery.getVoteId(), recovery.getName(), recovery.getService(), recovery.getTime())); @@ -6347,6 +6385,13 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId, boolean upstreamDelayValidated, boolean upstreamDelayValidationKnown) { + return vote(player, service, realVote, timeQueue, queueTime, text, uuid, queuedVote, requestedVoteId, + upstreamDelayValidated, upstreamDelayValidationKnown, 0L); + } + + private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue, + long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId, + boolean upstreamDelayValidated, boolean upstreamDelayValidationKnown, long upstreamOccurrenceTime) { try { String requestPlayer = player; if (!ServiceSiteValidator.isValid(service)) { @@ -6470,6 +6515,13 @@ else if (!restoredAcceptedServers.isEmpty()) : (queueTime != 0 ? queueTime : LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + MultiProxyVoteRetry forwardedOccurrence = multiProxyVoteRetries.get(voteId); + long canonicalOccurrenceTime = retryState != null ? retryState.canonicalOccurrenceTime + : upstreamOccurrenceTime != 0L ? upstreamOccurrenceTime + : forwardedOccurrence != null ? forwardedOccurrence.canonicalOccurrenceTime + : queuedVote == null ? time + : queuedVote.getCanonicalOccurrenceTime() == 0L ? -1L : queuedVote.getCanonicalOccurrenceTime(); + Set broadcastTargets = queuedVote == null ? new LinkedHashSet<>() : new LinkedHashSet<>(queuedVote.getBroadcastTargets()); Set broadcastForwardedServers = queuedVote == null ? new LinkedHashSet<>() @@ -6539,6 +6591,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { VoteTimeQueue delayedVote = new VoteTimeQueue(voteId, player, service, time, proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, projectedTotals == null ? "" : projectedTotals.toString(), false, uuid); + if (canonicalOccurrenceTime != 0L) delayedVote.setCanonicalOccurrenceTime(canonicalOccurrenceTime); delayedVote.setRealVote(realVote); delayedVote.setWasOnline(playerOnline); if (authoritativeDelayValidationKnown) { @@ -6579,12 +6632,14 @@ && getGlobalDataHandler().isTimeChangedHappened()) { } retryState.totalsInput = data; if (queuedVote != null && queuedVote.isTotalsApplied()) { - retryState.totals = text != null ? text : VoteTotalsSnapshot.parseStorage(queuedVote.getTotals()); + retryState.totals = text != null ? text : VoteOccurrenceMetadata.hasPayload(queuedVote.getTotals()) + ? VoteTotalsSnapshot.parseStorage(queuedVote.getTotals()) : null; } retryState.player = requestPlayer; retryState.service = service; retryState.uuid = uuid; retryState.time = time; + retryState.canonicalOccurrenceTime = canonicalOccurrenceTime; retryState.realVote = realVote; retryState.playerOnline = playerOnline; retryState.delayValidated = authoritativeDelayValidated; @@ -6693,6 +6748,9 @@ && getGlobalDataHandler().isTimeChangedHappened()) { text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0); } + String votePayload = canonicalOccurrenceTime == 0L ? text.toString() + : VoteOccurrenceMetadata.store(text.toString(), canonicalOccurrenceTime); + VoteLogStatus voteStatus = VoteLogStatus.IMMEDIATE; boolean standaloneProxyBroadcast = canValidateStandaloneBroadcast && (proxyBroadcastHandled || proxyBroadcastDecider.usesImmediateForwarding(playerOnline)); @@ -6727,7 +6785,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { } if (standaloneBroadcastState == null) { standaloneBroadcastState = new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, - text == null ? "" : text.toString(), false, true, proxyBroadcastTargets, + text == null ? "" : votePayload, false, true, proxyBroadcastTargets, broadcastForwardedServers, !sendVotesToAllServers, Collections.emptyMap(), queuedVote == null ? Collections.emptyMap() : queuedVote.getHttpBroadcastDeliveryIds()); standaloneBroadcastState.setWasOnline(playerOnline); @@ -6775,7 +6833,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { if (method == BungeeMethod.PLUGINMESSAGING || retryState.pluginMessageRouting) rewardJournalOwner = findCachedBroadcastOwner(uuid, voteId); if (rewardJournalOwner == null) rewardJournalOwner = createCachedRewardVote(voteId, player, uuid, service, time, realVote, - text.toString(), false, playerOnline); + votePayload, false, playerOnline); if (retryState.delayValidationKnown) rewardJournalOwner.setDelayValidated(authoritativeDelayValidated); markPendingRewardJournalTargets(rewardJournalOwner, rewardServers, retryState.deliveredRewardServers); retryState.rewardJournalOwner = rewardJournalOwner; @@ -6793,7 +6851,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { OfflineBungeeVote rewardState = retryState.rewardStates.get(server.toLowerCase(Locale.ROOT)); if (rewardState == null) { rewardState = createCachedRewardVote(voteId, player, uuid, service, time, - realVote, text.toString(), standaloneProxyBroadcast, playerOnline); + realVote, votePayload, standaloneProxyBroadcast, playerOnline); if (retryState.delayValidationKnown) rewardState.setDelayValidated(authoritativeDelayValidated); retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), rewardState); } @@ -6846,10 +6904,10 @@ && getGlobalDataHandler().isTimeChangedHappened()) { OfflineBungeeVote pendingVote = retryState.rewardStates.get(s.toLowerCase(Locale.ROOT)); JsonEnvelope rewardEnvelope = retryState.delayValidationKnown - ? VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, text.toString(), + ? VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, authoritativeDelayValidated) - : VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, text.toString(), + : VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1); boolean rewardAccepted = sendVoteEnvelopeAccepted(s, 2, rewardEnvelope, pendingVote); if (!rewardAccepted) { @@ -6894,10 +6952,10 @@ voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, } if (pendingVote == null) { pendingVote = standaloneProxyBroadcast - ? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), false, + ? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, votePayload, false, true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(), standaloneBroadcastState.getHttpBroadcastDeliveryIds()) - : createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false, playerOnline); + : createCachedRewardVote(voteId, player, uuid, service, time, realVote, votePayload, false, playerOnline); retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), pendingVote); } pendingVote.setWasOnline(playerOnline); @@ -6906,10 +6964,10 @@ voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, if (!rewardAccepted) { JsonEnvelope rewardEnvelope = retryState.delayValidationKnown ? VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote, - text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, + votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, authoritativeDelayValidated) : VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote, - text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1); + votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1); rewardAccepted = sendVoteEnvelopeAccepted(server, 1, rewardEnvelope, pendingVote); if (rewardAccepted) { retryState.deliveredRewardServers.add(server); @@ -6970,7 +7028,7 @@ && findCachedBroadcastOwner(uuid, voteId) == pendingVote } if (standaloneBroadcastState == null) { standaloneBroadcastState = new OfflineBungeeVote(voteId, player, uuid, service, time, - realVote, text.toString(), false, true, targets, broadcastForwardedServers, + realVote, votePayload, false, true, targets, broadcastForwardedServers, true, Collections.emptyMap(), Collections.emptyMap()); standaloneBroadcastState.setWasOnline(true); retryState.standaloneBroadcastState = standaloneBroadcastState; @@ -6996,7 +7054,7 @@ && findCachedBroadcastOwner(uuid, voteId) == pendingVote } JsonEnvelope broadcast = VotingPluginWire.voteBroadcast(uuid, player, service, time, - text == null ? "" : text.toString(), true); + text == null ? "" : votePayload, true); if (method == BungeeMethod.HTTP) { boolean forwarded = sendStableHttpEnvelope(targetServer, stableLiveHttpBroadcastDeliveryId(voteId, targetServer), broadcast); @@ -7028,10 +7086,10 @@ && findCachedBroadcastOwner(uuid, voteId) == pendingVote } } OfflineBungeeVote cachedReward = standaloneProxyBroadcast - ? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), false, + ? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, votePayload, false, true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(), standaloneBroadcastState.getHttpBroadcastDeliveryIds()) - : createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false, playerOnline); + : createCachedRewardVote(voteId, player, uuid, service, time, realVote, votePayload, false, playerOnline); cachedReward.setWasOnline(playerOnline); if (retryState.delayValidationKnown) cachedReward.setDelayValidated(authoritativeDelayValidated); retryState.pendingOnlineRewardState = cachedReward; @@ -7209,6 +7267,10 @@ private boolean beginMultiProxyForwarding(LiveVoteRetryState retryState, VoteTim String uuid, String service, long time, boolean realVote, VoteTotalsSnapshot totals, boolean delayValidated, boolean delayValidationKnown) { if (multiProxyHandler == null) return false; + String forwardingTotals = VoteOccurrenceMetadata.store(totals == null ? "" : totals.toString(), + retryState != null && retryState.canonicalOccurrenceTime != 0 + ? retryState.canonicalOccurrenceTime : queuedVote == null ? time + : queuedVote.getCanonicalOccurrenceTime()); // A corrupt or unpersistable peer-classification file cannot safely identify // which targets are legacy. Stop before creating an ACK outbox that a legacy // peer could never complete; the handler emits the operator recovery message. @@ -7258,12 +7320,12 @@ private boolean beginMultiProxyForwarding(LiveVoteRetryState retryState, VoteTim // Older peers do not understand acknowledgements. Preserve their historical // fire-and-forget route instead of creating an outbox they can never ACK. return multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.vote(player, uuid, service, time, - false, realVote, totals == null ? "" : totals.toString(), findLiveVoteId(retryState), false, false, + false, realVote, forwardingTotals, findLiveVoteId(retryState), false, false, 1, 1), legacyRecipients); } if (outbox == null) { outbox = new VoteTimeQueue(null, player, service, time, false, Collections.emptySet(), - Collections.emptySet(), totals == null ? "" : totals.toString(), true, uuid); + Collections.emptySet(), forwardingTotals, true, uuid); // The live retry key is the stable vote ID; copy it from the enclosing state // by locating its identity rather than creating a new duplicate record. outbox.setVoteId(findLiveVoteId(retryState)); @@ -7318,7 +7380,7 @@ private boolean beginMultiProxyForwarding(LiveVoteRetryState retryState, VoteTim outbox.setDeliveryStateDirty(true); if (!persistTimeVoteDelivery(outbox)) return false; if (!multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.vote(player, uuid, service, time, - false, realVote, totals == null ? "" : totals.toString(), outbox.getVoteId(), false, false, 1, 1), + false, realVote, forwardingTotals, outbox.getVoteId(), false, false, 1, 1), legacyPending)) { outbox.setMultiProxyLegacyPendingRecipients(legacyPending); outbox.setDeliveryStateDirty(true); @@ -7348,10 +7410,14 @@ private boolean beginMultiProxyForwarding(LiveVoteRetryState retryState, VoteTim private VoteTimeQueue admitMultiProxyDiscoveryOutbox(LiveVoteRetryState retryState, VoteTimeQueue queuedVote, String player, String uuid, String service, long time, boolean realVote, VoteTotalsSnapshot totals, boolean delayValidated, boolean delayValidationKnown) { + String forwardingTotals = VoteOccurrenceMetadata.store(totals == null ? "" : totals.toString(), + retryState != null && retryState.canonicalOccurrenceTime != 0 + ? retryState.canonicalOccurrenceTime : queuedVote == null ? time + : queuedVote.getCanonicalOccurrenceTime()); VoteTimeQueue outbox = queuedVote; if (outbox == null) { outbox = new VoteTimeQueue(null, player, service, time, false, Collections.emptySet(), - Collections.emptySet(), totals == null ? "" : totals.toString(), true, uuid); + Collections.emptySet(), forwardingTotals, true, uuid); outbox.setVoteId(findLiveVoteId(retryState)); if (outbox.getVoteId() == null) return null; retryState.queuedVote = outbox; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index 6daf4a2000..58a795f0d4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -34,6 +34,7 @@ private VotingPluginWire() { } public static final int SCHEMA_VERSION = 1; + public static final String K_CANONICAL_OCCURRENCE_TIME = "canonicalOccurrenceTime"; // ========================= // Subchannels (canonical) @@ -182,13 +183,16 @@ public static JsonEnvelope voteOnline(String player, String uuid, String service private static JsonEnvelope.Builder voteBuilder(String subChannel, String player, String uuid, String service, long time, boolean wasOnline, boolean realVote, String totals, UUID voteId, boolean manageTotals, boolean bungeeBroadcast, int num, int numberOfVotes) { - return base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service)) + JsonEnvelope.Builder builder = base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service)) .put(K_TIME, time).put(K_WAS_ONLINE, wasOnline).put(K_WAS_ONLINE_KNOWN, true) .put(K_REAL_VOTE, realVote).put(K_TOTALS, safe(totals)) .put(K_VOTE_ID, voteId == null ? "" : voteId.toString()).put(K_SET_TOTALS, true) .put(K_MANAGE_TOTALS, manageTotals).put(K_QUEUED_DELIVERY, false) .put(K_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num) .put(K_NUMBER_OF_VOTES, numberOfVotes); + Long occurrence = VoteOccurrenceMetadata.read(totals); + builder.put(K_CANONICAL_OCCURRENCE_TIME, occurrence == null ? time : occurrence); + return builder; } /** @@ -214,12 +218,15 @@ public static JsonEnvelope multiProxyVote(String player, String uuid, String ser private static JsonEnvelope.Builder multiProxyVoteBuilder(String subChannel, String player, String uuid, String service, long time, boolean wasOnline, boolean realVote, String totals, UUID voteId, boolean manageTotals, boolean bungeeBroadcast, int num, int numberOfVotes, String origin) { - return base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service)) + JsonEnvelope.Builder builder = base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service)) .put(K_TIME, time).put(K_WAS_ONLINE, wasOnline).put(K_WAS_ONLINE_KNOWN, true).put(K_REAL_VOTE, realVote).put(K_TOTALS, safe(totals)) .put(K_VOTE_ID, voteId == null ? "" : voteId.toString()).put(K_SET_TOTALS, true) .put(K_MANAGE_TOTALS, manageTotals).put(K_QUEUED_DELIVERY, false) .put(K_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num) .put(K_NUMBER_OF_VOTES, numberOfVotes).put(K_MULTI_PROXY_ORIGIN, safe(origin)); + Long occurrence = VoteOccurrenceMetadata.read(totals); + builder.put(K_CANONICAL_OCCURRENCE_TIME, occurrence == null ? time : occurrence); + return builder; } /** Reliable multi-proxy variant of {@link #voteOnline}. */ @@ -638,6 +645,7 @@ public static final class Vote { public final String uuid; public final String service; public final long time; + public final long canonicalOccurrenceTime; public final boolean wasOnline; public final boolean wasOnlineKnown; @@ -659,12 +667,13 @@ private Vote(String subChannel, String player, String uuid, String service, long boolean wasOnlineKnown, boolean realVote, String totals, UUID voteId, boolean setTotals, boolean manageTotals, boolean delayValidated, boolean delayValidationKnown, boolean queuedDelivery, boolean queuedDeliveryKnown, - boolean broadcast, int num, int numberOfVotes) { + boolean broadcast, int num, int numberOfVotes, long canonicalOccurrenceTime) { this.subChannel = subChannel; this.player = player; this.uuid = uuid; this.service = service; this.time = time; + this.canonicalOccurrenceTime = canonicalOccurrenceTime; this.wasOnline = wasOnline; this.wasOnlineKnown = wasOnlineKnown; this.realVote = realVote; @@ -695,6 +704,13 @@ public static Vote readVote(JsonEnvelope env) { final boolean wasOnlineKnown = readBool(f, K_WAS_ONLINE_KNOWN, false); final boolean realVote = readBool(f, K_REAL_VOTE, false); final String totals = safe(f.get(K_TOTALS)); + Long cachedOccurrence = VoteOccurrenceMetadata.read(totals); + // Missing or malformed provenance excludes only date accounting. An older + // intermediate proxy may have replaced ordinary time with its receipt time. + long occurrence = f.containsKey(K_CANONICAL_OCCURRENCE_TIME) + ? VoteOccurrenceMetadata.parse(f.get(K_CANONICAL_OCCURRENCE_TIME)) + : cachedOccurrence == null ? -1L : cachedOccurrence; + if (cachedOccurrence != null && (cachedOccurrence <= 0 || cachedOccurrence != occurrence)) occurrence = -1L; final UUID voteId = readUuid(f, K_VOTE_ID); final boolean setTotals = readBool(f, K_SET_TOTALS, true); @@ -711,7 +727,7 @@ public static Vote readVote(JsonEnvelope env) { return new Vote(sub, player, uuid, service, time, wasOnline, wasOnlineKnown, realVote, totals, voteId, setTotals, manageTotals, delayValidated, delayValidationKnown, queuedDelivery, queuedDeliveryKnown, - broadcast, num, numberOfVotes); + broadcast, num, numberOfVotes, occurrence); } public static final class VoteDelayRejected { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java index 7ad8e2c8a2..bcfd7d517f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java @@ -614,6 +614,18 @@ public IncomingVoteRuntimeResult processIncomingVote(String player, String servi } } + /** Captured ingress survives scheduler/reload/identity-resolution delays. */ + public IncomingVoteRuntimeResult processIncomingVote(String player, String service, UUID voteId, long occurredAt) { + if (reloading) return IncomingVoteRuntimeResult.RETRY_AFTER_RELOAD; + synchronized (reloadLock) { + if (reloading) return IncomingVoteRuntimeResult.RETRY_AFTER_RELOAD; + VotingPluginProxy runtime = votingPluginProxy; + if (!runtimeOperational || runtime == null) return IncomingVoteRuntimeResult.RUNTIME_UNAVAILABLE; + runtime.vote(player, service, true, true, 0, null, null, voteId, occurredAt); + return IncomingVoteRuntimeResult.PROCESSED; + } + } + /** Owns a Votifier event before any fallible scheduler handoff. */ public void acceptIncomingVote(String player, String service) { PendingIncomingVote pending = pendingIncomingVotes.admit(player, service); @@ -672,7 +684,7 @@ private void processPendingIncomingVote(PendingIncomingVote pending) { try { IncomingVoteRuntimeResult result; synchronized (reloadLock) { - result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId()); + result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId(), pending.getAcceptedAt()); if (result == IncomingVoteRuntimeResult.PROCESSED) pendingIncomingVotes.complete(pending); } if (result == IncomingVoteRuntimeResult.PROCESSED) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyOnlineVoteCacheTable.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyOnlineVoteCacheTable.java index 56a0f0a835..dbe1d2a52f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyOnlineVoteCacheTable.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyOnlineVoteCacheTable.java @@ -132,6 +132,8 @@ public void updateVoteText(OfflineBungeeVote vote, String newText) { return; } + newText = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.preserve(vote.getText(), newText); + String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("text") + " = ?" + " WHERE " + qi("uuid") + " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ?;"; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyVoteCacheTable.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyVoteCacheTable.java index 0c58f7120c..a25ffbb441 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyVoteCacheTable.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyVoteCacheTable.java @@ -97,6 +97,8 @@ public void updateVoteText(OfflineBungeeVote vote, String server, String newText return; } + newText = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.preserve(vote.getText(), newText); + String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("text") + " = ?" + " WHERE " + qi("uuid") + " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ? AND " + qi("server") + " = ?;"; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java index f478588b63..bb3e1b66bc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java @@ -444,6 +444,14 @@ public void triggerVote(String player, String service, boolean realVote, boolean triggerVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin); } + /** Additive occurrence metadata; queueTime remains the normal receiver clock convention. */ + public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId, String origin, boolean delayValidated, + boolean delayValidationKnown, long canonicalOccurrenceTime) { + triggerVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin, + delayValidated, delayValidationKnown); + } + /** Called when a receiver acknowledges this proxy's stable vote ID. */ public void onMultiProxyVoteAcknowledged(UUID voteId, String recipient) { // Optional for legacy implementations. @@ -1276,8 +1284,14 @@ private void handleEnvelope(JsonEnvelope envelope) { if (origin.isBlank()) { triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid); } else { + // Older reliable origin envelopes still carry the sender's original + // time. Keep it separate from receiver-local cooldown processing. + long occurrenceTime = !f.containsKey(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME) + && com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.read(totals) == null + ? wireVote.time : wireVote.canonicalOccurrenceTime; triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid, - wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown); + wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown, + occurrenceTime); } } return; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index 1365243369..db95d9dabf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -509,6 +509,18 @@ public IncomingVoteRuntimeResult processIncomingVote(String player, String servi } } + /** Captured ingress survives scheduler/reload/identity-resolution delays. */ + public IncomingVoteRuntimeResult processIncomingVote(String player, String service, UUID voteId, long occurredAt) { + if (reloading) return IncomingVoteRuntimeResult.RETRY_AFTER_RELOAD; + synchronized (reloadLock) { + if (reloading) return IncomingVoteRuntimeResult.RETRY_AFTER_RELOAD; + VotingPluginProxy runtime = votingPluginProxy; + if (!runtimeOperational || runtime == null) return IncomingVoteRuntimeResult.RUNTIME_UNAVAILABLE; + runtime.vote(player, service, true, false, 0, null, null, voteId, occurredAt); + return IncomingVoteRuntimeResult.PROCESSED; + } + } + /** Owns a Votifier event before any fallible executor handoff. */ public void acceptIncomingVote(String player, String service) { PendingIncomingVote pending = pendingIncomingVotes.admit(player, service); @@ -558,7 +570,7 @@ private void processPendingIncomingVote(PendingIncomingVote pending) { try { IncomingVoteRuntimeResult result; synchronized (reloadLock) { - result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId()); + result = processIncomingVote(pending.getPlayer(), pending.getService(), pending.getVoteId(), pending.getAcceptedAt()); if (result == IncomingVoteRuntimeResult.PROCESSED) pendingIncomingVotes.complete(pending); } if (result == IncomingVoteRuntimeResult.PROCESSED) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java new file mode 100644 index 0000000000..31ad16f5e4 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java @@ -0,0 +1,244 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; + +import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent; +import com.bencodez.votingplugin.util.DurableFiles; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.UUID; + +/** Single local accounting owner. All entry points must run on a persistence worker. */ +public final class DateVoteLedger { + public record Progress(int votes, Set reservedAwards, Set submittedAwards, Set deferredAwards) { } + private static final int MAX_RECORDS = 100_000; + private final Path directory; + private final int recordCapacity; + public DateVoteLedger(Path directory) { this(directory, MAX_RECORDS); } + // Smaller limits keep boundary regression fixtures bounded; production uses MAX_RECORDS. + DateVoteLedger(Path directory, int recordCapacity) { + if (recordCapacity < 1 || recordCapacity > MAX_RECORDS) throw new IllegalArgumentException("Invalid milestone record capacity"); + this.directory = directory.toAbsolutePath().normalize(); + this.recordCapacity = recordCapacity; + } + + public synchronized List record(DateVoteEvent event, UUID player, UUID occurrence) throws IOException { + return record(event, player, occurrence, true); + } + public synchronized List record(DateVoteEvent event, UUID player, UUID occurrence, boolean processRewards) throws IOException { + return withLedgerLock(() -> recordLocked(event, player, occurrence, processRewards)); + } + private List recordLocked(DateVoteEvent event, UUID player, UUID occurrence, boolean processRewards) throws IOException { + seal(event); + Path file = playerFile(event, player); + Properties state = read(file); + String fingerprint = state.getProperty("fingerprint"); + if (fingerprint != null && !fingerprint.equals(event.fingerprint())) throw new IOException("Event accounting changed; use a new event ID"); + validateFingerprint(event, state); + Set seen = occurrences(state); + boolean changed = !seen.contains(occurrence); + if (changed && seen.size() >= 4096) throw new IOException("Event occurrence capacity reached (4096); progress retained"); + seen.add(occurrence); + state.setProperty("fingerprint", event.fingerprint()); + state.setProperty("seen", String.join(",", seen.stream().map(UUID::toString).sorted().toList())); + List awards = new ArrayList<>(); + for (int threshold : event.thresholds()) { + String key = "award." + threshold; + if (seen.size() >= threshold && !state.containsKey(key)) { + state.setProperty(key, "DEFERRED"); + changed = true; + } + if (processRewards && "DEFERRED".equals(state.getProperty(key))) awards.add(threshold); + } + // Persist counts and known unattempted awards; each submission claims only its own threshold. + if (changed) write(file, state); + return List.copyOf(awards); + } + /** Atomically admit one known unattempted threshold immediately before its external submission. */ + public synchronized boolean reserve(DateVoteEvent event, UUID player, int threshold) throws IOException { + return withLedgerLock(() -> reserveLocked(event, player, threshold)); + } + private boolean reserveLocked(DateVoteEvent event, UUID player, int threshold) throws IOException { + Path file = playerFile(event, player); + Properties state = read(file); + validateFingerprint(event, state); + if (!event.thresholds().contains(threshold) || !"DEFERRED".equals(state.getProperty("award." + threshold))) return false; + state.setProperty("award." + threshold, "RESERVED"); + write(file, state); + return true; + } + public synchronized void submitted(DateVoteEvent event, UUID player, int threshold) throws IOException { + withLedgerLock(() -> { submittedLocked(event, player, threshold); return null; }); + } + private void submittedLocked(DateVoteEvent event, UUID player, int threshold) throws IOException { + Path file = playerFile(event, player); + Properties state = read(file); + validateFingerprint(event, state); + if (!"RESERVED".equals(state.getProperty("award." + threshold))) return; + state.setProperty("award." + threshold, "SUBMITTED"); + write(file, state); + } + public synchronized Progress progress(DateVoteEvent event, UUID player) throws IOException { + // An absent ledger is a valid empty snapshot. A read must not initialize + // accounting storage, including for legacy votes with no event progress. + // Unknown/unreadable paths still enter the checked ownership path. + if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) + return new Progress(0, Set.of(), Set.of(), Set.of()); + return withLedgerLock(() -> progressLocked(event, player)); + } + private Progress progressLocked(DateVoteEvent event, UUID player) throws IOException { + Properties definitions = readSealedDefinitions(event); + String sealed = definitions.getProperty(event.id()); + if (sealed != null && !sealed.equals(event.fingerprint())) throw new IOException("Event accounting changed; use a new ID"); + Properties state = read(playerFile(event, player)); + validateFingerprint(event, state); + Set reserved = new HashSet<>(), submitted = new HashSet<>(), deferred = new HashSet<>(); + for (String key : state.stringPropertyNames()) { + if (key.startsWith("award.")) { + int threshold; + try { threshold = Integer.parseInt(key.substring(6)); } + catch (NumberFormatException invalid) { throw new IOException("Invalid award state", invalid); } + if ("RESERVED".equals(state.getProperty(key))) reserved.add(threshold); + else if ("SUBMITTED".equals(state.getProperty(key))) submitted.add(threshold); + else if ("DEFERRED".equals(state.getProperty(key))) deferred.add(threshold); + else throw new IOException("Unknown award state"); + } + } + return new Progress(occurrences(state).size(), Set.copyOf(reserved), Set.copyOf(submitted), Set.copyOf(deferred)); + } + @FunctionalInterface + private interface LedgerAction { T run() throws IOException; } + private T withLedgerLock(LedgerAction action) throws IOException { + if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory"); + Files.createDirectories(directory); + if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory"); + // Interning shares this monitor across plugin classloaders without retaining a static registry. + // On Unix, closing any descriptor for a file can release another channel's process-owned lock. + String monitor = ("com.bencodez.votingplugin.DateVoteLedger:" + directory.toRealPath()).intern(); + if (Thread.holdsLock(monitor)) throw new IOException("Date milestone ledger is busy"); + synchronized (monitor) { + Path lockPath = directory.resolve(".ledger.lock"); + if (Files.isSymbolicLink(lockPath) || (Files.exists(lockPath, LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(lockPath, LinkOption.NOFOLLOW_LINKS))) + throw new IOException("Unsafe milestone lock path"); + try (FileChannel channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS)) { + FileLock lock; + try { lock = channel.tryLock(); } + catch (OverlappingFileLockException busy) { throw new IOException("Date milestone ledger is busy", busy); } + if (lock == null) throw new IOException("Date milestone ledger is busy"); + try (lock) { return action.run(); } + } + } + } + private void validateFingerprint(DateVoteEvent event, Properties state) throws IOException { + if (state.isEmpty()) return; + if (!event.fingerprint().equals(state.getProperty("fingerprint"))) + throw new IOException("Event accounting changed or state invalid; use a new ID"); + int votes = occurrences(state).size(); + for (int threshold : event.thresholds()) if (votes >= threshold && !state.containsKey("award." + threshold)) + throw new IOException("Missing reached milestone award state; progress retained for review"); + for (String key : state.stringPropertyNames()) if (key.startsWith("award.") + && !event.thresholds().contains(Integer.parseInt(key.substring(6)))) + throw new IOException("Award is outside configured milestone contract"); + } + private void seal(DateVoteEvent event) throws IOException { + Properties definitions = readSealedDefinitions(event); + String old = definitions.getProperty(event.id()); + if (old != null && !old.equals(event.fingerprint())) throw new IOException("DateVoteMilestones event " + event.id() + " changed accounting; use a new ID"); + if (old == null) { + if (definitions.size() >= 1024) throw new IOException("Event definition capacity reached"); + definitions.setProperty(event.id(), event.fingerprint()); + write(directory.resolve("definitions.properties"), definitions); + } + } + /** Missing metadata cannot turn surviving player history into a fresh event. */ + private Properties readSealedDefinitions(DateVoteEvent event) throws IOException { + if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory"); + Properties definitions = read(directory.resolve("definitions.properties")); + if (!definitions.containsKey(event.id()) && Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + // Bounded accounting directory; this lookup is exclusively on the I/O owner. + try (var history = Files.newDirectoryStream(directory, event.fileId() + "-*.properties")) { + if (history.iterator().hasNext()) + throw new IOException("Missing historical event seal; preserve state and repair metadata before accounting"); + } + } + return definitions; + } + private Path playerFile(DateVoteEvent event, UUID player) { + return directory.resolve(event.fileId() + "-" + player + ".properties"); + } + private Set occurrences(Properties state) throws IOException { + Set ids = new HashSet<>(); + String raw = state.getProperty("seen", ""); + if (!raw.isEmpty()) for (String id : raw.split(",", -1)) { + try { if (!ids.add(UUID.fromString(id))) throw new IOException("Duplicate stored occurrence"); } + catch (IllegalArgumentException invalid) { throw new IOException("Invalid stored occurrence", invalid); } + if (ids.size() > 4096) throw new IOException("Oversized event state"); + } + return ids; + } + private Properties read(Path file) throws IOException { + Properties state = new Properties() { + @Override public synchronized Object put(Object key, Object value) { + if (containsKey(key)) throw new IllegalArgumentException("Duplicate stored field"); + return super.put(key, value); + } + }; + if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return new Properties(); + if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory"); + if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > 256 * 1024) + throw new IOException("Unsafe/oversized date milestone state"); + try { state.load(new StringReader(Files.readString(file))); } + catch (IllegalArgumentException invalid) { throw new IOException("Malformed milestone state", invalid); } + if (file.getFileName().toString().equals("definitions.properties")) { + if (state.isEmpty() || state.size() > 1024) throw new IOException("Empty/oversized definitions; existing seals must not be reset"); + for (String key : state.stringPropertyNames()) if (!key.matches("[A-Za-z0-9_-]{1,64}") + || !state.getProperty(key).matches("[a-f0-9]{64}")) throw new IOException("Invalid definition state"); + } else { + if (!state.containsKey("fingerprint") || !state.getProperty("fingerprint").matches("[a-f0-9]{64}") + || !state.containsKey("seen") || state.size() > 66) throw new IOException("Incomplete milestone state"); + Set seen = occurrences(state); + for (String key : state.stringPropertyNames()) if (!key.equals("fingerprint") && !key.equals("seen")) { + if (!key.matches("award\\.[1-9][0-9]{0,3}")) throw new IOException("Unknown milestone state field"); + int threshold = Integer.parseInt(key.substring(6)); + if (threshold > 4096 || threshold > seen.size() + || !(state.getProperty(key).equals("RESERVED") || state.getProperty(key).equals("SUBMITTED") || state.getProperty(key).equals("DEFERRED"))) + throw new IOException("Invalid award state"); + } + } + Properties mutable = new Properties(); mutable.putAll(state); + return mutable; + } + protected void write(Path file, Properties state) throws IOException { + if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory"); + Files.createDirectories(directory); + if (Files.isSymbolicLink(directory) || Files.isSymbolicLink(file)) throw new IOException("Unsafe milestone state path"); + boolean newRecord = !file.getFileName().toString().equals("definitions.properties") && Files.notExists(file, LinkOption.NOFOLLOW_LINKS); + if (newRecord) { + int recordCount = 0; + try (var files = Files.newDirectoryStream(directory, "*.properties")) { + for (Path existing : files) if (!existing.getFileName().toString().equals("definitions.properties")) { + if (++recordCount >= recordCapacity) throw new IOException("Milestone record capacity reached; existing progress retained"); + } + } + } + StringWriter writer = new StringWriter(); state.store(writer, "DateVoteMilestones v1"); + String text = writer.toString(); + if (text.length() > 256 * 1024) throw new IOException("Oversized milestone state"); + Path staged = Files.createTempFile(directory, "milestone-", ".tmp"); + try { Files.writeString(staged, text); DurableFiles.publishStagedFile(staged, file); } + finally { Files.deleteIfExists(staged); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestoneConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestoneConfig.java new file mode 100644 index 0000000000..ab334b8c38 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestoneConfig.java @@ -0,0 +1,94 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.regex.Pattern; +import org.bukkit.configuration.ConfigurationSection; + +/** Bounded owner configuration; persistent award identities remain the numeric thresholds. */ +final class DateVoteMilestoneConfig { + record Reward(String key, boolean enabled) { } + private static final Pattern RANGE = Pattern.compile("(\\d+)\\s*\\.\\.\\s*(\\d+)(?:\\s+step\\s+(\\d+))?"); + private DateVoteMilestoneConfig() { } + + static Map read(ConfigurationSection event) { + var section = event.getConfigurationSection("Milestones"); + if (section == null || section.getKeys(false).isEmpty() || section.getKeys(false).size() > 64) + throw new IllegalArgumentException("Milestones requires 1–64 entries"); + Map result = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + var milestone = section.getConfigurationSection(key); + for (var entry : entry(key, milestone).entrySet()) { + if (result.putIfAbsent(entry.getKey(), entry.getValue()) != null) + throw new IllegalArgumentException("Milestone thresholds must not overlap between entries"); + if (result.size() > 64) throw new IllegalArgumentException("At most 64 thresholds per event"); + } + } + return result; + } + + static Map entry(String key, ConfigurationSection section) { + if (!key.matches("[A-Za-z0-9_-]{1,64}") || section == null + || section.getConfigurationSection("Rewards") == null) + throw new IllegalArgumentException("Each milestone requires an ID and Rewards section"); + if (section.contains("Enabled") && !(section.get("Enabled") instanceof Boolean)) + throw new IllegalArgumentException("Milestone Enabled must be a boolean"); + for (String unsupported : List.of("Total", "Group", "Limit")) { + if (section.contains(unsupported)) + throw new IllegalArgumentException(unsupported + " is not supported for date milestones"); + } + TreeSet thresholds = new TreeSet<>(); + if (!section.contains("At") && !section.contains("Every")) { + int value = integer(key, false); + if (!key.equals(Integer.toString(value))) throw new IllegalArgumentException("Legacy threshold must be canonical"); + thresholds.add(value); + } else { + if (section.contains("At")) { + Object raw = section.get("At"); + List values = raw instanceof List list ? list : java.util.Collections.singletonList(raw); + if (values.isEmpty() || values.size() > 64) throw new IllegalArgumentException("At requires 1–64 values"); + for (Object value : values) addAt(thresholds, value); + } + if (section.contains("Every")) { + int every = integer(section.get("Every"), false); + for (int value = every; value <= 4096; value += every) add(thresholds, value); + } + } + Map result = new LinkedHashMap<>(); + for (int value : thresholds) result.put(value, new Reward(key, section.getBoolean("Enabled", true))); + return result; + } + + private static void addAt(TreeSet values, Object raw) { + if (raw instanceof String text) { + var range = RANGE.matcher(text.trim()); + if (range.matches()) { + int start = integer(range.group(1), true), end = integer(range.group(2), true); + int step = range.group(3) == null ? 1 : integer(range.group(3), false); + int first = Math.min(start, end), last = Math.max(start, end); + for (int value = first; value <= last; value += step) add(values, value == 0 ? 1 : value); + return; + } + } + int value = integer(raw, true); + add(values, value == 0 ? 1 : value); + } + + private static void add(TreeSet values, int value) { + values.add(value); + if (values.size() > 64) throw new IllegalArgumentException("At most 64 thresholds per event"); + } + + private static int integer(Object raw, boolean zero) { + // Avoid Number.intValue truncation and YAML's boolean/string-list coercions. + if (!(raw instanceof Byte || raw instanceof Short || raw instanceof Integer || raw instanceof Long || raw instanceof String)) + throw new IllegalArgumentException("Threshold must be an integer"); + String text = raw.toString().trim(); + if (!text.matches("\\d{1,4}")) throw new IllegalArgumentException("Threshold must be an integer from 1 to 4096"); + int value = Integer.parseInt(text); + if (value < (zero ? 0 : 1) || value > 4096) throw new IllegalArgumentException("Threshold out of range"); + return value; + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java new file mode 100644 index 0000000000..9525ed420f --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java @@ -0,0 +1,289 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.RejectedExecutionException; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot; +import com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; + +/** Owner-defined windows, independently accounted on one backend using original occurrence IDs. */ +public final class DateVoteMilestones { + private record Definition(DateVoteEvent event, YamlConfiguration rewards, java.util.Set enabledThresholds, + java.util.Map milestones) { } + private final VotingPluginMain plugin; + private volatile List definitions = List.of(); + private final java.util.Set pendingProgress = new java.util.HashSet<>(); + private final java.util.concurrent.atomic.AtomicInteger deferredAccounting = new java.util.concurrent.atomic.AtomicInteger(); + private DateVoteLedger ledger; + public DateVoteMilestones(VotingPluginMain plugin) { this.plugin = plugin; } + private synchronized DateVoteLedger ledger() { + if (ledger == null) ledger = new DateVoteLedger(plugin.getDataFolder().toPath().resolve("date-vote-milestones")); + return ledger; + } + /** Configuration is already loaded; this method performs no disk access. */ + public void reload() { + List loaded = new ArrayList<>(); + YamlConfiguration rewards = new YamlConfiguration(); + var root = plugin.getSpecialRewardsConfig().getData().getConfigurationSection("DateVoteMilestones"); + if (root != null && !root.getKeys(false).isEmpty()) { + try { rewards.loadFromString(plugin.getSpecialRewardsConfig().getData().saveToString()); } + catch (org.bukkit.configuration.InvalidConfigurationException invalid) { + definitions = List.of(); plugin.getLogger().warning("DateVoteMilestones configuration snapshot invalid"); return; + } + } + int considered = 0; + if (root != null) for (String id : root.getKeys(false)) { + if (++considered > 64) { plugin.getLogger().warning("DateVoteMilestones supports at most 64 configured events"); break; } + try { + ConfigurationSection section = root.getConfigurationSection(id); + if (section == null) throw new IllegalArgumentException("Expected an event section"); + registerRewardHandles(id, section, rewards); + DateVoteEvent event = parse(id, section); + if (plugin.getBungeeSettings().isUseBungeecoord() && event.accountingServer().isBlank()) + throw new IllegalArgumentException("Proxy events require one explicit AccountingServer and all-server identified delivery"); + var configured = DateVoteMilestoneConfig.read(section); + var enabledThresholds = configured.entrySet().stream().filter(entry -> entry.getValue().enabled()) + .map(java.util.Map.Entry::getKey).collect(java.util.stream.Collectors.toUnmodifiableSet()); + loaded.add(new Definition(event, rewards, enabledThresholds, java.util.Map.copyOf(configured))); + } catch (Exception invalid) { + plugin.getLogger().warning("DateVoteMilestones " + id + " disabled: " + invalid.getMessage()); + } + } + definitions = List.copyOf(loaded); + } + /** Queued rewards must resolve even when an accounting-only setting is invalid. */ + private void registerRewardHandles(String id, ConfigurationSection section, YamlConfiguration rewards) { + String fileId = DateVoteEvent.fileId(id); + var milestones = section.getConfigurationSection("Milestones"); + if (milestones == null) return; + java.util.Map configuredRewards = new java.util.LinkedHashMap<>(); + java.util.Set ambiguous = new java.util.HashSet<>(); + int count = 0; + for (String key : milestones.getKeys(false)) { + if (++count > 64) break; + try { + for (var entry : DateVoteMilestoneConfig.entry(key, milestones.getConfigurationSection(key)).entrySet()) { + if (configuredRewards.size() >= 64 && !configuredRewards.containsKey(entry.getKey())) continue; + if (configuredRewards.putIfAbsent(entry.getKey(), entry.getValue()) != null) ambiguous.add(entry.getKey()); + } + } catch (IllegalArgumentException invalid) { /* Keep independent valid queued reward handles. */ } + } + for (var entry : configuredRewards.entrySet()) { + int threshold = entry.getKey(); + if (ambiguous.contains(threshold)) continue; + String source = "DateVoteMilestones." + id + ".Milestones." + entry.getValue().key() + ".Rewards"; + var configured = rewards.getConfigurationSection(source); + if (configured == null) continue; + String alias = "DateVoteMilestonesRuntime." + fileId + ".Milestones." + threshold + ".Rewards"; + rewards.createSection(alias, configured.getValues(true)); + plugin.addDirectlyDefinedRewards(new DirectlyDefinedReward(alias) { + private String sourcePath(String requested) { + if (!requested.equals(alias) && !requested.startsWith(alias + ".")) + throw new IllegalArgumentException("Unexpected date milestone reward path"); + String current = currentRewardSource(plugin.getSpecialRewardsConfig().getData(), id, threshold); + if (current == null) throw new IllegalStateException("Date milestone reward definition is unavailable"); + return current + requested.substring(alias.length()); + } + @Override public ConfigurationSection getFileData() { + YamlConfiguration data = new YamlConfiguration(); + var live = plugin.getSpecialRewardsConfig().getData(); + String currentSource = currentRewardSource(live, id, threshold); + var current = currentSource == null ? null + : live.getConfigurationSection(currentSource); + if (current != null) data.createSection(alias, current.getValues(true)); + return data; + } + @Override public void createSection(String path) { plugin.getSpecialRewardsConfig().createSection(sourcePath(path)); } + @Override public void setData(String path, Object value) { plugin.getSpecialRewardsConfig().setValue(sourcePath(path), value); } + @Override public void save() { plugin.getSpecialRewardsConfig().saveData(); } + }); + } + } + /** Old registry handles can outlive YAML replacement during reload; resolve by stable threshold. */ + private static String currentRewardSource(ConfigurationSection live, String id, int threshold) { + var milestones = live.getConfigurationSection("DateVoteMilestones." + id + ".Milestones"); + if (milestones == null) return null; + String found = null; + int count = 0; + for (String key : milestones.getKeys(false)) { + if (++count > 64) break; + try { + if (!DateVoteMilestoneConfig.entry(key, milestones.getConfigurationSection(key)).containsKey(threshold)) continue; + if (found != null) return null; + found = "DateVoteMilestones." + id + ".Milestones." + key + ".Rewards"; + } catch (IllegalArgumentException invalid) { /* An unrelated invalid entry does not hide queued rewards. */ } + } + return found; + } + public static DateVoteEvent parse(String id, ConfigurationSection section) { + String zone = section.getString("Timezone"); + String start = section.getString("Start"), end = section.getString("End"); + var milestones = section.getConfigurationSection("Milestones"); + if (zone == null || start == null || end == null || milestones == null) + throw new IllegalArgumentException("Start, End, Timezone and Milestones are required"); + List thresholds = new ArrayList<>(DateVoteMilestoneConfig.read(section).keySet()); + Object rawSites = section.get("VoteSites"); + if (rawSites != null && (!(rawSites instanceof List sites) + || sites.stream().anyMatch(site -> !(site instanceof String)))) + throw new IllegalArgumentException("VoteSites must be a list of configured site keys"); + return new DateVoteEvent(id, section.getString("DisplayName", id), section.getBoolean("Enabled", false), + DateVoteEvent.timestamp(start, zone), DateVoteEvent.timestamp(end, zone), zone, thresholds, + new java.util.HashSet<>(section.getStringList("VoteSites")), section.getString("AccountingServer", "")); + } + static String path(DateVoteEvent event, int threshold) { + return "DateVoteMilestonesRuntime." + event.fileId() + ".Milestones." + threshold + ".Rewards"; + } + /** Owner-thread extension events use the existing ordered storage worker without waiting on gameplay. */ + public boolean deferAccepted(VotingPluginUser user, String site, UUID occurrence, long occurredAt, + boolean real, boolean proxy, boolean canonicalProxy, boolean targeted, boolean forceProxyRouting) { + if (plugin.getBungeeSettings().isUseBungeecoord() && !proxy) return true; + if (!real || occurrence == null || occurredAt <= 0 || proxy && (!canonicalProxy || targeted)) return true; + List snapshot = definitions; + if (snapshot.stream().noneMatch(definition -> definition.event().matches(occurredAt, site, real, proxy, + plugin.getBungeeSettings().getServer()))) return true; + int pending; + do { + pending = deferredAccounting.get(); + if (pending >= 64) { + plugin.getLogger().warning("DateVoteMilestones deferred accounting is busy; occurrence remains unconfirmed."); + return false; + } + } while (!deferredAccounting.compareAndSet(pending, pending + 1)); + try { + plugin.getUserManager().getDataManager().getTimer().execute(() -> { + try { + if (!plugin.isEnabled() || snapshot != definitions) return; + accepted(user, site, occurrence, occurredAt, real, proxy, canonicalProxy, targeted, forceProxyRouting); + } finally { deferredAccounting.decrementAndGet(); } + }); + } catch (RejectedExecutionException rejected) { + deferredAccounting.decrementAndGet(); + plugin.getLogger().warning("DateVoteMilestones deferred accounting was rejected; occurrence remains unconfirmed."); + } + return false; + } + /** Called only from the real asynchronous accepted-vote pipeline; never from navigation. */ + public boolean accepted(VotingPluginUser user, String site, UUID occurrence, long occurredAt, + boolean real, boolean proxy, boolean canonicalProxy, boolean targeted, boolean forceProxyRouting) { + if (plugin.getBungeeSettings().isUseBungeecoord() && !proxy) return true; + if (!real || occurrence == null || occurredAt <= 0 || proxy && (!canonicalProxy || targeted)) return true; + boolean recorded = true; + for (Definition definition : definitions) { + DateVoteEvent event = definition.event(); + if (!event.matches(occurredAt, site, real, proxy, plugin.getBungeeSettings().getServer())) continue; + List reached; + try { + reached = ledger().record(event, user.getJavaUUID(), occurrence, plugin.getOptions().isProcessRewards()); + } catch (IOException | RuntimeException failure) { + recorded = false; + plugin.getLogger().warning("DateVoteMilestones " + event.id() + " occurrence was not confirmed; proxy delivery must remain quarantined for accounting reconciliation: " + failure.getMessage()); + continue; + } + try { + for (int threshold : reached) { + if (!plugin.getOptions().isProcessRewards()) break; + if (!definition.enabledThresholds().contains(threshold)) continue; + var placeholders = new HashMap(); + placeholders.put("DateVoteEvent", event.displayName()); + placeholders.put("DateVoteThreshold", Integer.toString(threshold)); + if (!ledger().reserve(event, user.getJavaUUID(), threshold)) continue; + plugin.getRewardHandler().giveReward(user, definition.rewards(), path(event, threshold), + new RewardOptions().setPrefix("DateVoteMilestones-" + event.id() + "-" + threshold) + .setServer(forceProxyRouting).setPlaceholders(placeholders)); + // Submission is not a transactional guarantee of arbitrary command execution. + ledger().submitted(event, user.getJavaUUID(), threshold); + } + } catch (IOException | RuntimeException failure) { + plugin.getLogger().warning("DateVoteMilestones " + event.id() + " accounting/reward submission failed; progress retained for review: " + failure.getMessage()); + } + } + return recorded; + } + public void progress(Player player) { + progress(player, (view, current) -> com.bencodez.votingplugin.commands.gui.player.DateVoteEventsMenu.open(plugin, player, view, current)); + } + /** Display is an entity-owned effect; identity resolution and ledger reads stay on the storage worker. */ + void progress(Player player, java.util.function.BiConsumer display) { + BukkitCompletionScheduler.run(plugin, player, () -> { + UUID uuid = player.getUniqueId(); + String playerName = player.getName(); + if (!canView(player)) return; + synchronized (pendingProgress) { + if (pendingProgress.contains(uuid) || pendingProgress.size() >= 64) { + player.sendMessage("Date voting progress is already being checked or busy. Try again shortly."); return; + } + pendingProgress.add(uuid); + } + List snapshot = definitions; + try { + plugin.getUserManager().getDataManager().getTimer().execute(() -> { + try { + if (snapshot != definitions || !plugin.isEnabled()) return; + boolean proxyBackend = plugin.getBungeeSettings().isUseBungeecoord(); + String server = plugin.getBungeeSettings().getServer(); + boolean localProgress = snapshot.stream().anyMatch(d -> !proxyBackend || d.event().accountingServer().equals(server)); + UUID storageId = !localProgress ? uuid + : plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, playerName).getJavaUUID(); + List events = new ArrayList<>(); + long now = System.currentTimeMillis(); + for (Definition definition : snapshot) { + var event = definition.event(); + var milestones = event.thresholds().stream().map(threshold -> { + var configured = definition.milestones().get(threshold); + var rewards = definition.rewards().getConfigurationSection(path(event, threshold)); + var types = rewards == null ? List.of() : rewards.getKeys(false).stream() + .filter(key -> key.length() <= 64).limit(16).toList(); + return new DateVoteProgressSnapshot.Milestone(threshold, configured.key(), configured.enabled(), types); + }).toList(); + if (proxyBackend && !event.accountingServer().equals(server)) { + events.add(view(event, DateVoteProgressSnapshot.Availability.REMOTE, null, milestones)); continue; + } + try { + var progress = ledger().progress(event, storageId); + events.add(view(event, DateVoteProgressSnapshot.Availability.OWNED, progress, milestones)); + } catch (IOException failure) { + events.add(view(event, DateVoteProgressSnapshot.Availability.UNAVAILABLE, null, milestones)); + } + } + var result = new DateVoteProgressSnapshot(now, events); + java.util.function.BooleanSupplier current = () -> canView(player) && snapshot == definitions + && plugin.getDateVoteMilestones() == this; + BukkitCompletionScheduler.run(plugin, player, () -> { + if (current.getAsBoolean()) display.accept(result, current); + }, () -> { }, () -> { }); + } catch (RuntimeException unavailable) { + BukkitCompletionScheduler.run(plugin, player, () -> { + if (canView(player) && snapshot == definitions) + player.sendMessage("Date voting progress is temporarily unavailable."); + }, () -> { }, () -> { }); + } finally { synchronized (pendingProgress) { pendingProgress.remove(uuid); } } + }); + } catch (RejectedExecutionException unavailable) { + synchronized (pendingProgress) { pendingProgress.remove(uuid); } + player.sendMessage("Date voting progress is temporarily unavailable."); + } + }, () -> { }, () -> { }); + } + private boolean canView(Player player) { + return plugin.isEnabled() && player.isOnline() + && (player.hasPermission("VotingPlugin.Commands.Vote.DateEvents") || player.hasPermission("VotingPlugin.Player")); + } + private static DateVoteProgressSnapshot.Event view(DateVoteEvent event, DateVoteProgressSnapshot.Availability availability, + DateVoteLedger.Progress progress, List milestones) { + return new DateVoteProgressSnapshot.Event(event, availability, progress == null ? 0 : progress.votes(), + progress == null ? java.util.Set.of() : progress.submittedAwards(), + progress == null ? java.util.Set.of() : progress.deferredAwards(), + progress == null ? java.util.Set.of() : progress.reservedAwards(), milestones); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index b1e46f3ef4..38b417af99 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Queue; +import java.util.UUID; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -48,10 +49,20 @@ public TimeQueueHandler(VotingPluginMain plugin) { * @param voteUsername the voter username * @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())); - } + public void addVote(String voteUsername, String voteSiteName) { addVote(voteUsername, voteSiteName, 0L); } + + public void addVote(String voteUsername, String voteSiteName, long occurredAt) { + addVote(voteUsername, voteSiteName, occurredAt, UUID.randomUUID()); + } + + public void addVote(String voteUsername, String voteSiteName, long occurredAt, UUID localOccurrenceId) { + java.util.Objects.requireNonNull(localOccurrenceId, "localOccurrenceId"); + VoteTimeQueue queued = new VoteTimeQueue(voteUsername, voteSiteName, + LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + queued.setLocalOccurrenceId(localOccurrenceId); + if (occurredAt > 0) queued.setCanonicalOccurrenceTime(occurredAt); + timeChangeQueue.add(queued); + } /** * Loads cached votes from server data and schedules queue processing. @@ -59,8 +70,29 @@ public void addVote(String voteUsername, String voteSiteName) { public void load() { for (String str : plugin.getServerData().getTimedVoteCacheKeys()) { ConfigurationSection data = plugin.getServerData().getTimedVoteCacheSection(str); - timeChangeQueue - .add(new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time"))); + VoteTimeQueue queued = new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time")); + UUID localOccurrenceId; + if (!data.contains("LocalOccurrenceId")) { + // The existing ServerData saver can defer publication. Derive only + // legacy migration IDs from their persisted row identity so a crash + // before that save completes cannot reroll the same legacy row. + String identity = "legacy-local-timed-vote\u0000" + str + "\u0000" + queued.getName() + + "\u0000" + queued.getService() + "\u0000" + queued.getTime(); + localOccurrenceId = UUID.nameUUIDFromBytes(identity.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + plugin.getServerData().setTimedVoteLocalOccurrenceId(str, localOccurrenceId); + } else { + String rawId = data.getString("LocalOccurrenceId"); + try { + localOccurrenceId = UUID.fromString(rawId); + if (!localOccurrenceId.toString().equalsIgnoreCase(rawId)) throw new IllegalArgumentException(); + } catch (IllegalArgumentException | NullPointerException malformed) { + plugin.getLogger().warning("Invalid local occurrence ID in timed vote cache; row withheld"); + continue; + } + } + queued.setLocalOccurrenceId(localOccurrenceId); + if (data.contains("CanonicalOccurrenceTime")) queued.setCanonicalOccurrenceTime(data.getLong("CanonicalOccurrenceTime", -1L)); + timeChangeQueue.add(queued); } scheduleQueueProcessing(120, TimeUnit.SECONDS); } @@ -116,6 +148,8 @@ public void processQueue() { plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), vote.getService(), true); voteEvent.setTime(vote.getTime()); + voteEvent.setLocalOccurrenceId(vote.getLocalOccurrenceId()); + if (vote.getCanonicalOccurrenceTime() != 0L) voteEvent.setCanonicalOccurrenceTime(vote.getCanonicalOccurrenceTime()); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isCancelled()) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java index c2d183f409..6864fb6684 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java @@ -30,6 +30,10 @@ public class VoteTimeQueue { @Getter @Setter private UUID voteId; + /** Local Bukkit receipt identity; never used as a proxy transport vote ID. */ + @Getter + @Setter + private UUID localOccurrenceId; /** SQL primary key for a legacy timed-cache row, or {@code -1} when not SQL-backed. */ @Getter @Setter @@ -47,6 +51,19 @@ public class VoteTimeQueue { @Getter @Setter private String totals; + + public long getCanonicalOccurrenceTime() { + Long original = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.read(totals); + return original == null ? 0L : original; + } + + public void setCanonicalOccurrenceTime(long time) { + totals = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.store(totals, time); + } + + public void setTotals(String updated) { + totals = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.preserve(totals, updated); + } @Getter @Setter private boolean processed; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index d985aa23e8..837c485177 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1003,24 +1003,64 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote, boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId, boolean targetedProxyVote) { + bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote, + proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, true); + } + + /** + * Normal event processing finished, but date accounting was not confirmed. + * A router must finish its remaining normal effects before quarantining the envelope. + */ + public static final class DateMilestoneAccountingException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private DateMilestoneAccountingException() { + super("Date milestone accounting is incomplete; retain the durable proxy envelope for reconciliation"); + } + } + + /** Preserve the canonical wire real/test flag; existing callers retain their historical real-vote default. */ + 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 realVote) { + bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote, + proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, realVote, time); + } + + 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 realVote, long canonicalOccurrenceTime) { bungeeVotePluginMessagingEvent(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote, - proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote); + proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, + realVote, canonicalOccurrenceTime); } - /** Dispatches the proxy vote and returns its final cancellation state; null when proxy mode is disabled. */ + /** Dispatches a legacy proxy vote and returns its 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) { + return bungeeVotePluginMessagingEvent(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote, + proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, true, time); + } + + /** Preserves occurrence provenance and returns final cancellation; null when proxy mode is disabled. */ + 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 realVote, long canonicalOccurrenceTime) { if (plugin.getBungeeSettings().isUseBungeecoord()) { plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service); PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true), - getPlayerName(), service, true); + getPlayerName(), service, realVote); voteEvent.setBungee(true); voteEvent.setVotingPluginUser(this); voteEvent.setForceBungee(true); voteEvent.setTime(time); + voteEvent.setCanonicalOccurrenceTime(canonicalOccurrenceTime); voteEvent.setAddTotals(setTotals); voteEvent.setBungeeTextTotals(text); voteEvent.setProxyVoteId(proxyVoteId); @@ -1032,6 +1072,7 @@ public PlayerVoteEvent bungeeVotePluginMessagingEvent(String service, long time, voteEvent.setProxyDelayValidationKnown(proxyDelayValidationKnown); voteEvent.setTargetedProxyVote(targetedProxyVote); plugin.getServer().getPluginManager().callEvent(voteEvent); + if (voteEvent.isDateMilestoneAccountingFailed()) throw new DateMilestoneAccountingException(); return voteEvent; } return null; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java index e7042a0d62..2ef0e4c432 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java @@ -18,6 +18,19 @@ public final class BukkitCompletionScheduler { public static boolean isSuccessfulEntityTaskResult(Object status) { return status instanceof Enum result && "SUCCESS".equals(result.name()); } + + /** Event flags are not a reliable execution-context check, including Folia region threads. */ + public static boolean isPlatformOwnedThread() { + try { if (org.bukkit.Bukkit.isPrimaryThread()) return true; } catch (RuntimeException ignored) { } + Object server = org.bukkit.Bukkit.getServer(); + if (server != null) try { + if (Boolean.TRUE.equals(server.getClass().getMethod("isGlobalTickThread").invoke(server))) return true; + } catch (ReflectiveOperationException | RuntimeException ignored) { } + for (String name : new String[] { "ca.spottedleaf.moonrise.common.util.TickThread", "io.papermc.paper.util.TickThread" }) try { + if (Boolean.TRUE.equals(Class.forName(name).getMethod("isTickThread").invoke(null))) return true; + } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { } + return false; + } private BukkitCompletionScheduler() { } diff --git a/VotingPlugin/src/main/resources/SpecialRewards.yml b/VotingPlugin/src/main/resources/SpecialRewards.yml index 6b48ca4db0..35028a6044 100644 --- a/VotingPlugin/src/main/resources/SpecialRewards.yml +++ b/VotingPlugin/src/main/resources/SpecialRewards.yml @@ -397,3 +397,43 @@ DailyAwards: Rewards: Messages: Player: '&aYou came in second place in %TopVoter%!' + +# Owner-defined dated events. This example is DISABLED by default. +# Set your own dates/rewards, then enable the event. No automatic recurring reset. +# Keep the event ID stable to retain progress; never delete its ledger and reuse it. +# After accounting starts, use a NEW ID for changes to dates, timezone, thresholds, +# VoteSites or AccountingServer. Unsafe edits are rejected, preserving progress. +# DisplayName and Enabled may change without resetting counts. +# Full configuration and durability limits: docs/date-vote-milestones.md +DateVoteMilestones: + example_event: + Enabled: false + DisplayName: 'Example voting event' + # Local ISO date-times interpreted in the explicit IANA timezone below. + Start: '2026-10-01T00:00:00' # Inclusive: occurrences at this instant count. + End: '2026-11-01T00:00:00' # Exclusive: occurrences at this instant do not count. + Timezone: 'UTC' + # Ledger owner: empty/omitted means local accounting on standalone servers. + # With UseBungeecord enabled, empty/omitted disables new date accounting. + # Proxy: set the exact, case-sensitive BungeeSettings.yml Server name of ONE + # backend, identically on every backend, with identified all-server delivery. + # Reward delivery follows the existing reward settings. Keep matching event + # IDs and milestone reward sections on backends that may resolve rewards. + AccountingServer: '' + # Configured VoteSites.yml keys (not service names); empty means all sites. + VoteSites: [] + # Named milestones use At (one count, list, or bounded range) / Every. + # Existing numeric keys with Rewards remain supported; see the guide. + Milestones: + TenVotes: + Enabled: true + At: 10 + Rewards: + Messages: + Player: '&aYou reached 10 votes in %DateVoteEvent%!' + TwentyFiveVotes: + Enabled: true + At: 25 + Rewards: + Messages: + Player: '&aYou reached 25 votes in %DateVoteEvent%!' diff --git a/VotingPlugin/src/main/resources/plugin.yml b/VotingPlugin/src/main/resources/plugin.yml index 633dc78417..b7448241f9 100644 --- a/VotingPlugin/src/main/resources/plugin.yml +++ b/VotingPlugin/src/main/resources/plugin.yml @@ -195,3 +195,7 @@ permissions: VotingPlugin.Login.RemindVotes.All: description: Remind if player can vote on all votesites, rather than any default: false + + VotingPlugin.Commands.Vote.DateEvents: + description: View owner-configured date event progress + default: false diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java new file mode 100644 index 0000000000..66a7f4799f --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java @@ -0,0 +1,125 @@ +package com.bencodez.votingplugin; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.bukkit.configuration.file.YamlConfiguration; +import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones; +import com.bencodez.votingplugin.user.VotingPluginUser; +class DateVoteIngressOrderingTest { + @TempDir Path root; + @Test void firstTransportDeliveryFindsDefinitionsAndRewardHandlesAlreadyPublished() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + YamlConfiguration config = new YamlConfiguration(); + String prefix = "DateVoteMilestones.startup."; + config.set(prefix + "Enabled", true); config.set(prefix + "Timezone", "UTC"); + config.set(prefix + "Start", "2020-01-01T00:00:00"); config.set(prefix + "End", "2099-01-01T00:00:00"); + config.set(prefix + "AccountingServer", "backend"); + config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + when(plugin.getBungeeSettings().getServer()).thenReturn("backend"); + DateVoteMilestones events = new DateVoteMilestones(plugin); + when(plugin.getDateVoteMilestones()).thenReturn(events); + VotingPluginUser user = mock(VotingPluginUser.class); when(user.getJavaUUID()).thenReturn(UUID.randomUUID()); + Runnable ingress = () -> { + verify(plugin).addDirectlyDefinedRewards(any()); + events.accepted(user, "a", UUID.randomUUID(), System.currentTimeMillis(), true, true, true, false, false); + }; + ready(plugin); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + plugin.initializeDateVoteIngress(ingress); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + assertTrue(java.nio.file.Files.exists(root.resolve("date-vote-milestones"))); + } + private static final String[] HANDLERS = {"voteMilestonesManager", "voteStreakHandler", "voteParty", "specialRewards", "topVoterHandler", "voteShopManager", "placeholders"}; + private static void ready(VotingPluginMain plugin) throws Exception { + for (String name : HANDLERS) { + var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, mock(field.getType())); + } + } + @Test void eachMissingDownstreamHandlerKeepsIngressClosed() throws Exception { + for (String name : HANDLERS) { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); ready(plugin); + var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, null); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); Runnable ingress = mock(Runnable.class); + assertThrows(IllegalStateException.class, () -> plugin.initializeDateVoteIngress(ingress)); verify(ingress, never()).run(); + verify(plugin.getDateVoteMilestones(), never()).reload(); + verify(plugin.getServer().getPluginManager(), never()).registerEvents(any(), eq(plugin)); + } + } + + @Test void earlyCaptureIsRegisteredOnceAndConsumersOpenOnlyAfterDefinitionsAndHandlers() throws Exception { + for (boolean proxyEnabled : new boolean[] {false, true}) { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.isVotifierLoaded()).thenReturn(true); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(proxyEnabled); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getVoteTimer()).thenReturn(timer); + var registered = new java.util.ArrayList(); + var manager = plugin.getServer().getPluginManager(); + doAnswer(call -> { registered.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin)); + doCallRealMethod().when(plugin).registerEarlyVotifierIngress(); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + try (var queues = mockConstruction(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class)) { + plugin.registerEarlyVotifierIngress(); plugin.registerEarlyVotifierIngress(); + assertEquals(1, registered.size()); + assertInstanceOf(com.bencodez.votingplugin.listeners.VotiferEvent.class, registered.getFirst()); + verify(plugin.getDateVoteMilestones(), never()).reload(); + assertEquals(1, queues.constructed().size(), "capture publishes its independent queue owner immediately"); + var queueField = VotingPluginMain.class.getDeclaredField("votifierVoteOverflowQueue"); + queueField.setAccessible(true); + assertSame(queues.constructed().getFirst(), queueField.get(plugin)); + verifyNoInteractions(timer); // Loading cannot be lost with vote-executor cancellation. + verify(queues.constructed().getFirst(), never()).start(); + ready(plugin); + Runnable proxy = () -> { + verify(plugin.getDateVoteMilestones()).reload(); + assertEquals(3, registered.size()); + assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, registered.get(1)); + assertInstanceOf(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class, registered.get(2)); + }; + plugin.initializeDateVoteIngress(proxy); plugin.initializeDateVoteIngress(proxy); + assertEquals(3, registered.size()); + verify(plugin.getDateVoteMilestones()).reload(); verify(queues.constructed().getFirst()).start(); + } + } + } + + @Test void persistedReplayProducerIsConstructedOnlyAfterItsAcceptedVoteConsumer() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); ready(plugin); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + var manager = plugin.getServer().getPluginManager(); + var registered = new java.util.ArrayList(); + doAnswer(call -> { registered.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin)); + try (var producers = mockConstruction(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class, (handler, context) -> { + // The real constructor schedules replay immediately; this hook runs at that boundary. + assertEquals(1, registered.size()); assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, registered.getFirst()); + verify(plugin.getDateVoteMilestones()).reload(); + })) { + plugin.initializeDateVoteIngress(() -> fail("Local startup does not open proxy ingress")); + assertEquals(1, producers.constructed().size()); assertSame(producers.constructed().getFirst(), registered.get(1)); + var field = VotingPluginMain.class.getDeclaredField("timeQueueHandler"); field.setAccessible(true); + assertSame(producers.constructed().getFirst(), field.get(plugin)); + } + } + + @Test void failedProxyOpeningRetriesWithoutDuplicatingAcceptedConsumers() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ready(plugin); when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + var registered = new java.util.ArrayList(); + var eventManager = plugin.getServer().getPluginManager(); + doAnswer(call -> { registered.add(call.getArgument(0)); return null; }) + .when(eventManager).registerEvents(any(), eq(plugin)); + assertThrows(IllegalStateException.class, () -> plugin.initializeDateVoteIngress(() -> { throw new IllegalStateException("transport unavailable"); })); + assertEquals(2, registered.size()); + plugin.initializeDateVoteIngress(() -> { }); plugin.initializeDateVoteIngress(() -> fail("Already ready")); + assertEquals(2, registered.size()); verify(plugin.getDateVoteMilestones(), times(2)).reload(); + } + +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java new file mode 100644 index 0000000000..58f0d74904 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java @@ -0,0 +1,475 @@ +package com.bencodez.votingplugin; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.logging.Logger; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.Listener; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.advancedcore.api.user.validation.*; +import com.bencodez.votingplugin.events.*; +import com.bencodez.votingplugin.listeners.*; +import com.bencodez.votingplugin.specialrewards.datemilestones.*; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.BoundedScheduledExecutor; +import com.bencodez.votingplugin.votesites.VoteSite; +import com.vexsoftware.votifier.model.Vote; + +/** Genuine NuVotifier input traverses actual startup capture, bounded workers and the accepted consumer. */ +class DateVotifierStartupCaptureTest { + @TempDir Path root; + private static final String SERVICE = "startup.example"; + private static final String[] HANDLERS = { "voteMilestonesManager", "voteStreakHandler", "voteParty", "specialRewards", "topVoterHandler", "voteShopManager", "placeholders" }; + static void ready(VotingPluginMain plugin) throws Exception { + for (String name : HANDLERS) { + var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, mock(field.getType())); + } + } + private final class Fixture implements AutoCloseable { + final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + final BoundedScheduledExecutor timer = new BoundedScheduledExecutor(1, 256); + final VotingPluginUser user = mock(VotingPluginUser.class); + final VoteSite site = mock(VoteSite.class); + final YamlConfiguration config = new YamlConfiguration(); + final List registered = new CopyOnWriteArrayList<>(); + final List accepted = new CopyOnWriteArrayList<>(); + final List posted = new CopyOnWriteArrayList<>(); + final CountDownLatch delivered; + final DateVoteMilestones dates; + volatile Runnable afterAccepted = () -> { }; + Fixture(Path directory, int expectedVotes) throws Exception { this(directory, expectedVotes, UUID.randomUUID()); } + Fixture(Path directory, int expectedVotes, UUID playerId) throws Exception { + delivered = new CountDownLatch(expectedVotes); + timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + timer.setThreadFactory(work -> new Thread(work, "date-startup-vote-owner")); + when(plugin.isEnabled()).thenReturn(true); when(plugin.isVotifierLoaded()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); when(plugin.getVoteTimer()).thenReturn(timer); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + doCallRealMethod().when(plugin).getVotifierVoteOverflowQueue(); + doCallRealMethod().when(plugin).registerEarlyVotifierIngress(); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn("."); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.getConfigFile().isAddTotals()).thenReturn(true); + // Create worker-used deep stubs before concurrent overflow admission + // and ordinary processing; Mockito cannot safely stub on both workers. + plugin.getServerData(); plugin.getBungeeSettings(); plugin.getTimeChecker(); + plugin.getBroadcastHandler(); plugin.getVoteParty(); plugin.getCoolDownCheck(); + plugin.getVoteMilestonesManager(); plugin.getVoteStreakHandler(); + plugin.getPlaceholders(); plugin.getConfigFile().getPlaceholderCacheLevel(); + plugin.getRewardHandler(); + when(plugin.getUserManager().getProperName("Steve")).thenReturn("Steve"); + when(plugin.getUserManager().getValidationService().validate("Steve", false)).thenReturn( + new UserValidationResult(ValidationStatus.VALID, "Steve", ValidationSource.STORAGE, "fixture", false)); + when(user.getPlayerName()).thenReturn("Steve"); when(user.getJavaUUID()).thenReturn(playerId); + when(user.getUUID()).thenReturn(playerId.toString()); when(user.isOnline()).thenReturn(true); + when(site.isEnabled()).thenReturn(true); when(site.getKey()).thenReturn("a"); when(site.getServiceSite()).thenReturn(SERVICE); + when(plugin.getVoteSiteManager().getVoteSiteName(false, SERVICE, "")).thenReturn("a"); + when(plugin.getVoteSiteManager().hasVoteSite("a")).thenReturn(true); + when(plugin.getVoteSiteManager().getVoteSiteName(true, SERVICE, "")).thenReturn("a"); + when(plugin.getVoteSiteManager().getVoteSite("a", true)).thenReturn(site); + String prefix = "DateVoteMilestones.startup."; + config.set(prefix + "Enabled", true); config.set(prefix + "Start", "2020-01-01T00:00:00"); + config.set(prefix + "End", "2099-01-01T00:00:00"); config.set(prefix + "Timezone", "UTC"); + config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + dates = new DateVoteMilestones(plugin); when(plugin.getDateVoteMilestones()).thenReturn(dates); + var manager = plugin.getServer().getPluginManager(); + doAnswer(call -> { registered.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin)); + doAnswer(call -> { + Object event = call.getArgument(0); + if (event instanceof PlayerVoteEvent vote) { + assertEquals("date-startup-vote-owner", Thread.currentThread().getName()); + vote.setVotingPluginUser(user); accepted.add(vote); + var consumer = registered.stream().filter(PlayerVoteListener.class::isInstance).map(PlayerVoteListener.class::cast).findFirst().orElseThrow(); + consumer.onplayerVote(vote); + afterAccepted.run(); + } else if (event instanceof PlayerPostVoteEvent post) { posted.add(post); delivered.countDown(); } + return null; + }).when(manager).callEvent(any()); + } + VotiferEvent capture() { return registered.stream().filter(VotiferEvent.class::isInstance).map(VotiferEvent.class::cast).findFirst().orElseThrow(); } + void vote() { capture().onVotiferEvent(new com.vexsoftware.votifier.model.VotifierEvent(new Vote(SERVICE, "Steve", "127.0.0.1", "1"))); } + void drain() throws Exception { + timer.submit(() -> { }).get(3, TimeUnit.SECONDS); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while ((!plugin.getVotifierVoteOverflowQueue().isInitializationComplete() || capture().getPendingCaptureCount() != 0) + && System.nanoTime() < deadline) Thread.sleep(5); + assertTrue(plugin.getVotifierVoteOverflowQueue().isInitializationComplete(), "queue initialization did not finish"); + assertEquals(0, capture().getPendingCaptureCount(), "capture transfers did not finish"); + timer.submit(() -> { }).get(3, TimeUnit.SECONDS); + } + void open() throws Exception { ready(plugin); plugin.initializeDateVoteIngress(() -> fail("Standalone does not open proxy ingress")); } + void received() throws Exception { assertTrue(delivered.await(3, TimeUnit.SECONDS)); drain(); } + DateVoteLedger.Progress progress() throws Exception { + var event = DateVoteMilestones.parse("startup", config.getConfigurationSection("DateVoteMilestones.startup")); + return new DateVoteLedger(plugin.getDataFolder().toPath().resolve("date-vote-milestones")).progress(event, user.getJavaUUID()); + } + public void close() { + capture().stop(); timer.shutdownNow(); + try { assertTrue(timer.awaitTermination(3, TimeUnit.SECONDS)); } catch (InterruptedException failure) { throw new AssertionError(failure); } + var queue = plugin.getVotifierVoteOverflowQueue(); if (queue != null) queue.close(); + } + } + + @Test void actualExternalEventsAreCapturedBeforeConsumersAndDeliveredOnceAfterReadiness() throws Exception { + try (var fixture = new Fixture(root, 2)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); + assertEquals(1, fixture.registered.size()); + long before = System.currentTimeMillis(); fixture.vote(); fixture.vote(); long after = System.currentTimeMillis(); + fixture.drain(); + assertEquals(2, fixture.plugin.getVotifierVoteOverflowQueue().size()); assertTrue(fixture.accepted.isEmpty()); + verify(fixture.plugin.getServerData(), never()).addServiceSite(anyString()); + verify(fixture.user, never()).cache(); verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + long openedAt = System.currentTimeMillis(); fixture.open(); fixture.plugin.initializeDateVoteIngress(() -> fail("Repeated startup cannot reopen transports")); + fixture.received(); fixture.vote(); fixture.drain(); + assertEquals(3, fixture.registered.size()); assertEquals(3, fixture.accepted.size()); assertEquals(3, fixture.posted.size()); + assertEquals(0, fixture.plugin.getVotifierVoteOverflowQueue().size()); + for (int index = 0; index < 2; index++) { + long occurrence = fixture.accepted.get(index).getCanonicalOccurrenceTime(); + assertTrue(occurrence >= before && occurrence <= after); + assertEquals(0L, fixture.accepted.get(index).getTime()); + assertEquals(occurrence, fixture.posted.get(index).getCanonicalOccurrenceTime()); + assertTrue(fixture.posted.get(index).getVoteTime() >= openedAt); + } + assertEquals(3, fixture.accepted.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + assertTrue(fixture.accepted.stream().allMatch(vote -> vote.getLocalOccurrenceId() != null && vote.getProxyVoteId() == null)); + for (int index = 0; index < 3; index++) assertEquals(fixture.accepted.get(index).getLocalOccurrenceId(), fixture.posted.get(index).getVoteUUID()); + assertEquals(3, fixture.progress().votes()); + verify(fixture.user, times(3)).setTime(fixture.site); + verify(fixture.user, never()).setTime(eq(fixture.site), anyLong()); + verify(fixture.user, times(3)).addTotal(); verify(fixture.user, times(3)).playerVote(fixture.site, true, false); + verify(fixture.plugin.getRewardHandler(), times(1)).giveReward(eq(fixture.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + } + } + + @Test void failedReadinessRetainsCaptureAndUsesCurrentDefinitionsWhenStartupRetries() throws Exception { + try (var fixture = new Fixture(root, 1)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + assertThrows(IllegalStateException.class, () -> fixture.plugin.initializeDateVoteIngress(() -> fail("Not ready"))); + assertEquals(1, fixture.registered.size()); assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size()); + // Reload/edit before release: a stale enabled snapshot must not award a buffered vote. + fixture.config.set("DateVoteMilestones.startup.Enabled", false); + fixture.plugin.registerEarlyVotifierIngress(); fixture.open(); fixture.received(); + assertEquals(1, fixture.accepted.size()); assertEquals(0, fixture.progress().votes()); + verify(fixture.user).addTotal(); verify(fixture.user).playerVote(fixture.site, true, false); + verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + } + + @Test void preReadyCaptureSurvivesRestartWithItsSeparateOccurrence() throws Exception { + long original; + try (var fixture = new Fixture(root, 0)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size()); assertTrue(fixture.accepted.isEmpty()); + // Closing the real overflow writes the captured receipt; no consumer was opened. + fixture.plugin.getVotifierVoteOverflowQueue().close(); + var yaml = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()); + original = ((Number) yaml.getMapList("Votes").getFirst().get("Time")).longValue(); + } + try (var restarted = new Fixture(root, 1)) { + restarted.plugin.registerEarlyVotifierIngress(); restarted.drain(); + assertEquals(1, restarted.plugin.getVotifierVoteOverflowQueue().size()); assertTrue(restarted.accepted.isEmpty()); + restarted.open(); restarted.received(); + assertEquals(1, restarted.accepted.size()); assertEquals(original, restarted.accepted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(0L, restarted.accepted.getFirst().getTime()); assertEquals(original, restarted.posted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(1, restarted.progress().votes()); verify(restarted.user).addTotal(); + } + } + + @Test void crashAfterLedgerBeforeOverflowAckReplaysTheSameOccurrenceWithoutAnotherAward() throws Exception { + UUID playerId = UUID.randomUUID(); + UUID original; + long originalTime; + var committed = new CountDownLatch(1); + var release = new CountDownLatch(1); + try (var fixture = new Fixture(root, 1, playerId)) { + // Recounting a replay would unlock a distinct second reward. + fixture.config.set("DateVoteMilestones.startup.Milestones.2.Rewards.Messages.Player", "Second"); + fixture.afterAccepted = () -> { + committed.countDown(); + try { assertTrue(release.await(3, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + }; + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + fixture.open(); + assertTrue(committed.await(3, TimeUnit.SECONDS)); + try { + assertEquals(1, fixture.progress().votes()); + original = fixture.accepted.getFirst().getLocalOccurrenceId(); + originalTime = fixture.accepted.getFirst().getCanonicalOccurrenceTime(); + // The native callback has not returned, so existing shutdown retains + // precisely the same durable receipt a process crash would leave. + fixture.plugin.getVotifierVoteOverflowQueue().close(); + var disk = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()); + assertEquals(original.toString(), disk.getMapList("Votes").getFirst().get("LocalOccurrenceId")); + verify(fixture.plugin.getRewardHandler(), times(1)).giveReward(eq(fixture.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + } finally { release.countDown(); } + fixture.drain(); + } + try (var restarted = new Fixture(root, 1, playerId)) { + restarted.config.set("DateVoteMilestones.startup.Milestones.2.Rewards.Messages.Player", "Second"); + restarted.plugin.registerEarlyVotifierIngress(); restarted.drain(); restarted.open(); restarted.received(); + assertEquals(original, restarted.accepted.getFirst().getLocalOccurrenceId()); + assertEquals(original, restarted.posted.getFirst().getVoteUUID()); + assertNull(restarted.accepted.getFirst().getProxyVoteId()); + assertFalse(restarted.accepted.getFirst().isBungee()); + assertEquals(originalTime, restarted.accepted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(0L, restarted.accepted.getFirst().getTime()); + assertEquals(1, restarted.progress().votes()); + verify(restarted.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + // Ordinary effects retain their existing at-least-once replay semantics. + verify(restarted.user).setTime(restarted.site); + verify(restarted.user).addTotal(); verify(restarted.user).playerVote(restarted.site, true, false); + } + } + + @Test void blockedExecutorCannotLoseEarlyCaptureOrQueueSetupOnCancellation() throws Exception { + capturedAdmissionSurvivesCancellation(false); + } + + @Test void blockedExecutorCannotLoseReadyCaptureOnCancellation() throws Exception { + capturedAdmissionSurvivesCancellation(true); + } + + @Test void fullRecoveredStartupQueueRetriesNewCaptureAndCountsItsOriginalReceiptOnce() throws Exception { + var rows = new ArrayList>(); + var recoveredIds = new HashSet(); + long recoveredTime = System.currentTimeMillis() - 1000; + for (int index = 0; index < 256; index++) { + UUID id = UUID.randomUUID(); recoveredIds.add(id); + rows.add(Map.of("Username", "Steve", "ServiceSite", SERVICE, "Time", recoveredTime, + "LocalOccurrenceId", id.toString())); + } + var yaml = new YamlConfiguration(); yaml.set("Votes", rows); + yaml.save(root.resolve("VotifierVoteQueue.yml").toFile()); + try (var fixture = new Fixture(root, 257)) { + when(fixture.plugin.getLogger()).thenReturn(mock(Logger.class)); + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); + var overflow = fixture.plugin.getVotifierVoteOverflowQueue(); assertEquals(256, overflow.size()); + fixture.vote(); fixture.timer.submit(() -> { }).get(3, TimeUnit.SECONDS); + var workerField = VotifierVoteOverflowQueue.class.getDeclaredField("worker"); workerField.setAccessible(true); + var worker = (ScheduledExecutorService) workerField.get(overflow); + worker.submit(() -> { }).get(3, TimeUnit.SECONDS); + assertEquals(1, fixture.capture().getPendingCaptureCount()); assertTrue(fixture.accepted.isEmpty()); + var capturesField = VotiferEvent.class.getDeclaredField("captured"); capturesField.setAccessible(true); + var captures = (Map) capturesField.get(fixture.capture()); + UUID original = (UUID) captures.keySet().iterator().next(); + var capture = captures.values().iterator().next(); + var timeField = capture.getClass().getDeclaredField("occurredAt"); timeField.setAccessible(true); + long originalTime = timeField.getLong(capture); + fixture.open(); + assertTrue(fixture.delivered.await(10, TimeUnit.SECONDS), "Startup must retry capture after recovered overflow drains"); + fixture.drain(); assertEquals(257, fixture.accepted.size()); assertEquals(257, fixture.posted.size()); + assertEquals(257, fixture.accepted.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + assertTrue(fixture.accepted.stream().map(PlayerVoteEvent::getLocalOccurrenceId).toList().containsAll(recoveredIds)); + var retried = fixture.accepted.stream().filter(event -> original.equals(event.getLocalOccurrenceId())).toList(); + assertEquals(1, retried.size()); assertEquals(originalTime, retried.getFirst().getCanonicalOccurrenceTime()); + assertEquals(0L, retried.getFirst().getTime()); assertNull(retried.getFirst().getProxyVoteId()); + var post = fixture.posted.stream().filter(event -> original.equals(event.getVoteUUID())).toList(); + assertEquals(1, post.size()); assertEquals(originalTime, post.getFirst().getCanonicalOccurrenceTime()); + assertEquals(257, fixture.progress().votes()); + verify(fixture.user, times(257)).setTime(fixture.site); + verify(fixture.user, times(257)).addTotal(); verify(fixture.user, times(257)).playerVote(fixture.site, true, false); + verify(fixture.plugin.getRewardHandler(), times(1)).giveReward(eq(fixture.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + assertEquals(0, overflow.size()); + } + } + + private void capturedAdmissionSurvivesCancellation(boolean readyFirst) throws Exception { + UUID playerId = UUID.randomUUID(); + UUID original; + long originalTime; + try (var fixture = new Fixture(root, 0, playerId)) { + if (readyFirst) { fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.open(); } + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + fixture.timer.submit(() -> { + entered.countDown(); + boolean interrupted = false; + try { + while (release.getCount() != 0) { + try { assertTrue(release.await(3, TimeUnit.SECONDS)); } + catch (InterruptedException expected) { interrupted = true; } + } + } finally { if (interrupted) Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(3, TimeUnit.SECONDS)); + // In the early case, even registration/setup happens behind the held + // vote worker. Queue construction and disk load must be independent. + if (!readyFirst) fixture.plugin.registerEarlyVotifierIngress(); + assertNotNull(fixture.plugin.getVotifierVoteOverflowQueue()); + long before = System.currentTimeMillis(); fixture.vote(); long after = System.currentTimeMillis(); + assertEquals(1, fixture.capture().getPendingCaptureCount()); + assertTrue(fixture.accepted.isEmpty()); + var registryField = VotiferEvent.class.getDeclaredField("captured"); registryField.setAccessible(true); + var registry = (Map) registryField.get(fixture.capture()); + original = (UUID) registry.keySet().iterator().next(); + var captureTime = registry.values().iterator().next().getClass().getDeclaredField("occurredAt"); captureTime.setAccessible(true); + originalTime = captureTime.getLong(registry.values().iterator().next()); + try { + fixture.capture().stop(); + var cancelled = fixture.timer.shutdownNow(); + if (readyFirst) assertFalse(cancelled.isEmpty(), "the admitted capture callback never executed"); + // Before queue initialization, the receipt is provisionally owned + // by ingress; retirement must save it without executor admission. + } finally { release.countDown(); } + assertTrue(fixture.timer.awaitTermination(3, TimeUnit.SECONDS)); + fixture.plugin.getVotifierVoteOverflowQueue().close(); + assertEquals(0, fixture.capture().getPendingCaptureCount()); + var disk = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + assertEquals(1, disk.size()); + assertEquals(original, UUID.fromString((String) disk.getFirst().get("LocalOccurrenceId"))); + assertEquals(originalTime, ((Number) disk.getFirst().get("Time")).longValue()); + assertTrue(originalTime >= before && originalTime <= after); + verify(fixture.user, never()).addTotal(); + verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + try (var restarted = new Fixture(root, 1, playerId)) { + restarted.plugin.registerEarlyVotifierIngress(); restarted.drain(); restarted.open(); restarted.received(); + assertEquals(original, restarted.accepted.getFirst().getLocalOccurrenceId()); + assertEquals(original, restarted.posted.getFirst().getVoteUUID()); + assertEquals(originalTime, restarted.accepted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(0L, restarted.accepted.getFirst().getTime()); + assertEquals(1, restarted.progress().votes()); + verify(restarted.user).addTotal(); verify(restarted.user).setTime(restarted.site); + verify(restarted.plugin.getRewardHandler(), times(1)).giveReward(eq(restarted.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + } + try (var secondRestart = new Fixture(root, 0, playerId)) { + secondRestart.plugin.registerEarlyVotifierIngress(); secondRestart.drain(); secondRestart.open(); secondRestart.drain(); + assertTrue(secondRestart.accepted.isEmpty()); assertEquals(1, secondRestart.progress().votes()); + } + } + + @Test void retiredCaptureCannotReopenAnOldStartupGeneration() throws Exception { + try (var fixture = new Fixture(root, 0)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + fixture.capture().stop(); + var field = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed"); field.setAccessible(true); field.setBoolean(fixture.plugin, true); + fixture.vote(); fixture.drain(); + assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size()); + assertThrows(IllegalStateException.class, () -> fixture.plugin.initializeDateVoteIngress(() -> fail("Retired"))); + assertTrue(fixture.accepted.isEmpty()); assertEquals(1, fixture.registered.size()); + } + } + + @Test void startedNativeCallbackBlockedBeforeConsumerSurvivesShutdownAndRewardsOnceAfterRestart() throws Exception { + UUID playerId = UUID.randomUUID(), original; long originalTime; + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + try (var fixture = new Fixture(root, 0, playerId)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.open(); + var serverData = fixture.plugin.getServerData(); + doAnswer(call -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test storage timeout"); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw new IllegalStateException("native receipt storage interrupted before consumer", interrupted); + } + return null; + }).when(serverData).addServiceSite(SERVICE); + fixture.vote(); assertTrue(entered.await(3, TimeUnit.SECONDS)); + assertEquals(1, fixture.capture().getPendingCaptureCount()); assertTrue(fixture.accepted.isEmpty()); + var registryField = VotiferEvent.class.getDeclaredField("captured"); registryField.setAccessible(true); + var registry = (Map) registryField.get(fixture.capture()); original = (UUID) registry.keySet().iterator().next(); + var captureTime = registry.values().iterator().next().getClass().getDeclaredField("occurredAt"); captureTime.setAccessible(true); + originalTime = captureTime.getLong(registry.values().iterator().next()); + // Use the native teardown fence, then the real listener/worker stop order. + var closed = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed"); closed.setAccessible(true); closed.setBoolean(fixture.plugin, true); + var readiness = VotingPluginMain.class.getDeclaredField("localVotifierIngressReady"); readiness.setAccessible(true); readiness.setBoolean(fixture.plugin, false); + fixture.capture().stop(); fixture.timer.shutdownNow(); assertTrue(fixture.timer.awaitTermination(3, TimeUnit.SECONDS)); + fixture.plugin.getVotifierVoteOverflowQueue().close(); assertEquals(0, fixture.capture().getPendingCaptureCount()); + var disk = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + assertEquals(1, disk.size()); assertEquals(original.toString(), disk.getFirst().get("LocalOccurrenceId")); + assertEquals(originalTime, ((Number) disk.getFirst().get("Time")).longValue()); + verify(fixture.user, never()).cache(); verify(fixture.user, never()).addTotal(); + verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } finally { release.countDown(); } + try (var restarted = new Fixture(root, 1, playerId)) { + restarted.plugin.registerEarlyVotifierIngress(); restarted.drain(); restarted.open(); restarted.received(); + assertEquals(original, restarted.accepted.getFirst().getLocalOccurrenceId()); + assertEquals(originalTime, restarted.accepted.getFirst().getCanonicalOccurrenceTime()); assertEquals(1, restarted.progress().votes()); + verify(restarted.user).addTotal(); verify(restarted.user).playerVote(restarted.site, true, false); + verify(restarted.plugin.getRewardHandler(), times(1)).giveReward(eq(restarted.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + } + try (var again = new Fixture(root, 0, playerId)) { + again.plugin.registerEarlyVotifierIngress(); again.drain(); again.open(); again.drain(); + assertTrue(again.accepted.isEmpty()); assertEquals(1, again.progress().votes()); verify(again.user, never()).addTotal(); + } + } + + @Test void productionOverflowRetainsStorageRejectionThenRetriesOriginalOccurrence() throws Exception { + try (var fixture = new Fixture(root, 1)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.open(); + var rejected = new CountDownLatch(1); + var permitStorage = new java.util.concurrent.atomic.AtomicBoolean(); + var serverData = fixture.plugin.getServerData(); + doAnswer(call -> { + if (!permitStorage.get()) { + rejected.countDown(); + throw new RejectedExecutionException("synthetic storage admission rejection"); + } + return null; + }).when(serverData).addServiceSite(SERVICE); + UUID original = UUID.fromString("00000000-0000-0000-0000-000000000019"); + long originalTime = java.time.Instant.parse("2026-10-01T00:00:00Z").toEpochMilli(); + var overflow = fixture.plugin.getVotifierVoteOverflowQueue(); + assertTrue(overflow.enqueue("Steve", SERVICE, originalTime, original)); + assertTrue(rejected.await(3, TimeUnit.SECONDS)); + fixture.timer.submit(() -> { }).get(3, TimeUnit.SECONDS); + assertEquals(1, overflow.size(), "known pre-dispatch failure must retain durable receipt ownership"); + assertTrue(fixture.accepted.isEmpty()); + verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + verify(fixture.user, never()).addPoints(); + var persisted = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + assertEquals(1, persisted.size()); + assertEquals(original.toString(), persisted.getFirst().get("LocalOccurrenceId")); + assertEquals(originalTime, ((Number) persisted.getFirst().get("Time")).longValue()); + + permitStorage.set(true); + fixture.received(); + assertEquals(0, overflow.size()); + assertEquals(1, fixture.accepted.size()); assertEquals(1, fixture.posted.size()); + assertEquals(original, fixture.accepted.getFirst().getLocalOccurrenceId()); + assertEquals(originalTime, fixture.accepted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(original, fixture.posted.getFirst().getVoteUUID()); + assertEquals(originalTime, fixture.posted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(1, fixture.progress().votes()); + verify(fixture.user).playerVote(fixture.site, true, false); + verify(fixture.user).addPoints(); + } + } + + @Test void admittedOverflowCallbackCannotRewardAfterStartupGenerationRetires() throws Exception { + try (var fixture = new Fixture(root, 0)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + fixture.timer.submit(() -> { + entered.countDown(); + try { assertTrue(release.await(3, TimeUnit.SECONDS)); } catch (InterruptedException failure) { throw new AssertionError(failure); } + }); + assertTrue(entered.await(3, TimeUnit.SECONDS)); + try { + fixture.open(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + // The constructor's delayed replay occupies one slot; overflow is another. + while (fixture.timer.getQueue().size() < 2 && System.nanoTime() < deadline) Thread.sleep(5); + assertTrue(fixture.timer.getQueue().size() >= 2); + fixture.capture().stop(); + var field = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed"); + field.setAccessible(true); field.setBoolean(fixture.plugin, true); + } finally { release.countDown(); } + fixture.drain(); + assertTrue(fixture.accepted.isEmpty()); assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size()); + verify(fixture.user, never()).addTotal(); verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + } + } + +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java new file mode 100644 index 0000000000..cb0aae1541 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java @@ -0,0 +1,559 @@ +package com.bencodez.votingplugin.backendproxy.messaging; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.Event; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.api.user.validation.UserValidationResult; +import com.bencodez.advancedcore.api.user.validation.ValidationSource; +import com.bencodez.advancedcore.api.user.validation.ValidationStatus; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; +import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync; +import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter.OrderedVoteOutcome; +import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; +import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; +import com.bencodez.votingplugin.events.PlayerPostVoteEvent; +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.listeners.PlayerVoteListener; +import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteLedger; +import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.votesites.VoteSite; + +/** Exercises the router, native event adapter, accepted listener and real date ledger together. */ +class BackendDateVoteAccountingRegressionTest { + private static final UUID PLAYER = UUID.fromString("e5baec32-9b2c-4fc8-9aed-0e0285e3c33d"); + private static final long OCCURRED_AT = Instant.parse("2026-10-15T00:00:00Z").toEpochMilli(); + private static final String SERVICE = "known.example"; + private static final int POINTS = 7; + + @TempDir Path root; + private final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + private final VotingPluginUser user = mock(VotingPluginUser.class); + private final VoteSite site = mock(VoteSite.class); + private final YamlConfiguration config = new YamlConfiguration(); + private final List accepted = new ArrayList<>(); + private final List posted = new ArrayList<>(); + private final AtomicInteger storedPoints = new AtomicInteger(37); + private final ProcessedVoteCache cache = spy(new ProcessedVoteCache()); + private BackendProxyMessageRouter router; + private boolean cancelNext; + private MockedStatic bukkit; + private MockedStatic advancedCore; + + @BeforeEach + void setUp() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getStorageType()).thenReturn(null); // Exercise ordinary synchronous point storage. + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(plugin.getBungeeSettings().getServer()).thenReturn("owner"); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.getOptions().getServer()).thenReturn("owner"); + advancedCore=mockStatic(com.bencodez.advancedcore.AdvancedCorePlugin.class); + advancedCore.when(com.bencodez.advancedcore.AdvancedCorePlugin::getInstance).thenReturn(plugin); + when(plugin.getConfigFile().getPointsOnVote()).thenReturn(POINTS); + when(plugin.getConfigFile().isAddTotals()).thenReturn(true); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.getUserManager().getProperName("Player")).thenReturn("Player"); + when(plugin.getUserManager().getValidationService().validate("Player", false)).thenReturn( + new UserValidationResult(ValidationStatus.VALID, "Player", ValidationSource.STORAGE, "fixture", false)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER, "Player")).thenReturn(user); + when(plugin.getVoteSiteManager().getVoteSite(SERVICE, true)).thenReturn(site); + when(site.isEnabled()).thenReturn(true); + when(site.getKey()).thenReturn("Example"); + when(site.getServiceSite()).thenReturn(SERVICE); + when(user.getPlayerName()).thenReturn("Player"); + when(user.getUUID()).thenReturn(PLAYER.toString()); + when(user.getJavaUUID()).thenReturn(PLAYER); + when(user.isOnline()).thenReturn(true); + when(user.getPoints()).thenAnswer(ignored -> storedPoints.get()); + doAnswer(call -> { storedPoints.set(call.getArgument(0)); return null; }) + .when(user).setPoints(anyInt(), eq(false)); + var field = VotingPluginUser.class.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(user, plugin); + doCallRealMethod().when(user).addPoints(anyInt()); + doCallRealMethod().when(user).addPoints(anyInt(), anyBoolean()); + doCallRealMethod().when(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(), + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong()); + + var dispatcher = plugin.getServer().getPluginManager(); + var listener = new PlayerVoteListener(plugin); + doAnswer(call -> { + Event event = call.getArgument(0); + if (event instanceof PlayerVoteEvent vote) { + accepted.add(vote); + if (cancelNext) { vote.setCancelled(true); cancelNext = false; } + listener.onplayerVote(vote); + } else if (event instanceof PlayerPostVoteEvent post) { + posted.add(post); + } + return null; + }).when(dispatcher).callEvent(any()); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(dispatcher); + router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), + mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); + definition("october"); + reloadDefinitions(); + } + + @AfterEach + void closeBukkit() { + if (bukkit != null) bukkit.close(); + if (advancedCore != null) advancedCore.close(); + } + + private void definition(String id) { + String prefix = "DateVoteMilestones." + id + "."; + config.set(prefix + "Enabled", true); + config.set(prefix + "Start", "2026-10-01T00:00:00"); + config.set(prefix + "End", "2026-11-01T00:00:00"); + config.set(prefix + "Timezone", "UTC"); + config.set(prefix + "AccountingServer", "owner"); + config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); + } + + private void reloadDefinitions() { + var milestones = new DateVoteMilestones(plugin); + milestones.reload(); + when(plugin.getDateVoteMilestones()).thenReturn(milestones); + } + + private DateVoteLedger.Progress progress(String id) throws Exception { + var event = DateVoteMilestones.parse(id, config.getConfigurationSection("DateVoteMilestones." + id)); + return new DateVoteLedger(root.resolve("date-vote-milestones")).progress(event, PLAYER); + } + + private JsonEnvelope vote(UUID id, boolean queued) { + var original = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, true, "", id, false, false, 1, 1, true); + var builder = JsonEnvelope.builder(original.getSubChannel()).schema(original.getSchema()); + original.getFields().forEach(builder::put); + return VotingPluginWire.requestVoteDeliveryAcknowledgement( + builder.put(VotingPluginWire.K_QUEUED_DELIVERY, queued).build()); + } + + @Test + void cancelledLiveDeliveryDoesNotCountOrCreditAndDoesNotBlockTheNextAcceptedOccurrence() throws Exception { + assertCancelledDeliveryRetiresWithoutEffects(false); + } + + @Test + void cancelledQueuedDeliveryDoesNotCountOrCreditAndDoesNotBlockTheNextAcceptedOccurrence() throws Exception { + assertCancelledDeliveryRetiresWithoutEffects(true); + } + + private void assertCancelledDeliveryRetiresWithoutEffects(boolean queued) throws Exception { + UUID cancelledId = UUID.randomUUID(); + cancelNext = true; + var outcome = new AtomicReference(); + var cancelled = VotingPluginWire.requestVoteDeliveryAcknowledgement(vote(cancelledId, queued)); + router.handleOrderedVote(cancelled, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + router.handleOrderedVote(cancelled, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(1, accepted.size(), "completed cancellation must be deduplicated"); + assertTrue(accepted.get(0).isCancelled()); + assertEquals(OCCURRED_AT, accepted.get(0).getCanonicalOccurrenceTime()); + assertEquals(0, progress("october").votes()); + assertEquals(37, storedPoints.get()); + assertTrue(posted.isEmpty()); + verify(user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + verify(user, never()).addTotal(); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + + UUID acceptedId = UUID.randomUUID(); + router.handleOrderedVote(VotingPluginWire.requestVoteDeliveryAcknowledgement(vote(acceptedId, queued)), outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(2, accepted.size()); + assertFalse(accepted.get(1).isCancelled()); + assertEquals(1, progress("october").votes()); + assertEquals(37 + POINTS, storedPoints.get()); + assertEquals(1, posted.size()); + verify(user, times(1)).playerVote(site, true, true); + verify(user, times(1)).addTotal(); + assertTrue(cache.hasCompletedEffects(cancelledId)); + assertTrue(cache.hasCompletedEffects(acceptedId)); + } + + @Test + void reliableForwardingSeparatesOriginalMembershipFromReceiverCooldownTime() throws Exception { + config.set("DateVoteMilestones.october.Start", "2026-10-15T00:00:00"); + config.set("DateVoteMilestones.october.End", "2026-10-16T00:00:00"); + reloadDefinitions(); + UUID id = UUID.randomUUID(); + JsonEnvelope forwarded = receiverEnvelope(id, OCCURRED_AT, null, true); + var wire = VotingPluginWire.readVote(forwarded); + assertEquals(OCCURRED_AT, wire.canonicalOccurrenceTime); + var outcome = new AtomicReference(); + router.handleOrderedVote(forwarded, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(1, progress("october").votes()); + assertEquals(java.util.Set.of(1), progress("october").submittedAwards()); + assertEquals(id, accepted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, accepted.get(0).getCanonicalOccurrenceTime()); + assertEquals(OCCURRED_AT, posted.get(0).getCanonicalOccurrenceTime()); + assertEquals(wire.time, posted.get(0).getVoteTime()); + assertNormalEffectsOnce(id, wire.time); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); + } + + @Test + void newForwarderPreservesIndependentOccurrenceTimeAcrossAnotherHop() throws Exception { + UUID id = UUID.randomUUID(); + var forwarded = receiverEnvelope(id, 1L, OCCURRED_AT, true); + var outcome = new AtomicReference(); + router.handleOrderedVote(forwarded, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(1, progress("october").votes()); + assertEquals(OCCURRED_AT, posted.get(0).getCanonicalOccurrenceTime()); + assertNormalEffectsOnce(id, VotingPluginWire.readVote(forwarded).time); + } + + @Test + void earlierOccurrenceAndOriginlessForwardingRemainOutsideCanonicalAccounting() throws Exception { + for (boolean originKnown : new boolean[] { true, false }) { + UUID id = UUID.randomUUID(); + var forwarded = receiverEnvelope(id, 1L, null, originKnown); + var outcome = new AtomicReference(); + router.handleOrderedVote(forwarded, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + } + assertEquals(0, progress("october").votes()); + assertEquals(2, posted.size()); + assertEquals(1L, posted.get(0).getCanonicalOccurrenceTime()); + assertEquals(-1L, posted.get(1).getCanonicalOccurrenceTime()); + verify(user, times(2)).playerVote(site, true, true); + verify(user, times(2)).addTotal(); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + + @Test + void malformedOccurrenceMetadataExcludesDateMembershipWithoutChangingNormalProcessing() throws Exception { + UUID id = UUID.randomUUID(); + var original = vote(id, false); + var forwarded = original.toBuilder().put(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME, + "9".repeat(100)).build(); + var outcome = new AtomicReference(); + router.handleOrderedVote(forwarded, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(0, progress("october").votes()); + assertEquals(-1L, accepted.get(0).getCanonicalOccurrenceTime()); + assertNormalEffectsOnce(id, OCCURRED_AT); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + + @Test + void oldBackendEnvelopeWithoutOccurrenceProvenanceKeepsNormalEffectsOnly() throws Exception { + UUID id = UUID.randomUUID(); + var source = vote(id, false); + var oldSender = JsonEnvelope.builder(source.getSubChannel()).schema(source.getSchema()); + source.getFields().forEach((key, value) -> { + if (!key.equals(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME)) oldSender.put(key, value); + }); + var outcome = new AtomicReference(); + router.handleOrderedVote(oldSender.build(), outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(0, progress("october").votes()); + assertEquals(id, accepted.get(0).getProxyVoteId()); + assertEquals(-1L, posted.get(0).getCanonicalOccurrenceTime()); + assertNormalEffectsOnce(id, OCCURRED_AT); + } + + @Test + void cachedReplayPreservesOccurrenceAndEstablishedProcessingTime() throws Exception { + UUID id = UUID.randomUUID(); + var cached = new com.bencodez.votingplugin.proxy.OfflineBungeeVote(id, "Player", PLAYER.toString(), + SERVICE, 1L, true, ""); + cached.setCanonicalOccurrenceTime(OCCURRED_AT); + cached.setWasOnline(true); + var envelope = cachedEnvelope(cached); + var outcome = new AtomicReference(); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(1, progress("october").votes()); + assertEquals(OCCURRED_AT, posted.get(0).getCanonicalOccurrenceTime()); + assertEquals(1L, posted.get(0).getVoteTime()); + assertNormalEffectsOnce(id, 1L); + } + + @Test + void legacyCacheReplayDoesNotInventOccurrenceFromProcessingTime() throws Exception { + UUID id = UUID.randomUUID(); + var cached = new com.bencodez.votingplugin.proxy.OfflineBungeeVote(id, "Player", PLAYER.toString(), + SERVICE, OCCURRED_AT, true, ""); + cached.setWasOnline(true); + var outcome = new AtomicReference(); + router.handleOrderedVote(cachedEnvelope(cached), outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(0, progress("october").votes()); + assertEquals(id, accepted.get(0).getProxyVoteId()); + assertEquals(-1L, posted.get(0).getCanonicalOccurrenceTime()); + assertNormalEffectsOnce(id, OCCURRED_AT); + } + + private JsonEnvelope cachedEnvelope(com.bencodez.votingplugin.proxy.OfflineBungeeVote cached) throws Exception { + var proxy = new com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl(); + var method = com.bencodez.votingplugin.proxy.VotingPluginProxy.class.getDeclaredMethod("cachedVoteEnvelope", + com.bencodez.votingplugin.proxy.OfflineBungeeVote.class, boolean.class, boolean.class, + int.class, int.class); + method.setAccessible(true); + return (JsonEnvelope) method.invoke(proxy, cached, false, false, 1, 1); + } + + private JsonEnvelope receiverEnvelope(UUID id, long senderTime, Long originalTime, boolean originKnown) throws Exception { + var proxy = spy(new com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl()); + proxy.setDataFolder(root.resolve(id.toString()).toFile()); + var votes = mock(com.bencodez.votingplugin.proxy.cache.VoteCacheHandler.class); + when(votes.markMultiProxyVoteCompletedDurably(any())).thenReturn(true); + when(votes.getTimeChangeQueue()).thenReturn(new java.util.concurrent.ConcurrentLinkedQueue<>()); + when(votes.addOnlineVoteDurably(anyString(), any())).thenReturn(true); + when(votes.addServerVoteDurably(anyString(), any())).thenReturn(true); + when(votes.updateOnlineVote(anyString(), any())).thenReturn(true); + when(votes.updateServerVote(anyString(), any())).thenReturn(true); + doReturn(votes).when(proxy).getVoteCacheHandler(); + doNothing().when(proxy).addVoteParty(); + proxy.setMethod(com.bencodez.votingplugin.proxy.BungeeMethod.PLUGINMESSAGING); + proxy.setGlobalMessageProxyHandlerForTest(new com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler() { + @Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { } + }); + proxy.loadMultiProxySupport(); + var handler = spy(proxy.getMultiProxyHandler()); + doNothing().when(handler).acknowledgeMultiProxyVote(any(), anyString()); + proxy.setMultiProxyHandler(handler); + when(proxy.getConfig().getMultiProxySupport()).thenReturn(true); + when(proxy.getConfig().getOnlineMode()).thenReturn(true); + when(proxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + var legacyField = com.bencodez.votingplugin.proxy.VotingPluginProxy.class.getDeclaredField("legacyVoteDeliveryServers"); + legacyField.setAccessible(true); + @SuppressWarnings("unchecked") var legacy = (java.util.Set) legacyField.get(proxy); + legacy.add("server1"); legacy.add("server2"); + var input = originKnown + ? VotingPluginWire.multiProxyVote("Player", PLAYER.toString(), SERVICE, senderTime, + true, true, "", id, false, false, 1, 1, "Primary", true) + : VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, senderTime, + true, true, "", id, false, false, 1, 1); + if (originalTime != null) input = input.toBuilder().put(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME, originalTime).build(); + else { + // Exercise the actual pre-metadata sender shape, including reliable origin. + var legacyBuilder = JsonEnvelope.builder(input.getSubChannel()).schema(input.getSchema()); + input.getFields().forEach((key, value) -> { + if (!key.equals(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME)) legacyBuilder.put(key, value); + }); + input = legacyBuilder.build(); + } + var handle = com.bencodez.votingplugin.proxy.multiproxy.MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handle.setAccessible(true); + long before = System.currentTimeMillis(); + handle.invoke(handler, input); + long after = System.currentTimeMillis(); + var emitted = proxy.getLastVoteEnvelope(); + assertNotNull(emitted); + var wire = VotingPluginWire.readVote(emitted); + assertTrue(wire.time >= before && wire.time <= after); + assertEquals(PLAYER.toString(), wire.uuid); + if (originKnown) { + assertEquals(id, wire.voteId); + assertTrue(wire.delayValidated); assertTrue(wire.delayValidationKnown); + } + return emitted; + } + + @Test + void liveAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception { + assertAccountingFailureFinishesNormalEffects(false); + } + + @Test + void queuedAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception { + assertAccountingFailureFinishesNormalEffects(true); + } + + private void assertAccountingFailureFinishesNormalEffects(boolean queued) throws Exception { + definition("sibling"); + reloadDefinitions(); + var event = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + Path corrupt = root.resolve("date-vote-milestones").resolve(event.fileId() + "-" + PLAYER + ".properties"); + Files.createDirectories(corrupt.getParent()); + Files.writeString(corrupt, "corrupt fixture record"); + // A corrupt player record needs its existing definition seal, just as an installed ledger would have. + var definitions = new java.util.Properties(); + definitions.setProperty("october", event.fingerprint()); + try (var writer = Files.newBufferedWriter(corrupt.getParent().resolve("definitions.properties"))) { + definitions.store(writer, "fixture"); + } + UUID id = UUID.randomUUID(); + JsonEnvelope envelope = vote(id, queued); + var outcomes = new ArrayList(); + assertThrows(VotingPluginUser.DateMilestoneAccountingException.class, + () -> router.handleOrderedVote(envelope, outcome -> { + // The lane may quarantine only after the remaining normal mutation and observation. + assertEquals(44, storedPoints.get()); + verify(plugin.getServerData()).addServiceSite(SERVICE); + outcomes.add(outcome); + })); + assertEquals(List.of(OrderedVoteOutcome.QUARANTINE), outcomes); + assertTrue(accepted.get(0).isDateMilestoneAccountingFailed()); + assertEquals(id, accepted.get(0).getProxyVoteId()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals("corrupt fixture record", Files.readString(corrupt)); + assertEquals(1, progress("sibling").votes()); + assertEquals(java.util.Set.of(1), progress("sibling").submittedAwards()); + + router.handleOrderedVote(envelope, outcomes::add); + assertEquals(List.of(OrderedVoteOutcome.QUARANTINE, OrderedVoteOutcome.QUARANTINE), outcomes); + assertEquals(44, storedPoints.get()); + assertEquals(1, accepted.size()); + assertEquals(1, posted.size()); + assertFalse(cache.hasCompletedEffects(id)); + verify(cache, never()).complete(id); + assertNormalEffectsOnce(id, OCCURRED_AT); + verify(user).addPoints(POINTS); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); + } + + private void assertNormalEffectsOnce(UUID id, long time) { + verify(user).playerVote(site, true, true); + verify(user).addTotal(); + verify(user).addTotalDaily(); + verify(user).addTotalWeekly(); + verify(user).addPoints(); + verify(user).setTime(site, time); + verify(plugin.getVoteParty()).vote(user, true, true, true); + verify(plugin.getVoteMilestonesManager()).handleVote(eq(user), any(), eq(true), eq(id), any()); + verify(plugin.getCoolDownCheck()).vote(user, site); + verify(plugin.getVoteStreakHandler()).processVote(user, time, id); + verify(plugin).setUpdate(true); + } + + @Test + void missingWireIdKeepsLegacyTotalsCorrelationAndDedupWithoutDateAccounting() throws Exception { + UUID correlation = UUID.randomUUID(); + // Actual legacy storage layout, including the old ignored milestone-count column. + String totals = "1//2//3//4//5//0//6//7//8//" + correlation; + JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).schema(VotingPluginWire.SCHEMA_VERSION) + .put(VotingPluginWire.K_PLAYER, "Player").put(VotingPluginWire.K_UUID, PLAYER) + .put(VotingPluginWire.K_SERVICE, SERVICE).put(VotingPluginWire.K_TIME, OCCURRED_AT) + .put(VotingPluginWire.K_WAS_ONLINE, true).put(VotingPluginWire.K_REAL_VOTE, true) + .put(VotingPluginWire.K_TOTALS, totals).build(); + assertFalse(envelope.getFields().containsKey(VotingPluginWire.K_VOTE_ID)); + assertLegacyCorrelation(envelope, correlation); + } + + @Test + void emptyWireIdKeepsVersionedTotalsCorrelationWithoutDateAccounting() throws Exception { + UUID correlation = UUID.randomUUID(); + JsonEnvelope envelope = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, true, "v2//1//2//3//4//5//6//7//8//" + correlation, null, false, false, 1, 1); + assertEquals("", envelope.getFields().get(VotingPluginWire.K_VOTE_ID)); + assertLegacyCorrelation(envelope, correlation); + } + + private void assertLegacyCorrelation(JsonEnvelope envelope, UUID correlation) throws Exception { + var outcome = new AtomicReference(); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(1, accepted.size()); + assertEquals(1, posted.size()); + assertNull(accepted.get(0).getProxyVoteId()); + assertNull(posted.get(0).getProxyVoteId()); + assertEquals(correlation, posted.get(0).getVoteUUID()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(0, progress("october").votes()); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + assertEquals(44, storedPoints.get()); + assertNormalEffectsOnce(correlation, OCCURRED_AT); + verify(cache, times(2)).reserveWithOutcome(correlation); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + + @Test + void targetedWireIdentityRemainsExplicitButDoesNotEnterTheOwnerLedger() throws Exception { + UUID id = UUID.randomUUID(); + router.handleOrderedVote(VotingPluginWire.voteOnline("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, true, "", id, false, false, 1, 1), ignored -> { }); + assertTrue(accepted.get(0).isTargetedProxyVote()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(0, progress("october").votes()); + assertNormalEffectsOnce(id, OCCURRED_AT); + } + + @Test + void canonicalTestFlagRemainsExcludedAcrossRouterAndAcceptedListener() throws Exception { + UUID id = UUID.randomUUID(); + router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, false, "", id, false, false, 1, 1), ignored -> { }); + assertFalse(accepted.get(0).isRealVote()); + assertFalse(posted.get(0).isRealVote()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(0, progress("october").votes()); + verify(user).playerVote(site, true, true); + verify(user, never()).addTotal(); + verify(user).addPoints(POINTS); // Preserve the existing router's per-server points policy. + } + + @Test + void missingOriginalTimeIsNotMadeCanonicalByNormalPostVoteTimestampFallback() throws Exception { + UUID id = UUID.randomUUID(); + long before = System.currentTimeMillis(); + router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, 0, + true, true, "", id, false, false, 1, 1), ignored -> { }); + assertEquals(0, accepted.get(0).getTime()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertTrue(posted.get(0).getVoteTime() >= before); + assertTrue(posted.get(0).getVoteTime() <= System.currentTimeMillis()); + assertEquals(0, progress("october").votes()); + verify(user).setTime(site); + } + + @Test + void explicitWireIdOverridesTotalsCorrelationThroughDateAccountingAndPostVote() throws Exception { + UUID wireId = UUID.randomUUID(), correlation = UUID.randomUUID(); + JsonEnvelope envelope = VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote( + "Player", PLAYER.toString(), SERVICE, OCCURRED_AT, true, true, + "v2//1//2//3//4//5//6//7//8//" + correlation, wireId, false, false, 1, 1)); + var outcome = new AtomicReference(); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(1, accepted.size()); + assertEquals(1, posted.size()); + assertEquals(wireId, accepted.get(0).getProxyVoteId()); + assertEquals(wireId, posted.get(0).getProxyVoteId()); + assertEquals(wireId, posted.get(0).getVoteUUID()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(1, progress("october").votes()); + assertNormalEffectsOnce(wireId, OCCURRED_AT); + verify(cache, times(2)).reserveWithOutcome(wireId); + verify(cache, never()).reserveWithOutcome(correlation); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java index 1d8c962ff2..ffc26c41f1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java @@ -838,6 +838,38 @@ void voteUpdateFailureAfterOfflineEffectRequestsQuarantine() { verify(user, never()).setTime(any(), anyLong()); } + @Test + void actualAccountingDispatchFailureQuarantinesOriginalIdAndNeverReplaysNormalProcessing() throws Exception { + UUID voteId = UUID.randomUUID(); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveWithOutcome(voteId)).thenReturn(Reservation.RESERVED, Reservation.DUPLICATE); + var dispatchPlugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(dispatchPlugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + var dispatchUser = mock(VotingPluginUser.class, org.mockito.Mockito.CALLS_REAL_METHODS); + var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(dispatchUser, dispatchPlugin); + org.mockito.Mockito.doReturn("Player").when(dispatchUser).getPlayerName(); + var dispatcher = dispatchPlugin.getServer().getPluginManager(); + org.mockito.Mockito.doAnswer(call -> { ((com.bencodez.votingplugin.events.PlayerVoteEvent) call.getArgument(0)).setDateMilestoneAccountingFailed(true); return null; }) + .when(dispatcher).callEvent(any()); + when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class)); + when(plugin.getServerData()).thenReturn(mock(ServerData.class)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")).thenReturn(user); + org.mockito.Mockito.doAnswer(call -> { + return dispatchUser.bungeeVotePluginMessagingEvent(call.getArgument(0), call.getArgument(1), call.getArgument(2), + call.getArgument(3), call.getArgument(4), call.getArgument(5), call.getArgument(6), call.getArgument(7), + call.getArgument(8), call.getArgument(9), call.getArgument(10), call.getArgument(11), call.getArgument(12), call.getArgument(13)); + }).when(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), + anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong()); + var router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); + var envelope = VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example", LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1)); + AtomicReference outcome = new AtomicReference<>(); + assertThrows(IllegalStateException.class, () -> router.handleOrderedVote(envelope, outcome::set)); + assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); outcome.set(null); + router.handleOrderedVote(envelope, outcome::set); assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); + verify(cache, never()).complete(voteId); verify(dispatchPlugin.getServer().getPluginManager(), times(1)).callEvent(any()); + verify(plugin.getServerData(), times(1)).addServiceSite("known.example"); + } + @Test void proxyCancellationSuppressesPerServerPointsAndCompletesWithoutReplayingRejectedVotes() throws Exception { for (boolean cancelled : new boolean[] {false, true}) { @@ -855,7 +887,7 @@ void proxyCancellationSuppressesPerServerPointsAndCompletesWithoutReplayingRejec 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(), anyLong()); var server = mock(org.bukkit.Server.class); var manager = mock(org.bukkit.plugin.PluginManager.class); when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(manager); @@ -895,7 +927,7 @@ void voteRewardFailureRequestsQuarantineAfterVoteIdReservation() { .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(), anyBoolean(), anyLong()); BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); @@ -941,7 +973,7 @@ void reliableVoteRetriesReceiptPersistenceWithoutRepeatingEffectsInProcess() { 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), anyBoolean(), anyLong()); } @Test @@ -962,7 +994,24 @@ void stableVoteIdAloneDoesNotBypassBackendDelayValidation() { 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), anyBoolean(), anyLong()); + } + + @Test + void canonicalTestVoteFlagIsPassedToTheBackendEventAdapter() { + UUID voteId = UUID.randomUUID(); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveWithOutcome(voteId)).thenReturn(Reservation.RESERVED); + when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")).thenReturn(user); + when(plugin.getServerData()).thenReturn(mock(ServerData.class)); + BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, + mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), + mock(BackendVotePartySync.class), cache); + voteRouter.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example", + LAST_VOTE_TIME, true, false, "", voteId, false, false, 1, 1), ignored -> { }); + verify(user).bungeeVotePluginMessagingEvent(any(), anyLong(), any(), anyBoolean(), anyBoolean(), + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(false), eq(false), anyLong()); } @Test @@ -982,7 +1031,7 @@ void voteOnlineMarksTheDeliveryAsTargetedToThisBackend() { 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), anyBoolean(), anyLong()); } @Test @@ -1004,7 +1053,7 @@ void malformedReliableVoteIsQuarantinedWithoutCompletionReceipt() { 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(), anyBoolean(), anyLong()); verify(cache).cancelReservation(voteId); } @@ -1024,7 +1073,7 @@ void saturatedReliableVoteRequestsRetryWithoutRunningEffects() { 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(), anyBoolean(), anyLong()); verify(cache, never()).complete(voteId); } @@ -1247,7 +1296,7 @@ void explicitQueuedDeliveryIsPassedSeparatelyFromDelayValidation() { 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), anyBoolean(), anyLong()); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java index 3907354b6d..cd75fac103 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java @@ -262,7 +262,9 @@ private Fixture(Path receipts) throws Exception { 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(), anyLong()); + when(plugin.getDateVoteMilestones()).thenReturn( + new com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones(plugin)); PlayerVoteListener listener = new PlayerVoteListener(plugin); var eventManager = plugin.getServer().getPluginManager(); doAnswer(call -> { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/DateVoteEventsMenuTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/DateVoteEventsMenuTest.java new file mode 100644 index 0000000000..0591530213 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/DateVoteEventsMenuTest.java @@ -0,0 +1,309 @@ +package com.bencodez.votingplugin.commands.gui.player; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; + +import com.bencodez.advancedcore.api.inventory.BInventory; +import com.bencodez.advancedcore.api.inventory.BInventoryButton; +import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Availability; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Event; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Milestone; + +class DateVoteEventsMenuTest { + private static final long NOW = 1_800_000_000_000L; + + @Test + void localProgressAndLiteralTextComeFromSnapshotAndNavigationPreservesListPage() { + try (Boundary boundary = new Boundary()) { + String literal = "&a%player% {javascript:throw new Error('must not run')}"; + Event event = event("literal", literal, true, NOW - 1000, NOW + 1000, Availability.OWNED, + 7, Set.of(1), Set.of(2), Set.of(3), List.of( + milestone(1, true), milestone(2, true), milestone(3, true), + milestone(5, true), milestone(10, false), milestone(20, true))); + List events = new ArrayList<>(IntStream.range(0, 45) + .mapToObj(i -> simpleEvent("event" + i, Availability.OWNED)).toList()); + events.add(event); + boundary.open(events); + boundary.click(0, 53); + assertEquals(literal, boundary.name(1, 0)); + assertTrue(boundary.lore(1, 0).contains("Votes: 7")); + assertTrue(boundary.lore(1, 0).contains("Next milestone: 20 votes")); + assertTrue(boundary.lore(1, 0).contains("Timezone: America/Regina")); + assertTrue(boundary.lore(1, 0).contains("Status: active")); + boundary.click(1, 0); + assertEquals(literal, boundary.name(2, 48)); + assertTrue(boundary.lore(2, 0).contains("Award state: submitted")); + assertTrue(boundary.lore(2, 1).contains("Award state: deferred")); + assertTrue(boundary.lore(2, 2).contains("Award state: pending review")); + assertTrue(boundary.lore(2, 3).contains("Award state: reached")); + assertTrue(boundary.lore(2, 4).contains("Award state: not reached")); + assertTrue(boundary.lore(2, 4).contains("Enabled: false")); + assertTrue(boundary.lore(2, 5).contains("Reward types: Commands, Items")); + boundary.click(2, 49); + assertEquals(literal, boundary.name(3, 0)); + boundary.assertOnlyPresentationAccess(); + } + } + + @Test + void all64EventsAndMilestonesAreReachableAcrossTwoBoundedPages() { + try (Boundary boundary = new Boundary()) { + List milestones = IntStream.rangeClosed(1, 64).mapToObj(i -> milestone(i, true)).toList(); + List events = IntStream.range(0, 64).mapToObj(i -> event("e" + i, "Event " + i, true, + NOW - 1000, NOW + 1000, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), milestones)).toList(); + boundary.open(events); + assertEquals(47, boundary.buttons.get(0).size()); + assertFalse(boundary.buttons.get(0).containsKey(45)); + boundary.click(0, 53); + assertEquals("Event 45", boundary.name(1, 0)); + assertEquals("Event 63", boundary.name(1, 18)); + assertFalse(boundary.buttons.get(1).containsKey(19)); + assertFalse(boundary.buttons.get(1).containsKey(53)); + boundary.click(1, 18); + assertEquals("1 votes", boundary.name(2, 0)); + assertEquals("45 votes", boundary.name(2, 44)); + boundary.click(2, 53); + assertEquals("46 votes", boundary.name(3, 0)); + assertEquals("64 votes", boundary.name(3, 18)); + assertFalse(boundary.buttons.get(3).containsKey(19)); + assertFalse(boundary.buttons.get(3).containsKey(53)); + boundary.click(3, 45); + assertEquals("1 votes", boundary.name(4, 0)); + boundary.click(4, 49); + assertEquals("Event 45", boundary.name(5, 0)); + boundary.click(5, 45); + assertEquals("Event 0", boundary.name(6, 0)); + boundary.inventories.constructed().forEach(inventory -> { + verify(inventory).runClicksSync(); + verify(inventory).setMaxInvSize(54); + verify(inventory).setPages(false); + verify(inventory).openInventory(boundary.player); + }); + boundary.assertOnlyPresentationAccess(); + } + } + + @Test + void remoteOwnerAndUnavailableErrorsNeverFabricateZeroProgress() { + try (Boundary boundary = new Boundary()) { + boundary.open(List.of(simpleEvent("remote", Availability.REMOTE), + simpleEvent("failed", Availability.UNAVAILABLE))); + assertTrue(boundary.lore(0, 0).contains("Progress owned by: Exact-Owner")); + assertTrue(boundary.lore(0, 1).contains("Progress unavailable: accounting read failed.")); + for (int slot = 0; slot < 2; slot++) { + assertTrue(boundary.lore(0, slot).stream().noneMatch(line -> line.startsWith("Votes:") + || line.startsWith("Next milestone:"))); + } + boundary.click(0, 0); + assertTrue(boundary.lore(1, 0).contains("Award state: progress unavailable")); + boundary.click(1, 49); + boundary.click(2, 1); + assertTrue(boundary.lore(3, 0).contains("Award state: progress unavailable")); + boundary.assertOnlyPresentationAccess(); + } + } + + @Test + void statusUsesCapturedTimeWithInclusiveStartAndExclusiveEnd() { + try (Boundary boundary = new Boundary()) { + boundary.open(List.of( + event("future", "Future", true, NOW + 1, NOW + 100, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, true))), + event("start", "Start", true, NOW, NOW + 100, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, true))), + event("end", "End", true, NOW - 100, NOW, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, true))), + event("off", "Off", false, NOW - 100, NOW + 100, Availability.OWNED, 0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, false))))); + String[] statuses = {"upcoming", "active", "ended", "disabled"}; + for (int i = 0; i < statuses.length; i++) + assertTrue(boundary.lore(0, i).contains("Status: " + statuses[i])); + } + } + + @Test + void emptyAndAllDisabledStatesAreExplicit() { + try (Boundary boundary = new Boundary()) { + boundary.open(List.of()); + assertTrue(boundary.lore(0, 49).contains("No date vote events configured.")); + assertFalse(boundary.buttons.get(0).containsKey(0)); + Event disabled = event("off", "Disabled", false, NOW - 1, NOW + 1, Availability.OWNED, + 0, Set.of(), Set.of(), Set.of(), List.of(milestone(1, false))); + boundary.open(List.of(disabled)); + assertTrue(boundary.lore(1, 49).contains("No enabled date vote events.")); + boundary.click(1, 0); + assertTrue(boundary.lore(2, 0).contains("Enabled: false")); + } + } + + @Test + void previewContainsOnlyTypesAndMilestoneClicksHaveNoRewardEffects() { + try (Boundary boundary = new Boundary()) { + Event event = event("preview", "Preview", true, NOW - 1, NOW + 1, Availability.OWNED, + 1, Set.of(), Set.of(), Set.of(), List.of(new Milestone(1, "one", true, + List.of("Commands", "say PRIVATE_SECRET", "{javascript:bad}", "Items")))); + boundary.open(List.of(event)); + boundary.click(0, 0); + assertTrue(boundary.lore(1, 0).contains("Reward types: Commands, Other, Items")); + assertFalse(String.join(" ", boundary.lore(1, 0)).contains("PRIVATE_SECRET")); + boundary.click(1, 0); + assertEquals(2, boundary.inventories.constructed().size()); + boundary.assertOnlyPresentationAccess(); + } + } + + @Test + void staleOfflineDisabledAndRevokedPermissionsBlockInitialAndLaterNavigation() { + for (int failure = 0; failure < 4; failure++) { + try (Boundary boundary = new Boundary()) { + boundary.open(List.of(simpleEvent("event", Availability.OWNED))); + switch (failure) { + case 0 -> boundary.current.set(false); + case 1 -> when(boundary.player.isOnline()).thenReturn(false); + case 2 -> when(boundary.plugin.isEnabled()).thenReturn(false); + case 3 -> when(boundary.player.hasPermission(anyString())).thenReturn(false); + default -> fail(); + } + boundary.click(0, 0); + boundary.open(List.of(simpleEvent("other", Availability.OWNED))); + assertEquals(1, boundary.inventories.constructed().size()); + } + } + } + + @Test + void backAndBothPaginationDirectionsRecheckEligibility() { + for (int control : new int[] {45, 49, 53}) { + try (Boundary boundary = new Boundary()) { + List milestones = IntStream.rangeClosed(1, 64).mapToObj(i -> milestone(i, true)).toList(); + boundary.open(List.of(event("event", "Event", true, NOW - 1, NOW + 1, Availability.OWNED, + 0, Set.of(), Set.of(), Set.of(), milestones))); + boundary.click(0, 0); + int screen = 1; + if (control == 45) { boundary.click(1, 53); screen = 2; } + boundary.current.set(false); + boundary.click(screen, control); + assertEquals(screen + 1, boundary.inventories.constructed().size()); + } + } + } + + @Test + void playerPermissionIsAcceptedAndSnapshotIsRecheckedImmediatelyBeforeOpen() { + try (Boundary boundary = new Boundary()) { + when(boundary.player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")).thenReturn(false); + when(boundary.player.hasPermission("VotingPlugin.Player")).thenReturn(true); + boundary.open(List.of()); + verify(boundary.inventories.constructed().get(0)).openInventory(boundary.player); + AtomicInteger checks = new AtomicInteger(); + DateVoteEventsMenu.open(boundary.plugin, boundary.player, new DateVoteProgressSnapshot(NOW, List.of()), + () -> checks.incrementAndGet() == 1); + verify(boundary.inventories.constructed().get(1), never()).openInventory(any(Player.class)); + } + } + + private static Milestone milestone(int threshold, boolean enabled) { + return new Milestone(threshold, "m" + threshold, enabled, List.of("Commands", "Items")); + } + + private static Event simpleEvent(String id, Availability availability) { + return event(id, id, true, NOW - 1000, NOW + 1000, availability, 0, + Set.of(), Set.of(), Set.of(), List.of(milestone(1, true))); + } + + private static Event event(String id, String name, boolean enabled, long start, long end, + Availability availability, int votes, Set submitted, Set deferred, + Set reserved, List milestones) { + DateVoteEvent definition = new DateVoteEvent(id, name, enabled, start, end, "America/Regina", + milestones.stream().map(Milestone::threshold).toList(), Set.of(), "Exact-Owner"); + return new Event(definition, availability, votes, submitted, deferred, reserved, milestones); + } + + /** Mock only platform/item rendering and inventory opening; retain real buttons and callbacks. */ + private static final class Boundary implements AutoCloseable { + final VotingPluginMain plugin = mock(VotingPluginMain.class); + final Player player = mock(Player.class); + final AtomicBoolean current = new AtomicBoolean(true); + final List> buttons = new ArrayList<>(); + final MockedConstruction inventories; + final MockedConstruction builders; + + Boundary() { + when(plugin.isEnabled()).thenReturn(true); + when(player.isOnline()).thenReturn(true); + when(player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")).thenReturn(true); + inventories = mockConstruction(BInventory.class, (inventory, context) -> { + Map entries = new HashMap<>(); + buttons.add(entries); + doAnswer(call -> { entries.put(call.getArgument(0), call.getArgument(1)); return null; }) + .when(inventory).addButton(anyInt(), any(BInventoryButton.class)); + }); + builders = mockConstruction(ItemBuilder.class, (builder, context) -> { + assertEquals(1, context.arguments().size()); + assertInstanceOf(Material.class, context.arguments().get(0)); + ItemStack stack = mock(ItemStack.class); + ItemMeta meta = mock(ItemMeta.class); + Map rendered = new HashMap<>(); + when(stack.getItemMeta()).thenReturn(meta); + when(builder.toItemStack()).thenReturn(stack); + doAnswer(call -> { rendered.put("name", call.getArgument(0)); return null; }) + .when(meta).setDisplayName(anyString()); + doAnswer(call -> { rendered.put("lore", call.getArgument(0)); return null; }) + .when(meta).setLore(anyList()); + when(meta.getDisplayName()).thenAnswer(call -> rendered.get("name")); + when(meta.getLore()).thenAnswer(call -> rendered.get("lore")); + }); + } + + void open(List events) { + DateVoteEventsMenu.open(plugin, player, new DateVoteProgressSnapshot(NOW, events), current::get); + } + + void click(int screen, int slot) { + BInventoryButton button = buttons.get(screen).get(slot); + assertNotNull(button, "Expected navigation button in slot " + slot); + button.onClick(null); + } + + String name(int screen, int slot) { + return buttons.get(screen).get(slot).getItem(player, new HashMap<>()).getItemMeta().getDisplayName(); + } + + List lore(int screen, int slot) { + return buttons.get(screen).get(slot).getItem(player).getItemMeta().getLore(); + } + + void assertOnlyPresentationAccess() { + assertTrue(mockingDetails(plugin).getInvocations().stream() + .allMatch(call -> call.getMethod().getName().equals("isEnabled")), + "Presentation must not access users, storage, rewards, configuration or schedulers"); + builders.constructed().forEach(builder -> { + verify(builder, never()).setName(anyString()); + verify(builder, never()).setLore(anyList()); + verify(builder, never()).addPlaceholder(anyString(), anyString()); + }); + } + + @Override public void close() { + builders.close(); + inventories.close(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/DateVoteMenuOpenBoundaryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/DateVoteMenuOpenBoundaryTest.java new file mode 100644 index 0000000000..a750023bf7 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/DateVoteMenuOpenBoundaryTest.java @@ -0,0 +1,154 @@ +package com.bencodez.votingplugin.commands.gui.player; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.inventory.BInventory; +import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot; +import com.bencodez.votingplugin.listeners.DateVoteMenuOpenGuard; + +/** Real AdvancedCore inventory construction and deferred open; Bukkit is the external boundary. */ +class DateVoteMenuOpenBoundaryTest { + @Test + void permissionRevokedAfterAdvancedCoreQueuesOpenCancelsFinalPlatformOpen() { + try (Boundary boundary = new Boundary()) { + boundary.open(); + assertEquals(1, boundary.scheduled.size()); + when(boundary.player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")).thenReturn(false); + boundary.finish(false); + } + } + + @Test + void reloadBetweenInventoryConstructionAndFinalOpenCancelsStaleSnapshot() { + try (Boundary boundary = new Boundary()) { + boundary.open(); + boundary.current.set(false); + boundary.finish(false); + } + } + + @Test + void pluginShutdownAndPlayerDisconnectEachCancelTheFinalOpen() { + try (Boundary boundary = new Boundary()) { + boundary.open(); + when(boundary.plugin.isEnabled()).thenReturn(false); + boundary.finish(false); + } + try (Boundary boundary = new Boundary()) { + boundary.open(); + when(boundary.player.isOnline()).thenReturn(false); + boundary.finish(false); + } + } + + @Test + void currentSnapshotOpensAndUnrelatedInventoriesRemainAllowed() { + try (Boundary boundary = new Boundary()) { + boundary.open(); + boundary.finish(true); + BInventory unrelated = new BInventory("Another menu"); + assertTrue(DateVoteEventsMenu.permitsOpen(unrelated, boundary.player)); + InventoryView view = mock(InventoryView.class); + Inventory ordinary = mock(Inventory.class); + when(view.getTopInventory()).thenReturn(ordinary); + when(view.getPlayer()).thenReturn(boundary.player); + InventoryOpenEvent event = new InventoryOpenEvent(view); + new DateVoteMenuOpenGuard().onOpen(event); + assertFalse(event.isCancelled()); + } + } + + private static final class Boundary implements AutoCloseable { + final VotingPluginMain plugin = mock(VotingPluginMain.class); + final Player player = mock(Player.class); + final AtomicBoolean current = new AtomicBoolean(true); + final AtomicInteger visible = new AtomicInteger(); + final ArrayDeque scheduled = new ArrayDeque<>(); + final MockedConstruction builders; + final MockedStatic bukkit; + final MockedStatic core; + InventoryOpenEvent finalEvent; + + Boundary() { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getOptions()).thenReturn(mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class)); + when(player.isOnline()).thenReturn(true); + when(player.getUniqueId()).thenReturn(new UUID(1, 1)); + when(player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")).thenReturn(true); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { scheduled.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + core = mockStatic(AdvancedCorePlugin.class); + core.when(AdvancedCorePlugin::getInstance).thenReturn(plugin); + builders = mockConstruction(ItemBuilder.class, withSettings().defaultAnswer(RETURNS_SELF), + (builder, context) -> { + ItemStack stack = mock(ItemStack.class); + when(stack.getItemMeta()).thenReturn(mock(ItemMeta.class)); + when(builder.toItemStack()).thenReturn(stack); + }); + bukkit = mockStatic(Bukkit.class); + bukkit.when(() -> Bukkit.createInventory(any(InventoryHolder.class), anyInt(), anyString())) + .thenAnswer(call -> { + Inventory inventory = mock(Inventory.class); + when(inventory.getHolder()).thenReturn(call.getArgument(0)); + return inventory; + }); + when(player.openInventory(any(Inventory.class))).thenAnswer(call -> { + InventoryView view = mock(InventoryView.class); + when(view.getTopInventory()).thenReturn(call.getArgument(0)); + when(view.getPlayer()).thenReturn(player); + finalEvent = new InventoryOpenEvent(view); + new DateVoteMenuOpenGuard().onOpen(finalEvent); + if (!finalEvent.isCancelled()) visible.incrementAndGet(); + return view; + }); + } + + void open() { + DateVoteEventsMenu.open(plugin, player, new DateVoteProgressSnapshot(1800000000000L, List.of()), + current::get); + assertEquals(0, visible.get()); + verify(player, never()).openInventory(any(Inventory.class)); + } + + void finish(boolean allowed) { + assertEquals(1, scheduled.size()); + scheduled.remove().run(); + assertNotNull(finalEvent); + assertEquals(!allowed, finalEvent.isCancelled()); + assertEquals(allowed ? 1 : 0, visible.get()); + assertTrue(scheduled.isEmpty()); + } + + @Override public void close() { + bukkit.close(); + builders.close(); + core.close(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEventTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEventTest.java new file mode 100644 index 0000000000..94cb7823ff --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEventTest.java @@ -0,0 +1,41 @@ +package com.bencodez.votingplugin.core.datemilestones; +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +class DateVoteEventTest { + private DateVoteEvent event(String name, long end) { + return new DateVoteEvent("october", name, true, 100, end, "UTC", List.of(25, 10), Set.of("a"), "owner"); + } + @Test void boundariesFiltersAndNetworkOwnerAreExplicit() { + var e = event("October", 200); + assertFalse(e.matches(99, "a", true, false, "")); + assertTrue(e.matches(100, "a", true, false, "")); + assertTrue(e.matches(199, "a", true, true, "owner")); + assertFalse(e.matches(200, "a", true, true, "owner")); + assertFalse(e.matches(100, "b", true, false, "")); + assertFalse(e.matches(100, "a", false, false, "")); + assertFalse(e.matches(100, "a", true, true, "other")); + } + @Test void displayRenamePreservesIdentityButWindowChangeDoesNot() { + assertEquals(event("October", 200).fingerprint(), event("Renamed", 200).fingerprint()); + assertNotEquals(event("October", 200).fingerprint(), event("October", 201).fingerprint()); + } + @Test void timezoneAndDstBoundariesRejectAmbiguity() { + assertEquals(3600000, DateVoteEvent.timestamp("2026-10-01T00:00:00", "UTC") + - DateVoteEvent.timestamp("2026-10-01T00:00:00", "Europe/London")); + assertThrows(IllegalArgumentException.class, () -> DateVoteEvent.timestamp("2026-11-01T01:30:00", "America/New_York")); + assertThrows(IllegalArgumentException.class, () -> DateVoteEvent.timestamp("2026-03-08T02:30:00", "America/New_York")); + assertThrows(RuntimeException.class, () -> DateVoteEvent.timestamp("not-a-date", "UTC")); + } + @Test void siteFilterFingerprintUsesUnambiguousEncoding() { + var first = new DateVoteEvent("a", "A", true, 100, 200, "UTC", List.of(1), Set.of("a", "b"), ""); + var second = new DateVoteEvent("a", "A", true, 100, 200, "UTC", List.of(1), Set.of("a, b"), ""); + assertNotEquals(first.fingerprint(), second.fingerprint()); + } + @Test void invalidIdentityWindowAndThresholdsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new DateVoteEvent("../x", "x", true, 100, 200, "UTC", List.of(1), Set.of(), "")); + assertThrows(IllegalArgumentException.class, () -> new DateVoteEvent("x", "x", true, 100, 100, "UTC", List.of(1), Set.of(), "")); + assertThrows(IllegalArgumentException.class, () -> new DateVoteEvent("x", "x", true, 100, 200, "UTC", List.of(1,1), Set.of(), "")); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index 8baf0730e7..a720f7be84 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -80,6 +80,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).checkDayVoteStreak(user, false); order.verify(ops).setMonthTotal(user, 2); order.verify(ops).milestones(eq(user), any(UUID.class), eq(false)); + order.verify(ops).dateMilestones(eq(user), eq(site), eq(123L), any(UUID.class)); order.verify(ops).cooldown(user, site); order.verify(ops).voteStreak(eq(user), eq(123L), any(UUID.class)); order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(123L), any(UUID.class), eq(false)); @@ -87,6 +88,29 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).setUpdate(); } + @Test + void standaloneDateMembershipUsesActualInstantDuringDstRollback() { + var ops = accepted(); + long instant = java.time.Instant.parse("2026-11-01T06:15:00Z").toEpochMilli(); + when(ops.currentTimeMillis()).thenReturn(instant); + SharedVoteProcessor.process(ops); + verify(ops).dateMilestones(eq(user), eq(site), eq(instant), any(UUID.class)); + } + + @Test + void localOccurrenceIdentityKeepsOrdinaryProcessingClockAndDoesNotTriggerProxyBehavior() { + var ops = accepted(); + UUID localId = UUID.randomUUID(); + when(ops.localOccurrenceId()).thenReturn(localId); + when(ops.proxyVoteId()).thenReturn(UUID.randomUUID()); // An irrelevant proxy field cannot override local identity. + when(ops.currentTimeMillis()).thenReturn(789L); + SharedVoteProcessor.process(ops); + verify(ops).setTimeNow(user, site); + verify(ops, never()).triggerProxyEvent(); + verify(ops).dateMilestones(user, site, 789L, localId); + verify(ops).postVote(site, user, "Ben", 789L, localId, true); + } + @Test void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { var ops = accepted(); @@ -113,6 +137,7 @@ void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { ArgumentCaptor id = ArgumentCaptor.forClass(UUID.class); verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(321L), id.capture(), eq(false)); assertEquals(proxyId, id.getValue()); + verify(ops).dateMilestones(user, site, 321L, proxyId); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java index d8b0ae063d..056cdb69e2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java @@ -31,4 +31,47 @@ void explicitTransportIdentityWinsOverLegacySnapshot() { assertEquals(explicitId, PlayerVoteListener.resolveProxyVoteId(event)); } + @Test void asynchronousEventFlagOnActualOwnerThreadDefersRatherThanTouchingLedger() throws Exception { + try (var bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::isPrimaryThread).thenReturn(true); + var event = new PlayerVoteEvent(null, "Ben", "Example", true); + event.setTime(123L); event.setBungee(true); UUID id = UUID.randomUUID(); event.setProxyVoteId(id); + org.junit.jupiter.api.Assertions.assertTrue(event.isAsynchronous()); + var plugin = org.mockito.Mockito.mock(com.bencodez.votingplugin.VotingPluginMain.class); + var manager = org.mockito.Mockito.mock(com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones.class); + org.mockito.Mockito.when(plugin.getDateVoteMilestones()).thenReturn(manager); + var user = org.mockito.Mockito.mock(com.bencodez.votingplugin.user.VotingPluginUser.class); + var site = org.mockito.Mockito.mock(com.bencodez.votingplugin.votesites.VoteSite.class); + org.mockito.Mockito.when(site.getKey()).thenReturn("Example"); + Class operations = Class.forName(PlayerVoteListener.class.getName()+"$BukkitOperations"); + var constructor = operations.getDeclaredConstructor(com.bencodez.votingplugin.VotingPluginMain.class, PlayerVoteEvent.class); constructor.setAccessible(true); + var method = operations.getDeclaredMethod("dateMilestones",com.bencodez.votingplugin.user.VotingPluginUser.class,com.bencodez.votingplugin.votesites.VoteSite.class,long.class,UUID.class);method.setAccessible(true); + method.invoke(constructor.newInstance(plugin,event),user,site,123L,id); + org.mockito.Mockito.verify(manager).deferAccepted(user,"Example",id,123L,true,true,true,false,false); + org.mockito.Mockito.verify(manager, org.mockito.Mockito.never()).accepted(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyBoolean(),org.mockito.ArgumentMatchers.anyBoolean(),org.mockito.ArgumentMatchers.anyBoolean(),org.mockito.ArgumentMatchers.anyBoolean(),org.mockito.ArgumentMatchers.anyBoolean()); + org.junit.jupiter.api.Assertions.assertTrue(event.isDateMilestoneAccountingFailed()); + } + } + @Test void dateAccountingRequiresExplicitTransportIdentityInsteadOfLegacyCorrelationFallback() throws Exception { + for (boolean explicit : new boolean[] {false, true}) { + UUID voteId = UUID.randomUUID(); + PlayerVoteEvent event = new PlayerVoteEvent(null, "Ben", "Example", true); + event.setTime(123L); event.setBungee(true); + event.setBungeeTextTotals(new VoteTotalsSnapshot(1,2,3,4,5,6,7,8,voteId)); + if (explicit) event.setProxyVoteId(voteId); + var plugin = org.mockito.Mockito.mock(com.bencodez.votingplugin.VotingPluginMain.class); + var milestones = org.mockito.Mockito.mock(com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones.class); + org.mockito.Mockito.when(plugin.getDateVoteMilestones()).thenReturn(milestones); + var user = org.mockito.Mockito.mock(com.bencodez.votingplugin.user.VotingPluginUser.class); + var site = org.mockito.Mockito.mock(com.bencodez.votingplugin.votesites.VoteSite.class); + org.mockito.Mockito.when(site.getKey()).thenReturn("Example"); + Class operations = Class.forName(PlayerVoteListener.class.getName()+"$BukkitOperations"); + var constructor = operations.getDeclaredConstructor(com.bencodez.votingplugin.VotingPluginMain.class, PlayerVoteEvent.class); constructor.setAccessible(true); + var method = operations.getDeclaredMethod("dateMilestones",com.bencodez.votingplugin.user.VotingPluginUser.class,com.bencodez.votingplugin.votesites.VoteSite.class,long.class,UUID.class);method.setAccessible(true); + method.invoke(constructor.newInstance(plugin,event),user,site,123L,voteId); + org.mockito.Mockito.verify(milestones).accepted(user,"Example",voteId,123L,true,true,explicit,false,false); + assertEquals(voteId,PlayerVoteListener.resolveProxyVoteId(event)); + } + } + } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/ServiceSiteVoteLoggingRegressionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/ServiceSiteVoteLoggingRegressionTest.java index ac998cd4a6..d63caa08ae 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/ServiceSiteVoteLoggingRegressionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/ServiceSiteVoteLoggingRegressionTest.java @@ -5,11 +5,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.times; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import java.nio.file.Path; -import java.util.UUID; import java.util.ArrayList; import org.mockito.ArgumentCaptor; import java.util.logging.Logger; @@ -35,15 +33,15 @@ void bungeeVoteProcessingUsesServiceSiteSanitizerForTheActualReceiptLog() throws String[] services = { "Minecraft-MP.com", "PlanetMinecraft.com", "Minecraft Servers", "R&D", "Top100Servers.net", "Safe\nName", "Safe\u2060Name", null }; for (String service : services) { - processBungeeVote(plugin, service, "player.name"); + PendingIncomingVote pending = processBungeeVote(plugin, service, "player.name"); verify(runtime).vote(eq("player.name"), eq(service), eq(true), eq(true), eq(0L), eq(null), eq(null), - any(UUID.class)); + eq(pending.getVoteId()), eq(pending.getAcceptedAt())); } String longService = "x".repeat(140); - processBungeeVote(plugin, longService, "example_player"); + PendingIncomingVote pending = processBungeeVote(plugin, longService, "example_player"); verify(runtime).vote(eq("example_player"), eq(longService), eq(true), eq(true), eq(0L), eq(null), eq(null), - any(UUID.class)); + eq(pending.getVoteId()), eq(pending.getAcceptedAt())); assertReceiptLogs(logger, services); } @@ -59,15 +57,15 @@ void velocityVoteProcessingUsesServiceSiteSanitizerForTheActualReceiptLog(@TempD String[] services = { "Minecraft-MP.com", "PlanetMinecraft.com", "Minecraft Servers", "R&D", "Top100Servers.net", "Safe\nName", "Safe\u2060Name", null }; for (String service : services) { - processVelocityVote(plugin, service, "player.name"); + PendingIncomingVote pending = processVelocityVote(plugin, service, "player.name"); verify(runtime).vote(eq("player.name"), eq(service), eq(true), eq(false), eq(0L), eq(null), eq(null), - any(UUID.class)); + eq(pending.getVoteId()), eq(pending.getAcceptedAt())); } String longService = "x".repeat(140); - processVelocityVote(plugin, longService, "example_player"); + PendingIncomingVote pending = processVelocityVote(plugin, longService, "example_player"); verify(runtime).vote(eq("example_player"), eq(longService), eq(true), eq(false), eq(0L), eq(null), eq(null), - any(UUID.class)); + eq(pending.getVoteId()), eq(pending.getAcceptedAt())); ArgumentCaptor logs = ArgumentCaptor.forClass(String.class); verify(logger, times(services.length + 1)).info(logs.capture()); assertEquals(expectedReceiptLogs(services), logs.getAllValues()); @@ -97,16 +95,18 @@ private static void prepareVelocity(VotingPluginVelocity plugin, VotingPluginPro setField(plugin, "runtimeOperational", true); } - private static void processBungeeVote(VotingPluginBungee plugin, String service, String player) throws Exception { + private static PendingIncomingVote processBungeeVote(VotingPluginBungee plugin, String service, String player) throws Exception { PendingIncomingVote pending = ((PendingIncomingVoteQueue) getField(plugin, "pendingIncomingVotes")) .admit(player, service); invokePendingProcessor(plugin, pending); + return pending; } - private static void processVelocityVote(VotingPluginVelocity plugin, String service, String player) throws Exception { + private static PendingIncomingVote processVelocityVote(VotingPluginVelocity plugin, String service, String player) throws Exception { PendingIncomingVote pending = ((PendingIncomingVoteQueue) getField(plugin, "pendingIncomingVotes")) .admit(player, service); invokePendingProcessor(plugin, pending); + return pending; } private static void assertReceiptLogs(Logger logger, String[] services) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VoteOccurrenceTotalsReplayTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VoteOccurrenceTotalsReplayTest.java new file mode 100644 index 0000000000..86288ee2ae --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VoteOccurrenceTotalsReplayTest.java @@ -0,0 +1,213 @@ +package com.bencodez.votingplugin.proxy; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; +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.messaging.BackendProxyMessageRouter.OrderedVoteOutcome; +import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; +import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; +import com.bencodez.votingplugin.proxy.bungee.VotingPluginBungee; +import com.bencodez.votingplugin.proxy.bungee.BungeeJsonVoteCache; +import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler; +import com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; +import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; + +/** Exercises durable row decoding, real proxy replay, and the complete backend wire envelope. */ +class VoteOccurrenceTotalsReplayTest { + private static final UUID PLAYER = new UUID(1, 731); + private static final UUID OCCURRENCE = new UUID(2, 731); + private static final long QUEUE_TIME = 1788201600123L; + private static final long ORIGINAL_TIME = 1788201500456L; + + @TempDir Path directory; + + private record Payload(String name, String text, int current, int required, int allTime, boolean metadata) { } + + @TestFactory + Stream retainedRows() { + List payloads = List.of( + new Payload("legacy-empty", "", 7, 40, 0, false), + new Payload("occurrence-only", "", 7, 40, 0, true), + new Payload("explicit-zero", "v2//0//0//0//0//0//0//0//0", 0, 0, 0, true), + new Payload("versioned-totals", "v2//11//12//13//14//15//16//17//18", 16, 17, 11, true), + new Payload("legacy-totals", "11//12//13//14//15//999//16//17//18", 16, 17, 11, true)); + return Stream.of(false, true).flatMap(applied -> payloads.stream().map(payload -> { + String name = payload.name() + "-already-applied-" + applied; + return DynamicTest.dynamicTest(name, () -> durableRestartReplayPreservesFallbackOrSuppliedTotals( + payload.text(), applied, payload.current(), payload.required(), payload.allTime(), + payload.metadata(), Files.createDirectories(directory.resolve(name)))); + })); + } + + private void durableRestartReplayPreservesFallbackOrSuppliedTotals(String payload, boolean applied, + int expectedCurrent, int expectedRequired, int expectedAllTime, boolean metadata, + Path directory) throws Exception { + ReplayProxy producer = new ReplayProxy(directory); + VoteTimeQueue row = row(payload, applied, metadata); + assertTrue(producer.cache.addTimeVoteToCache(row)); + + ReplayProxy restarted = new ReplayProxy(directory); + restarted.cache.load(); + VoteTimeQueue restored = restarted.cache.getTimeChangeQueue().peek(); + assertNotNull(restored); + assertEquals(OCCURRENCE, restored.getVoteId()); + assertEquals(PLAYER.toString(), restored.getUuid()); + assertEquals(QUEUE_TIME, restored.getTime()); + assertEquals(applied, restored.isTotalsApplied()); + assertEquals(metadata ? ORIGINAL_TIME : 0L, restored.getCanonicalOccurrenceTime()); + + restarted.processQueue(); + assertTrue(restarted.cache.getTimeChangeQueue().isEmpty(), "successful replay must retire its source row"); + assertRewardAndBackend(restarted, expectedCurrent, expectedRequired, expectedAllTime, + metadata ? ORIGINAL_TIME : -1L); + restarted.processQueue(); + assertEquals(1, restarted.cache.getOnlineVotes(PLAYER.toString()).size(), + "repeated queue processing must not create another reward occurrence"); + + ReplayProxy secondRestart = new ReplayProxy(directory); + secondRestart.cache.load(); + assertTrue(secondRestart.cache.getTimeChangeQueue().isEmpty()); + assertEquals(1, secondRestart.cache.getOnlineVotes(PLAYER.toString()).size()); + assertRewardAndBackend(secondRestart, expectedCurrent, expectedRequired, expectedAllTime, + metadata ? ORIGINAL_TIME : -1L); + } + + @Test + void alreadyAppliedRetryWithoutAnExplicitSnapshotDoesNotParseMetadataAsZeros(@TempDir Path directory) + throws Exception { + ReplayProxy proxy = new ReplayProxy(directory); + VoteTimeQueue row = row("", true, true); + assertTrue(proxy.cache.addTimeVoteToCache(row)); + assertEquals(VotingPluginProxy.QueuedVoteResult.SUCCESS, proxy.replay(row, null)); + assertRewardAndBackend(proxy, 7, 40, 0, ORIGINAL_TIME); + } + + @Test + void alreadyAppliedRetryRestoresARealSnapshotWhenTheCallerHasNone(@TempDir Path directory) throws Exception { + ReplayProxy proxy = new ReplayProxy(directory); + VoteTimeQueue row = row("v2//11//12//13//14//15//16//17//18", true, true); + assertTrue(proxy.cache.addTimeVoteToCache(row)); + assertEquals(VotingPluginProxy.QueuedVoteResult.SUCCESS, proxy.replay(row, null)); + assertRewardAndBackend(proxy, 16, 17, 11, ORIGINAL_TIME); + } + + private static VoteTimeQueue row(String payload, boolean applied, boolean metadata) { + VoteTimeQueue row = new VoteTimeQueue(OCCURRENCE, "Synthetic", "synthetic-service", QUEUE_TIME); + row.setUuid(PLAYER.toString()); + row.setWasOnline(false); + row.setTotals(payload); + if (metadata) row.setCanonicalOccurrenceTime(ORIGINAL_TIME); + row.setVotePartyApplied(true); + row.setTotalsApplied(applied); + return row; + } + + private static void assertRewardAndBackend(ReplayProxy proxy, int current, int required, int allTime, + long occurrenceTime) throws Exception { + List rewards = proxy.cache.getOnlineVotes(PLAYER.toString()); + assertEquals(1, rewards.size()); + OfflineBungeeVote reward = rewards.get(0); + assertEquals(OCCURRENCE, reward.getVoteId()); + assertEquals(PLAYER.toString(), reward.getUuid()); + assertEquals(QUEUE_TIME, reward.getTime()); + assertEquals(occurrenceTime, reward.getCanonicalOccurrenceTime()); + VoteTotalsSnapshot totals = VoteTotalsSnapshot.parseStorage(reward.getText()); + assertEquals(current, totals.getVotePartyCurrent()); + assertEquals(required, totals.getVotePartyRequired()); + assertEquals(allTime, totals.getAllTimeTotal()); + + // Serialize the complete reward wrapper before entering the actual backend router. + JsonEnvelope envelope = JsonEnvelopeCodec.decode(JsonEnvelopeCodec.encode(VotingPluginWire.vote( + reward.getPlayerName(), reward.getUuid(), reward.getService(), reward.getTime(), false, + reward.isRealVote(), reward.getText(), reward.getVoteId(), false, false, 1, 1))); + VotingPluginWire.Vote wire = VotingPluginWire.readVote(envelope); + assertEquals(OCCURRENCE, wire.voteId); + assertEquals(QUEUE_TIME, wire.time); + assertEquals(occurrenceTime, wire.canonicalOccurrenceTime); + + VotingPluginMain plugin = mock(VotingPluginMain.class); + UserManager users = mock(UserManager.class); + VotingPluginUser user = mock(VotingPluginUser.class); + BackendVotePartySync party = mock(BackendVotePartySync.class); + when(plugin.getVotingPluginUserManager()).thenReturn(users); + when(users.getVotingPluginUser(PLAYER, "Synthetic")).thenReturn(user); + when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class)); + when(plugin.getServerData()).thenReturn(mock(ServerData.class)); + BackendProxyMessageRouter router = new BackendProxyMessageRouter(plugin, + mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), party, + new ProcessedVoteCache()); + AtomicReference completion = new AtomicReference<>(); + router.handleOrderedVote(envelope, completion::set); + assertEquals(OrderedVoteOutcome.COMPLETE, completion.get()); + verify(party).replace(current, required); + verify(user).bungeeVotePluginMessagingEvent(eq("synthetic-service"), eq(QUEUE_TIME), + argThat(value -> value.getAllTimeTotal() == allTime && value.getVotePartyCurrent() == current + && value.getVotePartyRequired() == required), eq(true), eq(false), eq(false), eq(1), + eq(false), eq(false), eq(true), eq(OCCURRENCE), eq(false), eq(true), eq(occurrenceTime)); + } + + private static final class ReplayProxy extends VotingPluginProxyTestImpl { + final VoteCacheHandler cache; + + ReplayProxy(Path directory) throws Exception { + setDataFolder(directory.toFile()); + setMethod(BungeeMethod.PLUGINMESSAGING); + setVotePartyVotes(7); + setCurrentVotePartyVotesRequired(40); + setSchedulerForTest(mock(ScheduledExecutorService.class)); + setField(this, "enabled", true); + when(getConfig().getOnlineMode()).thenReturn(true); + when(getConfig().getMultiProxySupport()).thenReturn(true); + when(getConfig().getPrimaryServer()).thenReturn(false); + when(getConfig().getBlockedServers()).thenReturn(List.of()); + VotingPluginBungee platform = mock(VotingPluginBungee.class); + when(platform.getDataFolder()).thenReturn(directory.toFile()); + BungeeJsonVoteCache storage = new BungeeJsonVoteCache(platform); + cache = new VoteCacheHandler(null, false, false, null, false, storage) { + @Override public void logInfo1(String message) { } + @Override public void logSevere1(String message) { } + @Override public void debug1(Exception failure) { } + @Override public void debug1(Throwable failure) { } + @Override public void debug1(String message) { } + }; + setField(this, "voteCacheHandler", cache); + } + + @Override public boolean isPlayerOnline(String name) { return false; } + @Override public Set getAllAvailableServers() { return Set.of(); } + VotingPluginProxy.QueuedVoteResult replay(VoteTimeQueue row, VoteTotalsSnapshot totals) { + return super.replayQueuedVote(row, totals, true); + } + } + + private static void setField(Object owner, String name, Object value) throws Exception { + Field field = VotingPluginProxy.class.getDeclaredField(name); + field.setAccessible(true); + field.set(owner, value); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyPacingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyPacingTest.java index d8f0018c28..b7ab3e5cb9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyPacingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyPacingTest.java @@ -718,6 +718,51 @@ void restartPreservesCompletedRewardAndItsOriginalSingleServer(@TempDir Path dir "restart or backend movement repeated a completed reward"); } + @Test + void delayedLiveReceiptUsesCapturedDateTimeWithoutBackdatingCooldown(@TempDir Path directory) throws Exception { + CapturingProxy proxy = new CapturingProxy(directory); + enableLiveBroadcasts(proxy); + UUID id = new UUID(2, 6007); + long receivedAt = 1_609_459_199_999L; + long processingStarted = System.currentTimeMillis(); + assertDoesNotThrow(() -> proxy.vote("Synthetic", "synthetic-service", true, false, + 0L, null, new UUID(1, 6007).toString(), id, receivedAt)); + proxy.time.advance(TimeUnit.SECONDS.toNanos(6)); + var delivered = proxy.handoffs.stream().filter(h -> VotingPluginWire.SUB_VOTE_ONLINE + .equals(h.envelope().getSubChannel()) || VotingPluginWire.SUB_VOTE.equals(h.envelope().getSubChannel())).toList(); + assertFalse(delivered.isEmpty()); + for (var handoff : delivered) { + assertEquals(Long.toString(receivedAt), handoff.envelope().getFields() + .get(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME)); + assertTrue(Long.parseLong(handoff.envelope().getFields().get(VotingPluginWire.K_TIME)) >= processingStarted); + } + proxy.closeDispatcher(); + } + + @Test + void unprocessedIncomingReceiptPreservesOriginalTimeThroughDurableRestart(@TempDir Path directory) throws Exception { + CapturingProxy proxy = new CapturingProxy(directory); + UUID id = new UUID(2, 507); + long receivedAt = 1_609_459_199_999L; + PendingIncomingVote pending = new PendingIncomingVote(id, "TestPlayer", "synthetic-service", receivedAt); + var recovery = proxy.snapshotIncomingVoteForRestart(pending); + assertEquals(receivedAt, recovery.getCanonicalOccurrenceTime()); + assertEquals(receivedAt, recovery.getTime()); + assertFalse(recovery.isTotalsApplied()); + assertFalse(recovery.isVotePartyApplied()); + assertTrue(proxy.cache.addTimeVoteToCache(recovery)); + proxy.closeDispatcher(); + CapturingProxy restarted = new CapturingProxy(directory); + restarted.cache.load(); + var restored = restarted.cache.getTimeChangeQueue().peek(); + assertNotNull(restored); + assertEquals(id, restored.getVoteId()); + assertEquals(receivedAt, restored.getCanonicalOccurrenceTime()); + assertFalse(restored.isTotalsApplied()); + assertFalse(restored.isVotePartyApplied()); + restarted.closeDispatcher(); + } + @Test void existingTimedQueuePersistsAcceptedRewardBeforeLaterWorkFails(@TempDir Path directory) throws Exception { CapturingProxy proxy = new CapturingProxy(directory); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungeeInitializationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungeeInitializationTest.java index 81d16ffbfa..e869384bd8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungeeInitializationTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungeeInitializationTest.java @@ -122,6 +122,22 @@ void successfulVotifierRegistrationPublishesListener() throws Exception { assertSame(listener, getField(plugin, "voteEventBungee")); } + @Test + void capturedReceiptIsPassedSeparatelyFromNormalProcessingTime() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class, CALLS_REAL_METHODS); + VotingPluginProxy runtime = mock(VotingPluginProxy.class); + setField(plugin, "reloadLock", new Object()); + setField(plugin, "votingPluginProxy", runtime); + setField(plugin, "runtimeOperational", true); + java.util.UUID voteId = java.util.UUID.randomUUID(); + long receivedAt = 1_609_459_199_999L; + assertSame(IncomingVoteRuntimeResult.PROCESSED, + plugin.processIncomingVote("TestPlayer", "synthetic-service", voteId, receivedAt)); + verify(runtime).vote("TestPlayer", "synthetic-service", true, true, 0, + null, null, voteId, receivedAt); + org.mockito.Mockito.verifyNoMoreInteractions(runtime); + } + @Test void liveVoteAdmissionIsFencedToThePublishedRuntime() throws Exception { VotingPluginBungee plugin = mock(VotingPluginBungee.class, CALLS_REAL_METHODS); @@ -339,7 +355,7 @@ void acceptedVoteCompletesOnceWithItsOriginalId() throws Exception { .snapshot().get(0); task.get().run(); - verify(runtime).vote("Player", "Service", true, true, 0, null, null, pending.getVoteId()); + verify(runtime).vote("Player", "Service", true, true, 0, null, null, pending.getVoteId(), pending.getAcceptedAt()); assertEquals(0, ((PendingIncomingVoteQueue) getField(plugin, "pendingIncomingVotes")).size()); } @@ -379,7 +395,7 @@ void reloadWaitingKeepsStableIdWithoutConsumingStorageAttempts() throws Exceptio assertTrue(((PendingIncomingVoteQueue) getField(plugin, "pendingIncomingVotes")).contains(pending.getVoteId())); setField(plugin, "reloading", false); retry.get().run(); - verify(runtime).vote("Player", "Service", true, true, 0, null, null, pending.getVoteId()); + verify(runtime).vote("Player", "Service", true, true, 0, null, null, pending.getVoteId(), pending.getAcceptedAt()); assertEquals(0, ((PendingIncomingVoteQueue) getField(plugin, "pendingIncomingVotes")).size()); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocityInitializationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocityInitializationTest.java index 35bbb1efd2..3fe668ea4e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocityInitializationTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocityInitializationTest.java @@ -215,6 +215,22 @@ void successfulVotifierRegistrationPublishesListener(@TempDir Path dataDirectory } } + @Test + void capturedReceiptIsPassedSeparatelyFromNormalProcessingTime() throws Exception { + VotingPluginVelocity plugin = mock(VotingPluginVelocity.class, org.mockito.Mockito.CALLS_REAL_METHODS); + VotingPluginProxy runtime = mock(VotingPluginProxy.class); + setField(plugin, "reloadLock", new Object()); + setField(plugin, "votingPluginProxy", runtime); + setField(plugin, "runtimeOperational", true); + java.util.UUID voteId = java.util.UUID.randomUUID(); + long receivedAt = 1_609_459_199_999L; + assertSame(IncomingVoteRuntimeResult.PROCESSED, + plugin.processIncomingVote("TestPlayer", "synthetic-service", voteId, receivedAt)); + verify(runtime).vote("TestPlayer", "synthetic-service", true, false, 0, + null, null, voteId, receivedAt); + org.mockito.Mockito.verifyNoMoreInteractions(runtime); + } + @Test void liveVoteAdmissionIsFencedToThePublishedRuntime(@TempDir Path dataDirectory) throws Exception { VotingPluginVelocity plugin = new VotingPluginVelocity(mock(ProxyServer.class), mock(Logger.class), @@ -361,7 +377,7 @@ void acceptedVoteCompletesOnceWithItsOriginalId(@TempDir Path dataDirectory) thr .snapshot().get(0); task.get().run(); - verify(runtime).vote("Player", "Service", true, false, 0, null, null, pending.getVoteId()); + verify(runtime).vote("Player", "Service", true, false, 0, null, null, pending.getVoteId(), pending.getAcceptedAt()); assertEquals(0, ((PendingIncomingVoteQueue) getField(plugin, "pendingIncomingVotes")).size()); } @@ -396,7 +412,7 @@ void reloadWaitingKeepsStableIdWithoutConsumingStorageAttempts(@TempDir Path dat assertTrue(((PendingIncomingVoteQueue) getField(plugin, "pendingIncomingVotes")).contains(pending.getVoteId())); setField(plugin, "reloading", false); retry.get().run(); - verify(runtime).vote("Player", "Service", true, false, 0, null, null, pending.getVoteId()); + verify(runtime).vote("Player", "Service", true, false, 0, null, null, pending.getVoteId(), pending.getAcceptedAt()); assertEquals(0, ((PendingIncomingVoteQueue) getField(plugin, "pendingIncomingVotes")).size()); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java new file mode 100644 index 0000000000..d6f5a841b9 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java @@ -0,0 +1,351 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; +import static org.junit.jupiter.api.Assertions.*; +import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.StandardOpenOption; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +class DateVoteLedgerTest { + @TempDir Path root; + final UUID player = UUID.randomUUID(); + + @Test void progressOfMissingLedgerDoesNotInitializeStorage() throws Exception { + Path missing = root.resolve("missing"); + var progress = new DateVoteLedger(missing).progress(event("empty", "Empty", 200), player); + assertEquals(0, progress.votes()); + assertTrue(progress.reservedAwards().isEmpty()); + assertTrue(progress.submittedAwards().isEmpty()); + assertTrue(progress.deferredAwards().isEmpty()); + assertFalse(Files.exists(missing)); + } + + @Test void invalidLedgerPathCannotBeReportedAsAnEmptySnapshot() throws Exception { + Path invalid = root.resolve("invalid"); + Files.writeString(invalid, "synthetic non-directory"); + assertThrows(IOException.class, () -> new DateVoteLedger(invalid) + .progress(event("empty", "Empty", 200), player)); + assertEquals("synthetic non-directory", Files.readString(invalid)); + } + + @Test void unavailableCrossProcessLockRejectsEveryTransactionWithoutChangingProgress() throws Exception { + var e = event("a", "A", 200); UUID occurrence = UUID.randomUUID(); + var first = new DateVoteLedger(root); + first.record(e, player, occurrence, false); + Path lockPath = root.resolve(".ledger.lock"); + Process owner = lockProcess(lockPath, "hold"); + try { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (owner.getInputStream().available() == 0 && owner.isAlive() && System.nanoTime() < deadline) Thread.sleep(5); + assertTrue(owner.getInputStream().available() > 0, "External lock owner did not become ready"); + assertEquals(1, owner.getInputStream().read()); + var successor = new DateVoteLedger(root); + assertThrows(IOException.class, () -> successor.record(e, player, UUID.randomUUID())); + assertThrows(IOException.class, () -> successor.reserve(e, player, 1)); + assertThrows(IOException.class, () -> successor.submitted(e, player, 1)); + assertThrows(IOException.class, () -> successor.progress(e, player)); + owner.getOutputStream().write(1); owner.getOutputStream().flush(); + assertTrue(owner.waitFor(10, TimeUnit.SECONDS)); assertEquals(0, owner.exitValue()); + } finally { + owner.destroyForcibly(); owner.waitFor(10, TimeUnit.SECONDS); + } + assertEquals(1, first.progress(e, player).votes()); + assertEquals(Set.of(1), first.progress(e, player).deferredAwards()); + var successor = new DateVoteLedger(root); + assertEquals(List.of(), successor.record(e, player, occurrence, false)); + assertEquals(1, successor.progress(e, player).votes()); + assertEquals(Set.of(1), successor.progress(e, player).deferredAwards()); + } + + @Test void classloaderAndPathAliasContenderCannotReleaseAnOwnersNativeLock() throws Exception { + Path actual = Files.createDirectory(root.resolve("actual")); + Path alias = Files.createSymbolicLink(root.resolve("alias"), actual); + Path directory = actual.resolve("ledger"); + var e = event("a", "A", 200); + var owner = new DateVoteLedger(directory); + owner.record(e, player, UUID.randomUUID(), false); + CountDownLatch locked = new CountDownLatch(1), release = new CountDownLatch(1), contenderStarted = new CountDownLatch(1); + AtomicReference contenderThread = new AtomicReference<>(); + try (var loader = separateLedgerClassloader(); var pool = Executors.newFixedThreadPool(2)) { + Class ledgerClass = loader.loadClass(DateVoteLedger.class.getName()); + assertNotSame(DateVoteLedger.class, ledgerClass); + Object successor = ledgerClass.getConstructor(Path.class).newInstance(alias.resolve("ledger")); + Future owning = pool.submit(() -> withFence(owner, () -> { + locked.countDown(); + if (!release.await(20, TimeUnit.SECONDS)) throw new IOException("Fixture lock owner timed out"); + return null; + })); + try { + assertTrue(locked.await(10, TimeUnit.SECONDS)); + assertEquals(3, probeLock(directory.resolve(".ledger.lock"))); + Future contending = pool.submit(() -> { + contenderThread.set(Thread.currentThread()); contenderStarted.countDown(); + return invoke(ledgerClass.getMethod("record", DateVoteEvent.class, UUID.class, UUID.class, boolean.class), + successor, e, player, UUID.randomUUID(), false); + }); + assertTrue(contenderStarted.await(10, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (!contending.isDone() && contenderThread.get().getState() != Thread.State.BLOCKED + && System.nanoTime() < deadline) Thread.sleep(5); + assertEquals(3, probeLock(directory.resolve(".ledger.lock")), "A contender must not close another descriptor and drop the owner's native lock"); + assertFalse(contending.isDone(), "The contender must serialize before opening the OS channel"); + assertEquals(Thread.State.BLOCKED, contenderThread.get().getState()); + release.countDown(); owning.get(10, TimeUnit.SECONDS); contending.get(10, TimeUnit.SECONDS); + } finally { release.countDown(); } + } + assertEquals(2, owner.progress(e, player).votes()); + assertEquals(0, probeLock(directory.resolve(".ledger.lock"))); + } + + @Test void reentrantTransactionRejectsBeforeOpeningAnotherChannel() throws Exception { + var e = event("a", "A", 200); var owner = new DateVoteLedger(root); var successor = new DateVoteLedger(root); + owner.record(e, player, UUID.randomUUID(), false); + withFence(owner, () -> { + assertEquals(3, probeLock(root.resolve(".ledger.lock"))); + assertThrows(IOException.class, () -> successor.record(e, player, UUID.randomUUID())); + assertEquals(3, probeLock(root.resolve(".ledger.lock"))); + return null; + }); + assertEquals(1, successor.progress(e, player).votes()); + assertEquals(0, probeLock(root.resolve(".ledger.lock"))); + assertThrows(IOException.class, () -> withFence(owner, () -> { throw new IOException("Fixture transaction failed"); })); + assertEquals(0, probeLock(root.resolve(".ledger.lock")), "Failure must release the native lock"); + successor.record(e, player, UUID.randomUUID(), false); + assertEquals(2, successor.progress(e, player).votes()); + } + + @Test void serialInstancesEnforceCurrentCapacityAndStillUpdateExistingRecords() throws Exception { + var e = event("a", "A", 200); var first = new DateVoteLedger(root, 2); var successor = new DateVoteLedger(root, 2); + UUID otherPlayer = UUID.randomUUID(), rejectedPlayer = UUID.randomUUID(); + first.record(e, player, UUID.randomUUID(), false); + successor.record(e, otherPlayer, UUID.randomUUID(), false); + assertThrows(IOException.class, () -> first.record(e, rejectedPlayer, UUID.randomUUID())); + assertThrows(IOException.class, () -> new DateVoteLedger(root, 2).record(e, rejectedPlayer, UUID.randomUUID())); + assertFalse(Files.exists(root.resolve(e.fileId() + "-" + rejectedPlayer + ".properties"))); + first.record(e, player, UUID.randomUUID(), false); + assertTrue(successor.reserve(e, player, 1)); successor.submitted(e, player, 1); + assertEquals(2, new DateVoteLedger(root, 2).progress(e, player).votes()); + assertEquals(Set.of(1), first.progress(e, player).submittedAwards()); + assertEquals(1, successor.progress(e, otherPlayer).votes()); + assertThrows(IllegalArgumentException.class, () -> new DateVoteLedger(root, 0)); + assertThrows(IllegalArgumentException.class, () -> new DateVoteLedger(root, 100_001)); + } + + private static Object withFence(Object ledger, Callable action) throws Exception { + Class actionClass = Class.forName(DateVoteLedger.class.getName() + "$LedgerAction", true, ledger.getClass().getClassLoader()); + var method = ledger.getClass().getDeclaredMethod("withLedgerLock", actionClass); method.setAccessible(true); + Object proxy = Proxy.newProxyInstance(actionClass.getClassLoader(), new Class[] { actionClass }, + (ignored, called, arguments) -> action.call()); + return invoke(method, ledger, proxy); + } + private static Object invoke(java.lang.reflect.Method method, Object target, Object... arguments) throws Exception { + try { return method.invoke(target, arguments); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception cause) throw cause; + if (failure.getCause() instanceof Error cause) throw cause; + throw new AssertionError(failure.getCause()); + } + } + private static URLClassLoader separateLedgerClassloader() { + return new URLClassLoader(new URL[] { DateVoteLedger.class.getProtectionDomain().getCodeSource().getLocation() }, + DateVoteLedger.class.getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.equals(DateVoteLedger.class.getName()) && !name.startsWith(DateVoteLedger.class.getName() + "$")) + return super.loadClass(name, resolve); + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) loaded = findClass(name); + if (resolve) resolveClass(loaded); + return loaded; + } + } + }; + } + private static Process lockProcess(Path file, String mode) throws IOException { + String executable = System.getProperty("os.name", "").startsWith("Windows") ? "java.exe" : "java"; + return new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", executable).toString(), "-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + LockProbe.class.getName(), file.toString(), mode).redirectError(ProcessBuilder.Redirect.INHERIT).start(); + } + private static int probeLock(Path file) throws Exception { + Process probe = lockProcess(file, "probe"); + try { + assertTrue(probe.waitFor(10, TimeUnit.SECONDS), "External lock probe timed out"); + return probe.exitValue(); + } finally { probe.destroyForcibly(); probe.waitFor(10, TimeUnit.SECONDS); } + } + public static final class LockProbe { + public static void main(String[] arguments) throws Exception { + int result; + try (FileChannel channel = FileChannel.open(Path.of(arguments[0]), StandardOpenOption.WRITE); + FileLock lock = channel.tryLock()) { + result = lock == null ? 3 : 0; + if (lock != null && arguments[1].equals("hold")) { + System.out.write(1); System.out.flush(); System.in.read(); + } + } + System.exit(result); + } + } + private DateVoteEvent event(String id, String name, long end) { + return new DateVoteEvent(id, name, true, 100, end, "UTC", List.of(1, 2), Set.of(), ""); + } + @Test void deferredThresholdPromotesOnceAcrossConcurrentReplayAndNeverDemotesAnUncertainReservation() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); UUID occurrence = UUID.randomUUID(); + assertEquals(List.of(), ledger.record(e, player, occurrence, false)); + ledger = new DateVoteLedger(root); assertEquals(Set.of(1), ledger.progress(e, player).deferredAwards()); + final DateVoteLedger resumed = ledger; + try (var pool = Executors.newFixedThreadPool(4)) { + var jobs = java.util.stream.IntStream.range(0, 20).mapToObj(i -> (java.util.concurrent.Callable>) + () -> { + var pending = resumed.record(e, player, occurrence, true); + return pending.stream().filter(threshold -> { + try { return resumed.reserve(e, player, threshold); } + catch (IOException failure) { throw new java.io.UncheckedIOException(failure); } + }).toList(); + }).toList(); + int admitted = 0; + for (var result : pool.invokeAll(jobs)) admitted += result.get().size(); + assertEquals(1, admitted); + } + assertEquals(List.of(), ledger.record(e, player, occurrence, false)); + assertEquals(Set.of(1), ledger.progress(e, player).reservedAwards()); + assertTrue(ledger.progress(e, player).deferredAwards().isEmpty()); assertEquals(1, ledger.progress(e, player).votes()); + assertEquals(List.of(), new DateVoteLedger(root).record(e, player, occurrence, true)); + } + @Test void duplicateAndRestartNeverResubmitReservedOrSubmittedAwards() throws Exception { + var e = event("a", "A", 200); var ledger = new DateVoteLedger(root); UUID id = UUID.randomUUID(); + assertEquals(List.of(1), ledger.record(e, player, id)); + assertTrue(ledger.reserve(e, player, 1)); + ledger = new DateVoteLedger(root); + assertEquals(List.of(), ledger.record(e, player, id)); + assertEquals(Set.of(1), ledger.progress(e, player).reservedAwards()); + ledger.submitted(e, player, 1); ledger.submitted(e, player, 1); + ledger = new DateVoteLedger(root); + assertEquals(List.of(2), ledger.record(e, player, UUID.randomUUID())); + assertEquals(Set.of(1), ledger.progress(e, player).submittedAwards()); + assertEquals(2, ledger.progress(e, player).votes()); + } + @Test void overlapsCountOnceForEachEventButUnrelatedPlayersAreSeparate() throws Exception { + var ledger = new DateVoteLedger(root); UUID occurrence = UUID.randomUUID(); + assertEquals(List.of(1), ledger.record(event("a", "A", 200), player, occurrence)); + assertEquals(List.of(1), ledger.record(event("b", "B", 300), player, occurrence)); + assertEquals(List.of(1), ledger.record(event("a", "A", 200), UUID.randomUUID(), occurrence)); + } + @Test void sameOccurrenceConcurrentRequestsReserveExactlyOnce() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); UUID occurrence = UUID.randomUUID(); + try (var pool = Executors.newFixedThreadPool(4)) { + var jobs = java.util.stream.IntStream.range(0, 20).mapToObj(i -> (java.util.concurrent.Callable>) + () -> { + var pending = ledger.record(e, player, occurrence); + return pending.stream().filter(threshold -> { + try { return ledger.reserve(e, player, threshold); } + catch (IOException failure) { throw new java.io.UncheckedIOException(failure); } + }).toList(); + }).toList(); + int awarded = 0; + for (var result : pool.invokeAll(jobs)) awarded += result.get().size(); + assertEquals(1, awarded); assertEquals(1, ledger.progress(e, player).votes()); + } + } + @Test void immutableContractIsSealedAcrossPlayersAndDisplayRenames() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); + ledger.record(e, player, UUID.randomUUID()); + assertEquals(1, ledger.progress(event("a", "Renamed", 200), player).votes()); + assertThrows(IOException.class, () -> ledger.record(event("a", "A", 300), UUID.randomUUID(), UUID.randomUUID())); + assertThrows(IOException.class, () -> ledger.progress(event("a", "A", 300), player)); + } + @Test void malformedStateFailsClosedWithoutResettingOrRewards() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); + ledger.record(e, player, UUID.randomUUID()); + Path file = root.resolve(e.fileId() + "-" + player + ".properties"); + String original = Files.readString(file); + for (String corruption : List.of("", "seen=\nfingerprint=bad\n", original + "seen=\n", original + "unknown=value\n")) { + Files.writeString(file, corruption); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(e, player, UUID.randomUUID())); + assertEquals(corruption, Files.readString(file)); + } + } + @Test void missingReachedAwardFailsClosedForReadsWritesAndAcknowledgements() throws Exception { + var e = event("a", "A", 200); var ledger = new DateVoteLedger(root); + ledger.record(e, player, UUID.randomUUID()); assertTrue(ledger.reserve(e, player, 1)); ledger.submitted(e, player, 1); + Path file = root.resolve(e.fileId() + "-" + player + ".properties"); + String broken = Files.readString(file).replace("award.1=SUBMITTED\n", ""); + Files.writeString(file, broken); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(e, player, UUID.randomUUID())); + assertThrows(IOException.class, () -> new DateVoteLedger(root).progress(e, player)); + assertThrows(IOException.class, () -> new DateVoteLedger(root).submitted(e, player, 1)); + assertEquals(broken, Files.readString(file)); + } + @Test void caseDistinctIdsHaveDistinctPortableFilesAndIndependentProgress() throws Exception { + var first = event("Festival", "First", 200); var second = event("festival", "Second", 200); + var ledger = new DateVoteLedger(root); UUID occurrence = UUID.randomUUID(); + assertNotEquals(first.fileId().toLowerCase(), second.fileId().toLowerCase()); + assertEquals(List.of(1), ledger.record(first, player, occurrence)); + assertEquals(List.of(1), ledger.record(second, player, occurrence)); + assertEquals(1, new DateVoteLedger(root).progress(first, player).votes()); + assertEquals(1, new DateVoteLedger(root).progress(second, player).votes()); + } + @Test void delimiterContainingFilterCannotChangeASealedContract() throws Exception { + var a = new DateVoteEvent("event", "A", true, 100, 200, "UTC", List.of(1), Set.of("a", "b"), ""); + var b = new DateVoteEvent("event", "B", true, 100, 200, "UTC", List.of(1), Set.of("a, b"), ""); + var ledger = new DateVoteLedger(root); ledger.record(a, player, UUID.randomUUID()); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(b, player, UUID.randomUUID())); + } + @Test void truncatedDefinitionsCannotResetSealedContractsForNewPlayers() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); + ledger.record(e, player, UUID.randomUUID()); + Path definitions = root.resolve("definitions.properties"); Files.writeString(definitions, ""); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(event("a", "A", 300), UUID.randomUUID(), UUID.randomUUID())); + assertThrows(IOException.class, () -> new DateVoteLedger(root).progress(e, player)); + assertEquals("", Files.readString(definitions)); + } + @Test void missingDefinitionsCannotResealSurvivingHistoryForANewPlayer() throws Exception { + var ledger = new DateVoteLedger(root); var original = event("a", "A", 200); + ledger.record(original, player, UUID.randomUUID()); + Files.delete(root.resolve("definitions.properties")); + var changed = event("a", "A", 300); UUID newcomer = UUID.randomUUID(); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(changed, newcomer, UUID.randomUUID())); + assertThrows(IOException.class, () -> new DateVoteLedger(root).progress(changed, newcomer)); + assertFalse(Files.exists(root.resolve("definitions.properties"))); + assertFalse(Files.exists(root.resolve(changed.fileId() + "-" + newcomer + ".properties"))); + } + @Test void partialDefinitionsCannotResealAnOmittedHistoricalEvent() throws Exception { + var ledger = new DateVoteLedger(root); var first = event("a", "A", 200); var second = event("b", "B", 200); + ledger.record(first, player, UUID.randomUUID()); ledger.record(second, player, UUID.randomUUID()); + Path definitions = root.resolve("definitions.properties"); + String partial = Files.readString(definitions).replace("a=" + first.fingerprint() + "\n", ""); + Files.writeString(definitions, partial); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(event("a", "A", 300), UUID.randomUUID(), UUID.randomUUID())); + assertThrows(IOException.class, () -> new DateVoteLedger(root).progress(first, UUID.randomUUID())); + assertEquals(partial, Files.readString(definitions)); + assertEquals(1, new DateVoteLedger(root).progress(second, player).votes()); + } + @Test void newIdsWithNoHistoryRemainUsableAlongsideSealedEvents() throws Exception { + var ledger = new DateVoteLedger(root); ledger.record(event("a", "A", 200), player, UUID.randomUUID()); + assertEquals(List.of(1), new DateVoteLedger(root).record(event("new", "New", 300), player, UUID.randomUUID())); + } + @Test void unsafeDirectoryAndUnavailableStorageNeverAdmitAwards() throws Exception { + Path file = root.resolve("file"); Files.writeString(file, "keep"); + assertThrows(IOException.class, () -> new DateVoteLedger(file).record(event("a", "A", 200), player, UUID.randomUUID())); + Path link = root.resolve("link"); Files.createSymbolicLink(link, root); + assertThrows(IOException.class, () -> new DateVoteLedger(link).record(event("a", "A", 200), player, UUID.randomUUID())); + assertEquals("keep", Files.readString(file)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestoneConfigTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestoneConfigTest.java new file mode 100644 index 0000000000..c63312a7e9 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestoneConfigTest.java @@ -0,0 +1,77 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import java.util.stream.Stream; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +class DateVoteMilestoneConfigTest { + @Test void documentedCompleteOwnerExampleLoadsThroughTheProductionParser() throws Exception { + var path = java.nio.file.Path.of(System.getProperty("basedir")).getParent().resolve("docs/date-vote-milestones.md"); + String guide = java.nio.file.Files.readString(path); + String yaml = guide.split("```yaml\\n", 2)[1].split("```", 2)[0]; + var config = new YamlConfiguration(); config.loadFromString(yaml); + var event = DateVoteMilestones.parse("october_2026", config.getConfigurationSection("DateVoteMilestones.october_2026")); + assertTrue(event.enabled()); assertEquals(List.of(10, 25), event.thresholds()); + assertEquals("", event.accountingServer()); + assertEquals("give %player% diamond 1", config.getStringList("DateVoteMilestones.october_2026.Milestones.TwentyFiveVotes.Rewards.Commands").getFirst()); + } + private YamlConfiguration complete(String milestones, String newline) throws Exception { + String yaml = """ + VoteMilestones: + unrelated: + At: 75 + Rewards: {Commands: ['say literal At: 3']} + DateVoteMilestones: + festival: + Enabled: true + DisplayName: 'Fête 🎉 — At: 99' + Start: '2026-10-01T00:00:00' + End: '2026-11-01T00:00:00' + Timezone: UTC + Milestones: + """ + milestones.indent(6); + var config = new YamlConfiguration(); config.loadFromString(yaml.replace("\n", newline)); + return config; + } + @TestFactory Stream fullYamlAtRepresentationsRetainTheSameAccountingContract() { + return Stream.of("1", "'1'", "[0, 1]", "['0..1 step 1']", "['1..0']").flatMap(at -> + Stream.of("\n", "\r\n").map(newline -> DynamicTest.dynamicTest(at + " line=" + newline.length(), () -> { + var named = complete("first:\n At: " + at + "\n Rewards: {Messages: {Player: 'At: 99'}}\n", newline); + var legacy = complete("'1':\n Rewards: {Messages: {Player: 'At: 99'}}\n", newline); + var a = DateVoteMilestones.parse("festival", named.getConfigurationSection("DateVoteMilestones.festival")); + var b = DateVoteMilestones.parse("festival", legacy.getConfigurationSection("DateVoteMilestones.festival")); + assertEquals(List.of(1), a.thresholds()); assertEquals(b.fingerprint(), a.fingerprint()); + assertEquals(a, DateVoteMilestones.parse("festival", named.getConfigurationSection("DateVoteMilestones.festival"))); + assertEquals(75, named.getInt("VoteMilestones.unrelated.At")); + assertEquals("At: 99", named.getString("DateVoteMilestones.festival.Milestones.first.Rewards.Messages.Player")); + }))); + } + @Test void rangesEveryAndAtUseABoundedUnion() throws Exception { + var config = complete("bonus:\n At: [0, '10..30 step 10', 128]\n Every: 128\n Rewards: {}\n", "\n"); + var event = DateVoteMilestones.parse("festival", config.getConfigurationSection("DateVoteMilestones.festival")); + assertEquals(36, event.thresholds().size()); assertTrue(event.thresholds().containsAll(List.of(1, 10, 20, 30, 128, 4096))); + } + @TestFactory Stream invalidAtCannotCoerceOrTruncateIntoAnAward() { + return Stream.of("true", "1.5", "[]", "[1, nope]", "[1, null]", "[[1]]", "{At: 1}", + "4097", "99999999999999999999", "'1..4096'", "'1..2 step 0'", "'1..2 step 1 trailing'", + "'1..2..'", "'١'", "'1\\n2'").map(at -> DynamicTest.dynamicTest(at, () -> { + var config = complete("bad:\n At: " + at + "\n Rewards: {}\n", "\n"); + assertThrows(IllegalArgumentException.class, () -> DateVoteMilestones.parse("festival", + config.getConfigurationSection("DateVoteMilestones.festival"))); + })); + } + @TestFactory Stream ambiguousOrUnsupportedEntriesFailClosed() { + return Stream.of("first:\n Every: 1\n Rewards: {}\n", "first:\n Enabled: 'false'\n At: 1\n Rewards: {}\n", + "first:\n At: 1\n Total: AllTime\n Rewards: {}\n", "first:\n At: 1\n Rewards: {}\nsecond:\n At: 1\n Rewards: {}\n", + "'01':\n Rewards: {}\n", "first:\n At: 1\n Rewards: 'Commands: []'\n") + .map(yaml -> DynamicTest.dynamicTest(yaml.lines().findFirst().orElseThrow(), () -> { + var config = complete(yaml, "\n"); + assertThrows(IllegalArgumentException.class, () -> DateVoteMilestones.parse("festival", + config.getConfigurationSection("DateVoteMilestones.festival"))); + })); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java new file mode 100644 index 0000000000..c1a576845d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java @@ -0,0 +1,569 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.VotingPluginUser; +class DateVoteMilestonesTest { + @TempDir Path root; + final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + final VotingPluginUser user = mock(VotingPluginUser.class); + final YamlConfiguration config = new YamlConfiguration(); + final UUID player = UUID.randomUUID(); + final long time = java.time.Instant.parse("2026-10-15T00:00:00Z").toEpochMilli(); + private void event(String id, String owner) { + String key = "DateVoteMilestones." + id + "."; + config.set(key + "Enabled", true); config.set(key + "DisplayName", id); + config.set(key + "Start", "2026-10-01T00:00:00"); config.set(key + "End", "2026-11-01T00:00:00"); + config.set(key + "Timezone", "UTC"); config.set(key + "AccountingServer", owner); + config.set(key + "Milestones.1.Rewards.Messages.Player", "Thanks"); + } + private DateVoteMilestones manager() { + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.getBungeeSettings().getServer()).thenReturn("owner"); + when(user.getJavaUUID()).thenReturn(player); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + var m = new DateVoteMilestones(plugin); when(plugin.getDateVoteMilestones()).thenReturn(m); m.reload(); return m; + } + @Test void bundledDefaultExampleParsesAndNeverEnablesDateRewards() throws Exception { + var defaults = new YamlConfiguration(); + try (var resource = DateVoteMilestonesTest.class.getResourceAsStream("/SpecialRewards.yml")) { + assertNotNull(resource, "the shipped default resource must be tested"); + defaults.load(new java.io.InputStreamReader(resource, java.nio.charset.StandardCharsets.UTF_8)); + } + var events = defaults.getConfigurationSection("DateVoteMilestones"); + assertNotNull(events); assertEquals(java.util.Set.of("example_event"), events.getKeys(false)); + var definition = DateVoteMilestones.parse("example_event", events.getConfigurationSection("example_event")); + assertFalse(definition.enabled()); assertEquals("UTC", definition.timezone()); + assertEquals(java.util.List.of(10, 25), definition.thresholds()); + assertEquals(java.time.Instant.parse("2026-10-01T00:00:00Z").toEpochMilli(), definition.start()); + assertEquals(java.time.Instant.parse("2026-11-01T00:00:00Z").toEpochMilli(), definition.end()); + assertTrue(definition.sites().isEmpty()); assertEquals("", definition.accountingServer()); + assertFalse(definition.matches(definition.start(), "a", true, false, "owner")); + assertFalse(definition.matches(time, "a", true, false, "owner")); + config.loadFromString(defaults.saveToString()); + var manager = manager(); + for (int index = 0; index < 25; index++) + assertTrue(manager.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + assertFalse(Files.exists(root.resolve("date-vote-milestones")), "disabled defaults must not create accounting state"); + assertTrue(config.getBoolean("EnableMonthlyAwards"), "existing default awards stay intact"); + assertFalse(config.getBoolean("EnableWeeklyAwards")); + } + + @TestFactory + java.util.stream.Stream standaloneUnsetOwnerCountsLocallyAndRetainsDeduplication() { + return java.util.Arrays.asList(null, "", " ").stream().map(owner -> + DynamicTest.dynamicTest(owner == null ? "omitted" : owner.isEmpty() ? "empty" : "blank", () -> { + Path factoryRoot = root; + root = Files.createTempDirectory(factoryRoot, "accounting-owner-"); + reset(plugin, user); + try { + event("october", owner); + var manager = manager(); UUID occurrence = UUID.randomUUID(); + assertTrue(manager.accepted(user, "a", occurrence, time, true, false, false, false, false)); + assertTrue(manager.accepted(user, "a", occurrence, time, true, false, false, false, false)); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + assertEquals(1, new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).votes()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(rewardPath("october")), any()); + } finally { root = factoryRoot; } + })); + } + + @TestFactory + java.util.stream.Stream proxyUnsetOwnerStopsNewAccountingButRetainsExistingRewardHandles() { + return java.util.Arrays.asList(null, "", " ").stream().map(owner -> + DynamicTest.dynamicTest(owner == null ? "omitted" : owner.isEmpty() ? "empty" : "blank", () -> { + Path factoryRoot = root; + root = Files.createTempDirectory(factoryRoot, "accounting-owner-"); + reset(plugin, user); + try { + event("october", owner); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + var manager = manager(); + assertTrue(manager.accepted(user, "a", UUID.randomUUID(), time, true, true, true, false, false)); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + var handle = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin).addDirectlyDefinedRewards(handle.capture()); + assertEquals(rewardPath("october"), handle.getValue().getPath()); + assertEquals("Thanks", handle.getValue().getFileData().getString(handle.getValue().getPath() + ".Messages.Player")); + } finally { root = factoryRoot; } + })); + } + + @Test void proxyAccountingOwnerRequiresExactCaseAndValidSiblingsStillCount() throws Exception { + event("without-owner", ""); event("october", "owner"); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + var manager = manager(); UUID occurrence = UUID.randomUUID(); + when(plugin.getBungeeSettings().getServer()).thenReturn("Owner"); + assertTrue(manager.accepted(user, "a", occurrence, time, true, true, true, false, false)); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + when(plugin.getBungeeSettings().getServer()).thenReturn("other"); + assertTrue(manager.accepted(user, "a", occurrence, time, true, true, true, false, false)); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + when(plugin.getBungeeSettings().getServer()).thenReturn("owner"); + assertTrue(manager.accepted(user, "a", occurrence, time, true, true, true, false, false)); + assertTrue(manager.accepted(user, "a", occurrence, time, true, true, true, false, false)); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + assertEquals(1, new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).votes()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(rewardPath("october")), any()); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), any(), eq(rewardPath("without-owner")), any()); + } + + @Test void failedOccurrencePublicationIsReportedWithoutCountingOrRepeatingAnAward() throws Exception { + event("october", "owner"); var manager = manager(); + assertTrue(manager.accepted(user, "a", UUID.randomUUID(), time, true, true, true, false, false)); + UUID retry = UUID.randomUUID(); + try (var publication = mockStatic(com.bencodez.votingplugin.util.DurableFiles.class)) { + publication.when(() -> com.bencodez.votingplugin.util.DurableFiles.publishStagedFile(any(Path.class), any(Path.class))) + .thenThrow(new java.io.IOException("fixture occurrence unavailable")); + assertFalse(manager.accepted(user, "a", retry, time + 1, true, true, true, false, false)); + } + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + assertEquals(1, new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).votes()); + assertTrue(manager.accepted(user, "a", retry, time + 1, true, true, true, false, false)); + assertTrue(manager.accepted(user, "a", retry, time + 1, true, true, true, false, false)); + assertEquals(2, new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).votes()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), anyString(), any()); + } + @Test void disabledRewardProcessingCountsWithoutSubmittingOrLosingThePendingThreshold() throws Exception { + event("october", ""); var m = manager(); + when(plugin.getOptions().isProcessRewards()).thenReturn(false); + UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(1, progress.votes()); assertEquals(java.util.Set.of(1), progress.deferredAwards()); + assertTrue(progress.reservedAwards().isEmpty()); assertTrue(progress.submittedAwards().isEmpty()); + } + @Test void confirmedDeferredAwardsResumeOnceAfterRestartAndRewardProcessingIsEnabled() throws Exception { + event("october", ""); var m = manager(); + when(plugin.getOptions().isProcessRewards()).thenReturn(false); + UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + m = manager(); // Fresh manager and on-disk ledger; manager enables processing. + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + m.accepted(user, "a", UUID.randomUUID(), time + 1, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(rewardPath("october")), any()); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(2, progress.votes()); assertEquals(java.util.Set.of(1), progress.submittedAwards()); + assertTrue(progress.deferredAwards().isEmpty()); + } + private com.bencodez.votingplugin.core.datemilestones.DateVoteEvent deferredPair(DateVoteMilestones manager) { + when(plugin.getOptions().isProcessRewards()).thenReturn(false); + manager.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + manager.accepted(user, "a", UUID.randomUUID(), time + 1, true, false, false, false, false); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + return DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + } + @Test void earlierSubmissionFailureDoesNotReserveAnUnattemptedLaterThreshold() throws Exception { + event("october", ""); config.set("DateVoteMilestones.october.Milestones.2.Rewards.Messages.Player", "Second"); + var manager = manager(); var definition = deferredPair(manager); + var first = DateVoteMilestones.path(definition, 1); var second = DateVoteMilestones.path(definition, 2); + var handler = plugin.getRewardHandler(); + doThrow(new IllegalStateException("fixture uncertain submission")).when(handler) + .giveReward(eq(user), any(), eq(first), any()); + UUID delivery = UUID.randomUUID(); + manager.accepted(user, "a", delivery, time + 2, true, false, false, false, false); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(java.util.Set.of(1), progress.reservedAwards()); + assertEquals(java.util.Set.of(2), progress.deferredAwards()); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), any(), eq(second), any()); + manager = manager(); // Restart and replay must retain the earlier uncertain reservation. + manager.accepted(user, "a", delivery, time + 2, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(first), any()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(second), any()); + progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(3, progress.votes()); assertEquals(java.util.Set.of(2), progress.submittedAwards()); + } + @Test void acknowledgementFailureDoesNotReserveAnUnattemptedLaterThreshold() throws Exception { + event("october", ""); config.set("DateVoteMilestones.october.Milestones.2.Rewards.Messages.Player", "Second"); + var manager = manager(); var definition = deferredPair(manager); + try (var publication = mockStatic(com.bencodez.votingplugin.util.DurableFiles.class)) { + publication.when(() -> com.bencodez.votingplugin.util.DurableFiles.publishStagedFile(any(Path.class), any(Path.class))) + .thenAnswer(invocation -> { + Path staged = invocation.getArgument(0); + if (Files.readString(staged).contains("award.1=SUBMITTED")) throw new java.io.IOException("fixture acknowledgement failure"); + return invocation.callRealMethod(); + }); + manager.accepted(user, "a", UUID.randomUUID(), time + 2, true, false, false, false, false); + } + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(java.util.Set.of(1), progress.reservedAwards()); assertEquals(java.util.Set.of(2), progress.deferredAwards()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 1)), any()); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 2)), any()); + manager = manager(); manager.accepted(user, "a", UUID.randomUUID(), time + 3, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 1)), any()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 2)), any()); + } + @Test void turningRewardsOffBetweenSubmissionsKeepsTheLaterThresholdDeferred() throws Exception { + event("october", ""); config.set("DateVoteMilestones.october.Milestones.2.Rewards.Messages.Player", "Second"); + var manager = manager(); var definition = deferredPair(manager); + var handler = plugin.getRewardHandler(); + doAnswer(invocation -> { when(plugin.getOptions().isProcessRewards()).thenReturn(false); return null; }) + .when(handler).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 1)), any()); + manager.accepted(user, "a", UUID.randomUUID(), time + 2, true, false, false, false, false); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(java.util.Set.of(1), progress.submittedAwards()); assertEquals(java.util.Set.of(2), progress.deferredAwards()); + assertTrue(progress.reservedAwards().isEmpty()); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 2)), any()); + manager = manager(); manager.accepted(user, "a", UUID.randomUUID(), time + 3, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 2)), any()); + } + @Test void invalidTimezoneStopsAccountingButPreservesQueuedRewardResolutionOnReload() { + event("october", ""); var m = manager(); String alias = rewardPath("october"); + config.set("DateVoteMilestones.october.Timezone", "No/SuchZone"); clearInvocations(plugin); + m.reload(); + var handle = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin).addDirectlyDefinedRewards(handle.capture()); + assertEquals(alias, handle.getValue().getPath()); + assertEquals("Thanks", handle.getValue().getFileData().getString(alias + ".Messages.Player")); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + @Test void accountingInvalidAtStartupStillRegistersExistingCanonicalRewardSections() { + event("october", ""); String alias = rewardPath("october"); + config.set("DateVoteMilestones.october.End", "not-a-date"); var m = manager(); + var handle = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin).addDirectlyDefinedRewards(handle.capture()); + assertEquals(alias, handle.getValue().getPath()); + assertEquals("Thanks", handle.getValue().getFileData().getString(alias + ".Messages.Player")); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + @Test void configuredRewardsUseExistingHandlerOnceAcrossReplayRestartAndRename() { + event("october", ""); var m = manager(); UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + config.set("DateVoteMilestones.october.DisplayName", "Renamed"); + m = manager(); m.accepted(user, "a", occurrence, time, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(rewardPath("october")), any()); + verify(user, never()).addTotal(); + verify(user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + } + private String rewardPath(String id) { + return DateVoteMilestones.path(DateVoteMilestones.parse(id, config.getConfigurationSection("DateVoteMilestones." + id)), 1); + } + @Test void legacyToNamedReloadAndRestartKeepProgressAwardsAndRewardAliases() throws Exception { + event("october", ""); + config.set("DateVoteMilestones.october.Milestones.2.Rewards.Messages.Player", "Second"); + var original = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + var m = manager(); UUID first = UUID.randomUUID(); + assertTrue(m.accepted(user, "a", first, time, true, false, false, false, false)); + var rewards = config.getConfigurationSection("DateVoteMilestones.october.Milestones.1.Rewards").getValues(true); + config.set("DateVoteMilestones.october.Milestones", null); + config.set("DateVoteMilestones.october.Milestones.first.At", 1); + config.createSection("DateVoteMilestones.october.Milestones.first.Rewards", rewards); + config.set("DateVoteMilestones.october.Milestones.second.At", 2); + config.set("DateVoteMilestones.october.Milestones.second.Rewards.Messages.Player", "Second"); + var renamed = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + assertEquals(original.fingerprint(), renamed.fingerprint()); + m.reload(); m = manager(); + assertTrue(m.accepted(user, "a", first, time, true, false, false, false, false)); + assertTrue(m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(renamed, player); + assertEquals(2, progress.votes()); assertEquals(java.util.Set.of(1, 2), progress.submittedAwards()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(original, 1)), any()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(original, 2)), any()); + var handles = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin, atLeastOnce()).addDirectlyDefinedRewards(handles.capture()); + var firstHandle = handles.getAllValues().stream().filter(h -> h.getPath().equals(DateVoteMilestones.path(original, 1))) + .reduce((a, b) -> b).orElseThrow(); + assertEquals("Thanks", firstHandle.getFileData().getString(firstHandle.getPath() + ".Messages.Player")); + var configurationWriter = plugin.getSpecialRewardsConfig(); + doAnswer(call -> { config.set(call.getArgument(0), call.getArgument(1)); return null; }) + .when(configurationWriter).setValue(anyString(), any()); + firstHandle.setData(firstHandle.getPath() + ".Messages.Player", "Updated"); + assertEquals("Updated", config.getString("DateVoteMilestones.october.Milestones.first.Rewards.Messages.Player")); + assertNull(config.get("DateVoteMilestonesRuntime")); + } + @Test void disabledNamedMilestoneRetainsQueuedRewardsAndDefersOnlyUnattemptedAwards() throws Exception { + event("october", ""); config.set("DateVoteMilestones.october.Milestones", null); + config.set("DateVoteMilestones.october.Milestones.first.At", 1); + config.set("DateVoteMilestones.october.Milestones.first.Enabled", false); + config.set("DateVoteMilestones.october.Milestones.first.Rewards.Messages.Player", "Thanks"); + var m = manager(); UUID occurrence = UUID.randomUUID(); + assertTrue(m.accepted(user, "a", occurrence, time, true, false, false, false, false)); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + assertEquals(java.util.Set.of(1), new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).deferredAwards()); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + var handle = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin).addDirectlyDefinedRewards(handle.capture()); + assertEquals("Thanks", handle.getValue().getFileData().getString(handle.getValue().getPath() + ".Messages.Player")); + config.set("DateVoteMilestones.october.Milestones.first.Enabled", true); m = manager(); + assertTrue(m.accepted(user, "a", occurrence, time, true, false, false, false, false)); + m = manager(); m.accepted(user, "a", occurrence, time, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 1)), any()); + assertEquals(1, new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).votes()); + } + @Test void preReloadRegistryHandlesResolveRegroupedRewardsAndEditsBeforeRegistryReplacement() throws Exception { + event("october", ""); + config.set("DateVoteMilestones.october.Milestones.2.Rewards.Messages.Player", "Second"); + var m = manager(); + var handles = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin, times(2)).addDirectlyDefinedRewards(handles.capture()); + var registry = new com.bencodez.advancedcore.api.rewards.RewardRegistry(plugin); + handles.getAllValues().forEach(registry::addDirectlyDefined); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + var oldFirst = registry.getDirectlyDefined(DateVoteMilestones.path(definition, 1)); + var oldSecond = registry.getDirectlyDefined(DateVoteMilestones.path(definition, 2)); + config.set("DateVoteMilestones.october.Milestones", null); + config.set("DateVoteMilestones.october.Milestones.grouped.At", java.util.List.of(1, 2)); + config.set("DateVoteMilestones.october.Milestones.grouped.Rewards.Messages.Player", "Grouped"); + // Real reload ordering: YAML is replaced while the old definitions/registry still serve votes. + var rewardHandler = plugin.getRewardHandler(); + doAnswer(call -> { + String alias = call.getArgument(2); + assertEquals("Grouped", registry.getDirectlyDefined(alias).getFileData().getString(alias + ".Messages.Player")); + return null; + }).when(rewardHandler).giveReward(eq(user), any(), anyString(), any()); + assertTrue(m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + clearInvocations(plugin); m.reload(); + verify(plugin, times(2)).addDirectlyDefinedRewards(handles.capture()); + handles.getAllValues().forEach(registry::addDirectlyDefined); + assertSame(oldFirst, registry.getDirectlyDefined(oldFirst.getPath())); + assertSame(oldSecond, registry.getDirectlyDefined(oldSecond.getPath())); + assertTrue(m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + var writer = plugin.getSpecialRewardsConfig(); + doAnswer(call -> { config.set(call.getArgument(0), call.getArgument(1)); return null; }) + .when(writer).setValue(anyString(), any()); + oldFirst.setData(oldFirst.getPath() + ".Messages.Player", "Edited"); + assertEquals("Edited", oldSecond.getFileData().getString(oldSecond.getPath() + ".Messages.Player")); + oldSecond.createSection(oldSecond.getPath() + ".Commands"); + verify(writer).createSection("DateVoteMilestones.october.Milestones.grouped.Rewards.Commands"); + assertEquals(java.util.Set.of(1, 2), new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).submittedAwards()); + verify(plugin.getRewardHandler(), times(2)).giveReward(eq(user), any(), anyString(), any()); + } + @Test void malformedNamedMilestoneDisablesOnlyItsEventAndKeepsIndependentQueuedHandle() { + event("october", ""); event("broken", ""); + config.set("DateVoteMilestones.broken.Milestones.invalid.At", java.util.List.of(1, "garbage")); + config.set("DateVoteMilestones.broken.Milestones.invalid.Rewards.Messages.Player", "Invalid"); + var m = manager(); m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(rewardPath("october")), any()); + var handles = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin, times(2)).addDirectlyDefinedRewards(handles.capture()); + assertTrue(handles.getAllValues().stream().anyMatch(h -> h.getPath().contains(com.bencodez.votingplugin.core.datemilestones.DateVoteEvent.fileId("broken")))); + } + @Test void disablingStopsAccountingButKeepsQueuedRewardReferencesResolvable() { + event("october", ""); var m = manager(); + var handles = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin).addDirectlyDefinedRewards(handles.capture()); + String stable = handles.getValue().getPath(); + config.set("DateVoteMilestones.october.Enabled", false); clearInvocations(plugin); + m.reload(); verify(plugin).addDirectlyDefinedRewards(handles.capture()); + assertEquals(stable, handles.getValue().getPath()); + assertEquals("Thanks", handles.getValue().getFileData().getString(stable + ".Messages.Player")); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + @Test void caseDistinctEventsHaveDistinctRewardRegistryHandlesAndActualConfigurations() { + event("Festival", ""); event("festival", ""); + config.set("DateVoteMilestones.Festival.Milestones.1.Rewards.Messages.Player", "First"); + config.set("DateVoteMilestones.festival.Milestones.1.Rewards.Messages.Player", "Second"); + var m = manager(); + var handles = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin, times(2)).addDirectlyDefinedRewards(handles.capture()); + var registry = new com.bencodez.advancedcore.api.rewards.RewardRegistry(plugin); + handles.getAllValues().forEach(registry::addDirectlyDefined); + assertEquals(2, registry.getDirectlyDefinedRewards().size()); + var first = registry.getDirectlyDefined(rewardPath("Festival")); + var second = registry.getDirectlyDefined(rewardPath("festival")); + assertNotSame(first, second); + assertEquals("First", first.getFileData().getString(first.getPath() + ".Messages.Player")); + assertEquals("Second", second.getFileData().getString(second.getPath() + ".Messages.Player")); + UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), eq(first.getPath()), any()); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), eq(second.getPath()), any()); + } + @Test void fakeLegacyTargetedAndNonOwnerDeliveriesNeverCreateProgress() { + event("october", "owner"); var m = manager(); + m.accepted(user, "a", UUID.randomUUID(), time, false, true, true, false, false); + m.accepted(user, "a", UUID.randomUUID(), time, true, true, false, false, false); + m.accepted(user, "a", UUID.randomUUID(), time, true, true, true, true, false); + when(plugin.getBungeeSettings().getServer()).thenReturn("other"); + m.accepted(user, "a", UUID.randomUUID(), time, true, true, true, false, false); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + @Test void configuredNetworkRejectsLocalVoteWithoutCanonicalNetworkMetadata() { + event("october", "owner"); var m = manager(); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + @Test void overlappingWindowsUseSameOriginalOccurrenceIndependently() { + event("october", "owner"); event("festival", "owner"); var m = manager(); UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, true, true, false, false); + verify(plugin.getRewardHandler(), times(2)).giveReward(eq(user), any(), anyString(), any()); + } + @Test void failedRewardAdmissionStaysReservedAndNeverAutomaticallyRepeats() throws Exception { + event("october", ""); var m = manager(); + var handler = plugin.getRewardHandler(); + doThrow(new IllegalStateException("fixture submission failure")).when(handler).giveReward(any(), any(), anyString(), any()); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + m = manager(); m.accepted(user, "a", UUID.randomUUID(), time + 1, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(any(), any(), anyString(), any()); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(2, progress.votes()); assertEquals(java.util.Set.of(1), progress.reservedAwards()); + } + @Test void malformedDefinitionIsDisabledWithoutBreakingOtherDefinitions() { + event("broken", ""); event("valid", ""); config.set("DateVoteMilestones.broken.Timezone", "No/SuchZone"); + var m = manager(); m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), eq(rewardPath("valid")), any()); + } + @Test void milestoneMustHaveCanonicalThresholdAndActualRewardSection() { + event("a", ""); config.set("DateVoteMilestones.a.Milestones.01.Rewards.Messages.Player", "bad"); + assertThrows(IllegalArgumentException.class, () -> DateVoteMilestones.parse("a", config.getConfigurationSection("DateVoteMilestones.a"))); + } + @Test void progressIsWorkerScheduledBoundedReadOnlyAndReloadFenced() { + event("october", ""); var m = manager(); + UUID storageId = UUID.randomUUID(); + when(user.getJavaUUID()).thenReturn(storageId); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + clearInvocations(plugin.getRewardHandler()); + var accountingBefore = accountingFiles(root.resolve("date-vote-milestones")); + var playerEntity = mock(org.bukkit.entity.Player.class); + when(playerEntity.getUniqueId()).thenReturn(player); + when(playerEntity.getName()).thenReturn("Player"); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(player, "Player")).thenReturn(user); + when(playerEntity.isOnline()).thenReturn(true); + when(playerEntity.hasPermission(anyString())).thenReturn(true); + when(plugin.isEnabled()).thenReturn(true); + java.util.Queue worker = new java.util.ArrayDeque<>(), entity = new java.util.ArrayDeque<>(); + var displayed = new java.util.ArrayList(); + java.util.function.BiConsumer display + = (view, current) -> { assertTrue(current.getAsBoolean()); displayed.add(view); }; + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(timer); + doAnswer(invocation -> { worker.add(invocation.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + try (var scheduler = mockStatic(com.bencodez.votingplugin.util.BukkitCompletionScheduler.class)) { + scheduler.when(() -> com.bencodez.votingplugin.util.BukkitCompletionScheduler.run(eq(plugin), eq(playerEntity), + any(Runnable.class), any(Runnable.class), any(Runnable.class))).thenAnswer(invocation -> { + entity.add(invocation.getArgument(2)); return null; + }); + worker.add(() -> when(user.getJavaUUID()).thenReturn(storageId)); + when(user.getJavaUUID()).thenReturn(player); // Earlier ordered mutation has not yet run. + m.progress(playerEntity, display); entity.remove().run(); + for (int i = 0; i < 100; i++) { m.progress(playerEntity, display); entity.remove().run(); } + assertEquals(2, worker.size()); + verify(plugin, never()).getTimer(); + verify(plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(any(UUID.class), anyString()); + worker.remove().run(); // The prior mutation must precede the progress lookup. + assertTrue(displayed.isEmpty()); + assertEquals(accountingBefore, accountingFiles(root.resolve("date-vote-milestones")), + "Progress must preserve every accounting file; the lock file is coordination metadata"); + worker.remove().run(); assertEquals(1, entity.size()); + entity.remove().run(); assertEquals(1, displayed.size()); assertEquals(1, displayed.getFirst().events().getFirst().votes()); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + assertEquals(1, countPlayerRecords(root.resolve("date-vote-milestones"))); + assertEquals(accountingBefore, accountingFiles(root.resolve("date-vote-milestones"))); + clearInvocations(playerEntity); + displayed.clear(); m.progress(playerEntity, display); entity.remove().run(); worker.remove().run(); + m.reload(); entity.remove().run(); + assertTrue(displayed.isEmpty()); + assertEquals(accountingBefore, accountingFiles(root.resolve("date-vote-milestones"))); + } + } + @Test void malformedSiteFilterDisablesOnlyThatDefinitionAndPreservesQueuedRewardHandles() { + for (Object malformed : new Object[] {"SiteA", 42, java.util.Map.of("SiteA", true), java.util.List.of("SiteA", 42)}) { + event("badfilter", ""); event("validfilter", ""); + config.set("DateVoteMilestones.badfilter.VoteSites", malformed); + var manager = manager(); + assertThrows(IllegalArgumentException.class, () -> DateVoteMilestones.parse("badfilter", config.getConfigurationSection("DateVoteMilestones.badfilter"))); + var handles = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin, atLeastOnce()).addDirectlyDefinedRewards(handles.capture()); + assertTrue(handles.getAllValues().stream().anyMatch(h -> h.getPath().contains(com.bencodez.votingplugin.core.datemilestones.DateVoteEvent.fileId("badfilter")))); + manager.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), any(), contains(com.bencodez.votingplugin.core.datemilestones.DateVoteEvent.fileId("badfilter")), any()); + verify(plugin.getRewardHandler(), atLeastOnce()).giveReward(eq(user), any(), eq(rewardPath("validfilter")), any()); + } + } + @Test void absentEmptyAndExplicitStringSiteFiltersKeepTheirDocumentedScope() { + event("filters", ""); var section = config.getConfigurationSection("DateVoteMilestones.filters"); + assertTrue(DateVoteMilestones.parse("filters", section).matches(time, "a", true, false, "")); + section.set("VoteSites", java.util.List.of()); + assertTrue(DateVoteMilestones.parse("filters", section).matches(time, "b", true, false, "")); + section.set("VoteSites", java.util.List.of("a")); + var filtered = DateVoteMilestones.parse("filters", section); + assertTrue(filtered.matches(time, "a", true, false, "")); assertFalse(filtered.matches(time, "b", true, false, "")); + } + + private long countPlayerRecords(Path directory) { + try (var files = Files.list(directory)) { + return files.filter(p -> !p.getFileName().toString().equals("definitions.properties") + && !p.getFileName().toString().equals(".ledger.lock")).count(); + } catch (java.io.IOException failure) { throw new AssertionError(failure); } + } + private java.util.Map accountingFiles(Path directory) { + var result = new java.util.LinkedHashMap(); + try (var files = Files.list(directory)) { + for (var path : files.toList()) if (!path.getFileName().toString().equals(".ledger.lock")) + result.put(path.getFileName().toString(), Files.readString(path)); + } catch (java.io.IOException failure) { throw new AssertionError(failure); } + return result; + } + + @Test void ownerEventAccountingIsBoundedDeferredAndPreservesCanonicalDeduplication() throws Exception { + event("october", ""); var m = manager(); when(plugin.isEnabled()).thenReturn(true); + var queue = new java.util.ArrayDeque(); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(timer); + doAnswer(call -> { queue.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + UUID id = UUID.randomUUID(); + for (int i = 0; i < 65; i++) assertFalse(m.deferAccepted(user, "a", id, time, true, false, false, false, false)); + assertEquals(64, queue.size()); assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + while (!queue.isEmpty()) queue.remove().run(); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + assertEquals(1, new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).votes()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), anyString(), any()); + assertFalse(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + assertEquals(1, queue.size()); + } + @Test void deferredOwnerAccountingDoesNotCrossReloadOrDisableAndRejectsWithoutDisk() { + for (boolean reload : new boolean[] { false, true }) { + event("october", ""); var m = manager(); when(plugin.isEnabled()).thenReturn(true); + var queue = new java.util.ArrayDeque(); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(timer); + doAnswer(call -> { queue.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + assertFalse(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + if (reload) m.reload(); else when(plugin.isEnabled()).thenReturn(false); + queue.remove().run(); assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + doThrow(new java.util.concurrent.RejectedExecutionException("fixture retired")).when(timer).execute(any(Runnable.class)); + assertFalse(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + } + @Test void disabledUnmatchedFakeAndUnidentifiedProxyEventsNeedNoOwnerHandoff() { + event("october", ""); var m = manager(); + assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), time, false, false, false, false, false)); + assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, true, false, false, false)); + assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), 1L, true, false, false, false, false)); + config.set("DateVoteMilestones.october.Enabled", false); m.reload(); + assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + verify(plugin.getUserManager().getDataManager().getTimer(), never()).execute(any(Runnable.class)); + } + @Test void absentDateDefinitionsNeedNoOwnerThreadHandoffOrQuarantine() { + var m = manager(); assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + verify(plugin.getUserManager().getDataManager().getTimer(), never()).execute(any(Runnable.class)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteProgressSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteProgressSchedulingTest.java new file mode 100644 index 0000000000..3b9e8e608a --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteProgressSchedulingTest.java @@ -0,0 +1,143 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; +import com.bencodez.votingplugin.commands.gui.player.DateVoteEventsMenu; +import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot; + +class DateVoteProgressSchedulingTest { + @TempDir Path root; + final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + final VotingPluginUser user = mock(VotingPluginUser.class); + final Player player = mock(Player.class); + final ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + final ArrayDeque worker = new ArrayDeque<>(), entity = new ArrayDeque<>(), fallback = new ArrayDeque<>(); + final ArrayList shown = new ArrayList<>(); + final UUID playerId = UUID.randomUUID(), storageId = UUID.randomUUID(); + final long occurred = java.time.Instant.parse("2026-10-15T00:00:00Z").toEpochMilli(); + final YamlConfiguration config = new YamlConfiguration(); + + private DateVoteMilestones manager(String owner) throws Exception { + config.loadFromString(""" + DateVoteMilestones: + festival: + Enabled: true + DisplayName: 'Fête 🎉' + Start: '2026-10-01T00:00:00' + End: '2026-11-01T00:00:00' + Timezone: UTC + Milestones: + first: + At: 1 + Rewards: + Messages: {Player: 'Literal %player% {javascript:data}'} + Commands: ['say private reward arguments'] + """); + config.set("DateVoteMilestones.festival.AccountingServer", owner); + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getBungeeSettings().getServer()).thenReturn("local"); + when(player.getUniqueId()).thenReturn(playerId); when(player.getName()).thenReturn("Player"); + when(player.isOnline()).thenReturn(true); when(player.hasPermission(anyString())).thenReturn(true); + when(user.getJavaUUID()).thenReturn(storageId); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(playerId, "Player")).thenReturn(user); + when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(timer); + doAnswer(call -> { worker.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + var manager = new DateVoteMilestones(plugin); when(plugin.getDateVoteMilestones()).thenReturn(manager); manager.reload(); return manager; + } + private MockedStatic scheduling() { + var scheduler = mockStatic(BukkitCompletionScheduler.class); + scheduler.when(() -> BukkitCompletionScheduler.run(eq(plugin), eq(player), any(Runnable.class), any(Runnable.class), any(Runnable.class))) + .thenAnswer(call -> { entity.add(call.getArgument(2)); fallback.add(call.getArgument(3)); return null; }); + return scheduler; + } + private void request(DateVoteMilestones manager) { + manager.progress(player, (snapshot, current) -> { assertTrue(current.getAsBoolean()); shown.add(snapshot); }); + entity.remove().run(); worker.remove().run(); entity.remove().run(); + } + @Test void publicEntryPointOpensMenuOnlyAfterWorkerAndEntityHandoffWithoutSubmittingRewards() throws Exception { + var manager = manager(""); manager.accepted(user, "site", UUID.randomUUID(), occurred, true, false, false, false, false); + try (var scheduler = scheduling(); var menu = mockStatic(DateVoteEventsMenu.class)) { + manager.progress(player); assertTrue(worker.isEmpty()); entity.remove().run(); + verify(plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(any(UUID.class), anyString()); + menu.verifyNoInteractions(); worker.remove().run(); menu.verifyNoInteractions(); entity.remove().run(); + menu.verify(() -> DateVoteEventsMenu.open(eq(plugin), eq(player), argThat(snapshot -> + snapshot.events().size() == 1 && snapshot.events().getFirst().votes() == 1 + && snapshot.events().getFirst().deferred().contains(1) + && snapshot.events().getFirst().milestones().getFirst().rewardTypes().contains("Commands")), any())); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + } + @Test void remoteOwnerNeverReadsTheLocalLedgerOrFabricatesLocalProgress() throws Exception { + var manager = manager("remote"); when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + try (var scheduler = scheduling()) { request(manager); } + assertEquals(DateVoteProgressSnapshot.Availability.REMOTE, shown.getFirst().events().getFirst().availability()); + assertEquals("remote", shown.getFirst().events().getFirst().definition().accountingServer()); + verify(plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(any(UUID.class), anyString()); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + @Test void disabledEmptyAndUnavailableRemainDistinctAndReadsNeverCreateAccountingState() throws Exception { + var manager = manager(""); config.set("DateVoteMilestones.festival.Enabled", false); manager.reload(); + try (var scheduler = scheduling()) { + request(manager); assertEquals("disabled", shown.getFirst().events().getFirst().status(occurred)); + assertEquals(DateVoteProgressSnapshot.Availability.OWNED, shown.getFirst().events().getFirst().availability()); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + Files.createDirectory(root.resolve("date-vote-milestones")); Files.writeString(root.resolve("date-vote-milestones/definitions.properties"), ""); + request(manager); assertEquals(DateVoteProgressSnapshot.Availability.UNAVAILABLE, shown.get(1).events().getFirst().availability()); + config.set("DateVoteMilestones", null); manager.reload(); request(manager); assertTrue(shown.get(2).events().isEmpty()); + } + } + @Test void permissionLossOfflineReloadAndRetirementSuppressPlayerEffects() throws Exception { + var manager = manager(""); + try (var scheduler = scheduling()) { + when(player.hasPermission(anyString())).thenReturn(false); + manager.progress(player, (snapshot, current) -> shown.add(snapshot)); entity.remove().run(); assertTrue(worker.isEmpty()); + when(player.hasPermission(anyString())).thenReturn(true); + manager.progress(player, (snapshot, current) -> shown.add(snapshot)); entity.remove().run(); worker.remove().run(); + when(player.isOnline()).thenReturn(false); entity.remove().run(); assertTrue(shown.isEmpty()); + when(player.isOnline()).thenReturn(true); + manager.progress(player, (snapshot, current) -> shown.add(snapshot)); entity.remove().run(); worker.remove().run(); + manager.reload(); entity.remove().run(); assertTrue(shown.isEmpty()); + manager.progress(player, (snapshot, current) -> shown.add(snapshot)); entity.remove().run(); worker.remove().run(); + fallback.getLast().run(); assertTrue(shown.isEmpty()); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + } + @Test void managerReplacementInvalidatesAnAlreadyDeliveredMenuSnapshot() throws Exception { + var manager = manager(""); + var guards = new ArrayList(); + try (var scheduler = scheduling()) { + manager.progress(player, (snapshot, current) -> guards.add(current)); + entity.remove().run(); worker.remove().run(); entity.remove().run(); + assertEquals(1, guards.size()); + assertTrue(guards.getFirst().getAsBoolean()); + when(plugin.getDateVoteMilestones()).thenReturn(new DateVoteMilestones(plugin)); + assertFalse(guards.getFirst().getAsBoolean()); + } + } + @Test void rejectedStorageAdmissionReleasesTheBusyFenceAndReportsUnavailableOnPlayerThread() throws Exception { + var manager = manager(""); doThrow(new java.util.concurrent.RejectedExecutionException()).when(timer).execute(any(Runnable.class)); + try (var scheduler = scheduling()) { + manager.progress(player, (snapshot, current) -> shown.add(snapshot)); entity.remove().run(); + verify(player).sendMessage("Date voting progress is temporarily unavailable."); + doAnswer(call -> { worker.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + request(manager); assertEquals(1, shown.size()); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java index 597b077195..cdbe166fee 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java @@ -47,6 +47,7 @@ import com.bencodez.votingplugin.proxy.cache.ProxyTimedVoteCacheTable; import com.bencodez.votingplugin.proxy.cache.ProxyVoteCacheTable; import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler; +import com.bencodez.votingplugin.proxy.velocity.VelocityJsonVoteCache; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; /** @@ -1398,6 +1399,69 @@ private static DataNode timedVoteNode(VoteTimeQueue vote) { return new GsonDataNode(data); } + @Test + void occurrenceMetadataSurvivesBothJsonAdaptersAndCacheRestart() throws Exception { + for (boolean bungee : new boolean[] { true, false }) { + Path folder = tempDir.resolve(bungee ? "bungee" : "velocity"); + Files.createDirectories(folder); + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(folder.toFile()); + IVoteCache store = bungee ? new BungeeJsonVoteCache(plugin) + : new VelocityJsonVoteCache(folder.resolve("votecache.json").toFile()); + VoteCacheHandler writer = newHandler(store); + OfflineBungeeVote server = vote(UUID.randomUUID(), 456L); + server.setCanonicalOccurrenceTime(123L); + OfflineBungeeVote online = vote(UUID.randomUUID(), 457L); + online.setCanonicalOccurrenceTime(123L); + VoteTimeQueue timed = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 458L); + timed.setUuid("player-uuid"); timed.setCanonicalOccurrenceTime(123L); + timed.setMultiProxyOrigin("Primary"); + assertTrue(writer.addServerVoteDurably("server", server)); + assertTrue(writer.addOnlineVoteDurably("player-uuid", online)); + assertTrue(writer.addTimeVoteToCache(timed)); + IVoteCache recoveredStore = bungee ? new BungeeJsonVoteCache(plugin) + : new VelocityJsonVoteCache(folder.resolve("votecache.json").toFile()); + VoteCacheHandler restarted = newHandler(recoveredStore); + restarted.load(); + assertEquals(456L, restarted.getVotes("server").get(0).getTime()); + assertEquals(123L, restarted.getVotes("server").get(0).getCanonicalOccurrenceTime()); + assertEquals(457L, restarted.getOnlineVotes("player-uuid").get(0).getTime()); + assertEquals(123L, restarted.getOnlineVotes("player-uuid").get(0).getCanonicalOccurrenceTime()); + assertEquals(458L, restarted.getTimeChangeQueue().element().getTime()); + assertEquals(123L, restarted.getTimeChangeQueue().element().getCanonicalOccurrenceTime()); + assertEquals("Primary", restarted.getTimeChangeQueue().element().getMultiProxyOrigin()); + } + } + + @Test + void sqlRecoveryDecodesOccurrenceFromAllExistingOpaquePayloadColumns() throws Exception { + VoteCacheHandler recovered = newHandler(storage); + ProxyVoteCacheTable serverTable = mock(ProxyVoteCacheTable.class); + ProxyOnlineVoteCacheTable onlineTable = mock(ProxyOnlineVoteCacheTable.class); + ProxyTimedVoteCacheTable timedTable = mock(ProxyTimedVoteCacheTable.class); + String payload = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.store("v2//1//2//3//4//5//6//7//8", 123L); + UUID serverId = UUID.randomUUID(), onlineId = UUID.randomUUID(), timedId = UUID.randomUUID(); + when(serverTable.getAllVotesChecked()).thenReturn(List.of(new ProxyVoteCacheTable.VoteRow(1, + serverId.toString(), "player-uuid", "Player", "Service", 456L, true, false, false, + payload, false, false, "", "", false, "", "", "server"))); + when(onlineTable.getAllVotesChecked()).thenReturn(List.of(new ProxyOnlineVoteCacheTable.VoteRow(2, + onlineId.toString(), "player-uuid", "Player", "Service", 457L, true, false, false, + payload, false, false, "", "", false, "", ""))); + when(timedTable.getAllVotesChecked()).thenReturn(List.of(new ProxyTimedVoteCacheTable.TimedVoteRow(3, + "Player", "Service", 458L, timedId, "player-uuid", false, "", "", payload, false))); + setPrivateField(recovered, "useMySQL", true); + setPrivateField(recovered, "voteCacheTable", serverTable); + setPrivateField(recovered, "onlineVoteCacheTable", onlineTable); + setPrivateField(recovered, "timedVoteCacheTable", timedTable); + recovered.load(); + assertEquals(456L, recovered.getVotes("server").get(0).getTime()); + assertEquals(123L, recovered.getVotes("server").get(0).getCanonicalOccurrenceTime()); + assertEquals(457L, recovered.getOnlineVotes("player-uuid").get(0).getTime()); + assertEquals(123L, recovered.getOnlineVotes("player-uuid").get(0).getCanonicalOccurrenceTime()); + assertEquals(458L, recovered.getTimeChangeQueue().element().getTime()); + assertEquals(123L, recovered.getTimeChangeQueue().element().getCanonicalOccurrenceTime()); + } + private static VoteCacheHandler newHandler(IVoteCache storage) { return newHandler(storage, true); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteOccurrenceMetadataTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteOccurrenceMetadataTest.java new file mode 100644 index 0000000000..a30857e9a8 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteOccurrenceMetadataTest.java @@ -0,0 +1,63 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.proxy.OfflineBungeeVote; +import com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata; +import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; + +class VoteOccurrenceMetadataTest { + @SuppressWarnings("deprecation") + @Test void metadataPreservesLegacyAndVersionedTotalsAndCorrelation() { + UUID correlation = UUID.randomUUID(); + for (String payload : new String[] { + "1//2//3//4//5//999//6//7//8//" + correlation, + "v2//1//2//3//4//5//6//7//8//" + correlation }) { + String decorated = VoteOccurrenceMetadata.store(payload, 123L); + var original = VoteTotalsSnapshot.parseStorage(payload); + var restored = VoteTotalsSnapshot.parseStorage(decorated); + assertEquals(original.toStorageString(), restored.toStorageString()); + assertEquals(correlation, restored.getVoteUUID()); + assertEquals(123L, VoteOccurrenceMetadata.read(decorated)); + } + } + + @Test void cachedTotalsUpdatesAndCopiesKeepOccurrenceSeparateFromCooldownTime() { + var offline = new OfflineBungeeVote(UUID.randomUUID(), "Player", "uuid", "Service", 456L, true, ""); + offline.setCanonicalOccurrenceTime(123L); + offline.setText("v2//1//2//3//4//5//6//7//8"); + var copied = new OfflineBungeeVote(offline.getVoteId(), offline.getPlayerName(), offline.getUuid(), + offline.getService(), offline.getTime(), offline.isRealVote(), offline.getText()); + assertEquals(456L, copied.getTime()); + assertEquals(123L, copied.getCanonicalOccurrenceTime()); + var timed = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 789L); + timed.setCanonicalOccurrenceTime(123L); + timed.setTotals("v2//8//7//6//5//4//3//2//1"); + assertEquals(789L, timed.getTime()); + assertEquals(123L, timed.getCanonicalOccurrenceTime()); + assertEquals(8, VoteTotalsSnapshot.parseStorage(timed.getTotals()).getAllTimeTotal()); + } + + @Test void malformedOversizedAndConflictingMetadataCannotBecomeDateProvenance() { + for (String token : new String[] { "", "0", "-1", "bad", "9".repeat(100), "123//vp-occurrence-time:456" }) { + String payload = "v2//1//2//3//4//5//6//7//8//vp-occurrence-time:" + token; + assertEquals(-1L, VoteOccurrenceMetadata.read(payload)); + var envelope = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 789L, + true, true, payload, UUID.randomUUID(), false, false, 1, 1); + assertEquals(-1L, VotingPluginWire.readVote(envelope).canonicalOccurrenceTime); + assertEquals(789L, VotingPluginWire.readVote(envelope).time); + assertEquals(1, VoteTotalsSnapshot.parseStorage(payload).getAllTimeTotal()); + } + var envelope = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 789L, + true, true, VoteOccurrenceMetadata.store("", 123L), UUID.randomUUID(), false, false, 1, 1); + var conflicting = envelope.toBuilder().put(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME, 456L).build(); + assertEquals(-1L, VotingPluginWire.readVote(conflicting).canonicalOccurrenceTime); + assertEquals(789L, VotingPluginWire.readVote(conflicting).time); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index b5adc7ca1a..3be29e5a53 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -720,6 +720,9 @@ public Boolean answer(org.mockito.invocation.InvocationOnMock invocation) { target.setAccessible(true); target.set(retry, field.getValue()); } + java.lang.reflect.Field occurrence = retryType.getDeclaredField("canonicalOccurrenceTime"); + occurrence.setAccessible(true); + occurrence.setLong(retry, 123L); java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); retriesField.setAccessible(true); @SuppressWarnings("unchecked") @@ -738,7 +741,11 @@ public Boolean answer(org.mockito.invocation.InvocationOnMock invocation) { @SuppressWarnings({ "rawtypes", "unchecked" }) org.mockito.ArgumentCaptor> recipients = (org.mockito.ArgumentCaptor) org.mockito.ArgumentCaptor.forClass(java.util.Collection.class); - verify(multiProxyHandler, Mockito.times(2)).sendMultiProxyEnvelopeAccepted(Mockito.any(), recipients.capture()); + org.mockito.ArgumentCaptor envelopes = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(multiProxyHandler, Mockito.times(2)).sendMultiProxyEnvelopeAccepted(envelopes.capture(), recipients.capture()); + assertTrue(envelopes.getAllValues().stream().allMatch(envelope -> + VotingPluginWire.readVote(envelope).canonicalOccurrenceTime == 123L + && VotingPluginWire.readVote(envelope).time == 100L)); assertEquals(java.util.Set.of(java.util.Set.of("proxy2"), java.util.Set.of("ProxyLegacy")), new java.util.HashSet<>(recipients.getAllValues())); java.lang.reflect.Method settle = VotingPluginProxy.class @@ -750,6 +757,8 @@ public Boolean answer(org.mockito.invocation.InvocationOnMock invocation) { verify(voteCache, Mockito.atLeast(2)).addTimeVoteToCache(outboxes.capture()); assertTrue(outboxes.getAllValues().stream().allMatch(outbox -> voteId.equals(outbox.getVoteId()))); assertEquals(1, queue.size()); + assertEquals(100L, queue.element().getTime()); + assertEquals(123L, queue.element().getCanonicalOccurrenceTime()); assertEquals(voteId, queue.element().getVoteId()); assertEquals(java.util.Set.of("proxy2"), queue.element().getMultiProxyRecipients()); } @@ -840,6 +849,8 @@ void capabilityDiscoveryDefersVoteUntilExpiryThenSendsOneLegacyCopy() throws Exc verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); assertEquals(1, queue.size()); assertTrue(queue.element().isMultiProxyCapabilityDiscoveryPending()); + assertEquals(100L, queue.element().getTime()); + assertEquals(100L, queue.element().getCanonicalOccurrenceTime()); assertTrue(queue.element().isProcessed()); verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L), Mockito.eq(java.util.concurrent.TimeUnit.SECONDS)); @@ -1714,7 +1725,8 @@ void overflowForwardedVoteIsSpilledToDurableReplayQueueInsteadOfDropped() throws assertEquals(1, queue.size()); assertEquals(voteId, queue.element().getVoteId()); - assertEquals("", queue.element().getTotals()); + assertEquals(0, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.parseStorage(queue.element().getTotals()).getAllTimeTotal()); + assertEquals(100L, queue.element().getCanonicalOccurrenceTime()); assertFalse(queue.element().isRealVote()); assertEquals(1024, retryMap.size()); } @@ -1742,6 +1754,10 @@ void acceptedPlatformVoteRetainsStableIdentityInExistingDurableQueue() { assertEquals("Player", retained.getName()); assertEquals("Service", retained.getService()); assertEquals(1234L, retained.getTime()); + assertEquals(1234L, retained.getCanonicalOccurrenceTime()); + assertFalse(retained.isTotalsApplied()); + assertFalse(retained.isVotePartyApplied()); + verify(voteCache, Mockito.never()).updateTimeVote(Mockito.any()); verify(voteCache, Mockito.times(1)).addTimeVoteToCache(Mockito.any()); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index c43d21301e..f51034e0a3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.tests.listeners; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.doAnswer; @@ -66,6 +67,8 @@ public void setUp() { when(plugin.getVoteSiteManager()).thenReturn(voteSiteManager); when(plugin.getVoteTimer()).thenReturn(voteTimer); when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflowQueue); + when(overflowQueue.reserveCapture(any(java.util.UUID.class))) + .thenReturn(VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED); when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(pluginManager); @@ -156,15 +159,45 @@ public void testUnknownSiteStillAttemptsGenerationByVotifierPath() { verify(pluginManager).callEvent(any(PlayerVoteEvent.class)); } + @Test + public void cancelledCapturesAreBoundedAndTransferredOnceBeforeShutdown() { + var tasks = new java.util.ArrayList(); + doAnswer(call -> { tasks.add(call.getArgument(0)); return CompletableFuture.completedFuture(null); }) + .when(voteTimer).submit(any(Runnable.class)); + doAnswer(call -> { ((java.util.function.Consumer) call.getArgument(5)).accept(true); return null; }) + .when(overflowQueue).enqueueAfterInitialization(anyString(), anyString(), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); + for (int index = 0; index < 257; index++) listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); + org.junit.jupiter.api.Assertions.assertEquals(256, listener.getPendingCaptureCount()); + org.junit.jupiter.api.Assertions.assertEquals(256, tasks.size()); + // The 257th receipt uses the independently bounded overflow directly. + verify(overflowQueue).enqueueAfterInitialization(anyString(), anyString(), + org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); + doAnswer(call -> { + var captures = (java.util.List) call.getArgument(0); + captures.forEach(capture -> capture.completion().accept(true)); return null; + }).when(overflowQueue).retireCaptures(anyList()); + listener.stop(); listener.stop(); + org.junit.jupiter.api.Assertions.assertEquals(0, listener.getPendingCaptureCount()); + // Cancelled callbacks that later run cannot transfer or process again. + tasks.forEach(Runnable::run); + verify(overflowQueue, org.mockito.Mockito.times(1)).enqueueAfterInitialization(anyString(), anyString(), + org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); + verify(overflowQueue).retireCaptures(anyList()); + verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); + } + @Test public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() { doThrow(new RejectedExecutionException("capacity exhausted")) .when(voteTimer).submit(any(Runnable.class)); - when(overflowQueue.enqueue("Steve", SERVICE_SITE)).thenReturn(true); + doAnswer(call -> { ((java.util.function.Consumer) call.getArgument(5)).accept(true); return null; }) + .when(overflowQueue).enqueueAfterInitialization(org.mockito.ArgumentMatchers.eq("Steve"), + org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); - verify(overflowQueue).enqueue("Steve", SERVICE_SITE); + verify(overflowQueue).enqueueAfterInitialization(org.mockito.ArgumentMatchers.eq("Steve"), + org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowCompletionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowCompletionTest.java new file mode 100644 index 0000000000..a60767c122 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowCompletionTest.java @@ -0,0 +1,223 @@ +package com.bencodez.votingplugin.tests.listeners; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue; + +class VotifierOverflowCompletionTest { + private static final UUID OCCURRENCE = UUID.fromString("00000000-0000-0000-0000-000000000019"); + private static final long OCCURRED_AT = 123L; + private static final String SERVICE = "example.org"; + + private VotingPluginMain plugin(Path directory, ScheduledExecutorService timer) { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getVoteTimer()).thenReturn(timer); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn("."); + return plugin; + } + + private ScheduledExecutorService inlineTimer() { + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + return CompletableFuture.completedFuture(null); + }).when(timer).submit(any(Runnable.class)); + return timer; + } + + private ScheduledExecutorService capturedTimer(BlockingQueue tasks) { + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doAnswer(call -> { + tasks.add(call.getArgument(0, Runnable.class)); + return CompletableFuture.completedFuture(null); + }).when(timer).submit(any(Runnable.class)); + return timer; + } + + private void enqueue(VotifierVoteOverflowQueue queue) throws Exception { + var admitted = new CompletableFuture(); + queue.enqueueAfterInitialization("Steve", SERVICE, OCCURRED_AT, OCCURRENCE, admitted::complete); + assertTrue(admitted.get(3, TimeUnit.SECONDS)); + } + + private List> rows(Path directory) { + return new ArrayList<>(YamlConfiguration.loadConfiguration(directory.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes")); + } + + private Runnable take(BlockingQueue tasks) throws Exception { + Runnable task = tasks.poll(3, TimeUnit.SECONDS); + assertNotNull(task, "overflow did not admit a pending receipt"); + return task; + } + + @Test void inlineFailureRetriesAfterDelayWithSameIdentityAndAcknowledgesOnlyCompletion(@TempDir Path directory) throws Exception { + AtomicInteger attempts = new AtomicInteger(); + AtomicInteger prefixEffects = new AtomicInteger(); + AtomicLong firstAttempt = new AtomicLong(); + CompletableFuture retried = new CompletableFuture<>(); + VotingPluginMain plugin = plugin(directory, inlineTimer()); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (site, user, time, id) -> { + assertEquals(SERVICE, site); assertEquals("Steve", user); + assertEquals(OCCURRED_AT, time); assertEquals(OCCURRENCE, id); + prefixEffects.incrementAndGet(); // Ordinary effects can precede an uncertain failure. + if (attempts.incrementAndGet() == 1) { firstAttempt.set(System.nanoTime()); return false; } + retried.complete(System.nanoTime() - firstAttempt.get()); + return true; + }, () -> true)) { + enqueue(queue); queue.start(); + assertTrue(retried.get(3, TimeUnit.SECONDS) >= TimeUnit.MILLISECONDS.toNanos(250), + "inline failure cannot retry in the same drain or bypass its backoff"); + queue.close(); + assertEquals(2, attempts.get()); + assertEquals(2, prefixEffects.get(), "the existing at-least-once contract does not roll back prefix effects"); + assertTrue(rows(directory).isEmpty()); + } + } + + @Test void legacyOccurrenceCallbackExceptionRetainsReceiptAndDoesNotStrandInlineDrain(@TempDir Path directory) throws Exception { + AtomicInteger attempts = new AtomicInteger(); + var completed = new CompletableFuture(); + VotingPluginMain plugin = plugin(directory, inlineTimer()); + try (var queue = new VotifierVoteOverflowQueue(plugin, (site, user, time, id) -> { + assertEquals(OCCURRED_AT, time); assertEquals(OCCURRENCE, id); + if (attempts.incrementAndGet() == 1) throw new IllegalStateException("synthetic callback failure"); + completed.complete(null); + }, () -> true)) { + assertTrue(queue.enqueue("Steve", SERVICE, OCCURRED_AT, OCCURRENCE)); queue.start(); + completed.get(3, TimeUnit.SECONDS); + queue.close(); + assertEquals(2, attempts.get()); + assertTrue(rows(directory).isEmpty()); + } + } + + @Test void failedConfirmedAttemptSurvivesRestartWithOriginalIdentity(@TempDir Path directory) throws Exception { + for (boolean exceptional : List.of(false, true)) { + Path data = directory.resolve(exceptional ? "exception" : "unconfirmed"); + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(data, capturedTimer(tasks)); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (site, user, time, id) -> { + if (exceptional) throw new IllegalStateException("synthetic callback failure"); + return false; + }, () -> true)) { + enqueue(queue); queue.start(); Runnable attempt = take(tasks); + if (exceptional) assertThrows(IllegalStateException.class, attempt::run); else attempt.run(); + assertEquals(1, queue.size()); + } + assertEquals(OCCURRENCE.toString(), rows(data).getFirst().get("LocalOccurrenceId")); + assertEquals(OCCURRED_AT, ((Number) rows(data).getFirst().get("Time")).longValue()); + tasks.clear(); + AtomicInteger processed = new AtomicInteger(); + try (var restarted = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (site, user, time, id) -> { + assertEquals(OCCURRED_AT, time); assertEquals(OCCURRENCE, id); processed.incrementAndGet(); return true; + }, () -> true)) { + restarted.start(); take(tasks).run(); + assertEquals(0, restarted.size()); + } + assertEquals(1, processed.get()); assertTrue(rows(data).isEmpty()); + } + } + + @Test void interruptedReturnDoesNotConfirmReceiptCompletion(@TempDir Path directory) throws Exception { + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(directory, capturedTimer(tasks)); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (site, user, time, id) -> { + Thread.currentThread().interrupt(); return true; + }, () -> true)) { + enqueue(queue); queue.start(); + try { + take(tasks).run(); + assertTrue(Thread.currentThread().isInterrupted()); + assertEquals(1, queue.size(), "interrupted completion retains the uncertain receipt"); + } finally { Thread.interrupted(); } + } + assertEquals(OCCURRENCE.toString(), rows(directory).getFirst().get("LocalOccurrenceId")); + } + + @Test void callbackAdmittedBeforeCloseCannotRunOrOverwriteSuccessorQueue(@TempDir Path directory) throws Exception { + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(directory, capturedTimer(tasks)); + AtomicInteger processed = new AtomicInteger(); + Runnable stale; + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, + (site, user, time, id) -> { processed.incrementAndGet(); return true; }, () -> true)) { + enqueue(queue); queue.start(); stale = take(tasks); + } + try (var successor = new VotifierVoteOverflowQueue(plugin, (site, user) -> { })) { + assertTrue(successor.enqueue("Alex", "second.example.org", 456L)); + stale.run(); assertEquals(0, processed.get()); assertEquals(2, successor.size()); + } + assertEquals(2, rows(directory).size()); + assertEquals(OCCURRENCE.toString(), rows(directory).getFirst().get("LocalOccurrenceId")); + } + + @Test void retiredGenerationRetainsAdmittedReceiptWithoutInvokingProcessor(@TempDir Path directory) throws Exception { + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(directory, capturedTimer(tasks)); + var active = new java.util.concurrent.atomic.AtomicBoolean(true); + AtomicInteger processed = new AtomicInteger(); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, + (site, user, time, id) -> { processed.incrementAndGet(); return true; }, active::get)) { + enqueue(queue); queue.start(); Runnable admitted = take(tasks); + active.set(false); admitted.run(); + assertEquals(0, processed.get()); assertEquals(1, queue.size()); + assertTrue(tasks.isEmpty(), "a retired callback must not schedule an immediate retry"); + } + assertEquals(OCCURRENCE.toString(), rows(directory).getFirst().get("LocalOccurrenceId")); + } + + @Test void retiredInlineGenerationParksBeforeSubmittingCallbacks(@TempDir Path directory) throws Exception { + ScheduledExecutorService timer = inlineTimer(); + VotingPluginMain plugin = plugin(directory, timer); + var checked = new CompletableFuture(); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, + (site, user, time, id) -> { fail("retired generation cannot process receipts"); return true; }, + () -> { checked.complete(null); return false; })) { + enqueue(queue); queue.start(); checked.get(3, TimeUnit.SECONDS); + assertEquals(1, queue.size()); + verify(timer, never()).submit(any(Runnable.class)); + } + assertEquals(1, rows(directory).size()); + } + + @Test void legacyTwoAndThreeArgumentCallbacksStillCompleteNormally(@TempDir Path directory) throws Exception { + for (int arity : List.of(2, 3)) { + Path data = directory.resolve("callback-" + arity); + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(data, capturedTimer(tasks)); + AtomicInteger processed = new AtomicInteger(); + VotifierVoteOverflowQueue queue = arity == 2 + ? new VotifierVoteOverflowQueue(plugin, (site, user) -> processed.incrementAndGet()) + : new VotifierVoteOverflowQueue(plugin, (site, user, time) -> { + assertEquals(OCCURRED_AT, time); processed.incrementAndGet(); + }, () -> true); + try (queue) { + assertTrue(queue.enqueue("Steve", SERVICE, OCCURRED_AT, OCCURRENCE)); + queue.start(); take(tasks).run(); assertEquals(0, queue.size()); + } + assertEquals(1, processed.get()); assertTrue(rows(data).isEmpty()); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowGenerationOwnershipTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowGenerationOwnershipTest.java new file mode 100644 index 0000000000..2eb430fb43 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowGenerationOwnershipTest.java @@ -0,0 +1,223 @@ +package com.bencodez.votingplugin.tests.listeners; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; +import java.util.logging.Logger; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue; + +class VotifierOverflowGenerationOwnershipTest { + @TempDir Path directory; + + private VotingPluginMain plugin() { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn("."); + return plugin; + } + + private static ScheduledThreadPoolExecutor worker(VotifierVoteOverflowQueue queue) throws Exception { + var field = VotifierVoteOverflowQueue.class.getDeclaredField("worker"); + field.setAccessible(true); + return (ScheduledThreadPoolExecutor) field.get(queue); + } + + private static void turn(VotifierVoteOverflowQueue queue) throws Exception { + worker(queue).submit(() -> { }).get(5, TimeUnit.SECONDS); + } + + private static void await(BooleanSupplier condition) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(10); + assertTrue(condition.getAsBoolean(), "bounded lifecycle condition did not complete"); + } + + private static void add(VotifierVoteOverflowQueue queue, UUID id) throws Exception { + var accepted = new AtomicBoolean(); var done = new CountDownLatch(1); + queue.enqueueAfterInitialization("TestPlayer", "example.org", 1234L, id, + result -> { accepted.set(result); done.countDown(); }); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertTrue(accepted.get()); turn(queue); + } + + private List rows() throws Exception { + var yaml = new YamlConfiguration(); + yaml.load(directory.resolve("VotifierVoteQueue.yml").toFile()); + return yaml.getList("Votes"); + } + + @Test void nativeOwnershipSurvivesSameJvmContenderAndReleasesAfterClose() throws Exception { + var plugin = plugin(); + var first = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (s, u, t, id) -> true, () -> false); + VotifierVoteOverflowQueue successor = null; + Path owner = directory.resolve("VotifierVoteQueue.yml.owner"); + try { + await(first::isInitializationComplete); + successor = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (s, u, t, id) -> true, () -> false); + assertTrue(Files.exists(owner)); + turn(successor); + assertFalse(successor.isInitializationComplete(), "same-JVM contender must wait before opening a descriptor"); + assertEquals(3, externalLockProbe(owner)); + first.close(); + await(successor::isInitializationComplete); + successor.close(); + assertEquals(0, externalLockProbe(owner), "native lock must be released after final queue close"); + } finally { + first.close(); if (successor != null) successor.close(); + } + } + + private static int externalLockProbe(Path path) throws Exception { + String java = Path.of(System.getProperty("java.home"), "bin").resolve( + System.getProperty("os.name", "").startsWith("Windows") ? "java.exe" : "java").toString(); + Process process = new ProcessBuilder(java, "-cp", System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + LockProbe.class.getName(), path.toString()).start(); + try { assertTrue(process.waitFor(10, TimeUnit.SECONDS)); return process.exitValue(); } + finally { process.destroyForcibly(); process.waitFor(10, TimeUnit.SECONDS); } + } + + public static final class LockProbe { + public static void main(String[] args) throws Exception { + try (var channel = java.nio.channels.FileChannel.open(Path.of(args[0]), StandardOpenOption.WRITE); + var lock = channel.tryLock()) { System.exit(lock == null ? 3 : 0); } + } + } + + @Test void isolatedClassloaderCannotOpenSecondNativeOwner() throws Exception { + var first = new VotifierVoteOverflowQueue(plugin(), (s, u) -> { }); + try (var loader = isolatedQueueLoader()) { + Class type = loader.loadClass(VotifierVoteOverflowQueue.class.getName()); + assertNotSame(VotifierVoteOverflowQueue.class, type); + var constructor = type.getConstructor(VotingPluginMain.class, java.util.function.BiConsumer.class); + Object contender = constructor.newInstance(plugin(), (java.util.function.BiConsumer) (s, u) -> { }); + try { + var enqueue = type.getMethod("enqueue", String.class, String.class); + assertFalse((Boolean) enqueue.invoke(contender, "TestPlayer", "example.org")); + assertEquals(3, externalLockProbe(directory.resolve("VotifierVoteQueue.yml.owner"))); + } finally { type.getMethod("close").invoke(contender); } + } finally { first.close(); } + } + + @Test void failedOwnerOpenReclaimsReservationAndRepeatedCloseCannotClearSuccessor() throws Exception { + Path obstruction = directory.resolve("VotifierVoteQueue.yml.owner"); + Files.createDirectory(obstruction); + var failed = new VotifierVoteOverflowQueue(plugin(), (s, u) -> { }); + assertFalse(failed.enqueue("TestPlayer", "example.org")); + failed.close(); Files.delete(obstruction); + var first = new VotifierVoteOverflowQueue(plugin(), (s, u) -> { }); + first.close(); + var second = new VotifierVoteOverflowQueue(plugin(), (s, u) -> { }); + try { + assertTrue(second.enqueue("TestPlayer", "example.org")); + first.close(); failed.close(); + assertEquals(3, externalLockProbe(obstruction)); + } finally { second.close(); } + assertEquals(0, externalLockProbe(obstruction)); + } + + private static URLClassLoader isolatedQueueLoader() { + URL location = VotifierVoteOverflowQueue.class.getProtectionDomain().getCodeSource().getLocation(); + return new URLClassLoader(new URL[] { location }, VotifierVoteOverflowQueue.class.getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.equals(VotifierVoteOverflowQueue.class.getName()) + && !name.startsWith(VotifierVoteOverflowQueue.class.getName() + "$")) return super.loadClass(name, resolve); + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) loaded = findClass(name); + if (resolve) resolveClass(loaded); + return loaded; + } + } + }; + } + + @Test void failedOwnershipInitializationRejectsAdmissionWithoutOverwritingObstruction() throws Exception { + Path obstruction = directory.resolve("blocked-data"); + java.nio.file.Files.writeString(obstruction, "synthetic obstruction"); + var plugin = plugin(); when(plugin.getDataFolder()).thenReturn(obstruction.toFile()); + try (var queue = new VotifierVoteOverflowQueue(plugin, (s, u) -> { })) { + assertFalse(queue.enqueue("TestPlayer", "example.org")); + assertEquals(0, queue.size()); + } + assertEquals("synthetic obstruction", java.nio.file.Files.readString(obstruction)); + } + + @Test void successorWaitsUntilRetiredWriterPublishesAndReleasesFileOwnership() throws Exception { + var plugin = plugin(); UUID original = UUID.randomUUID(), next = UUID.randomUUID(); + var first = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (s, u, t, id) -> true, () -> false); + VotifierVoteOverflowQueue successor = null; + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + try { + turn(first); add(first, original); + worker(first).execute(() -> { + entered.countDown(); + try { if (!release.await(10, TimeUnit.SECONDS)) throw new AssertionError("worker release timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + first.close(); // Returns after the bounded wait while its final publication remains queued. + assertFalse(worker(first).isTerminated()); + successor = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (s, u, t, id) -> true, () -> false); + turn(successor); + assertFalse(successor.isInitializationComplete(), "successor must not load/publish ahead of the old writer"); + assertEquals(1, rows().size()); + release.countDown(); + assertTrue(worker(first).awaitTermination(5, TimeUnit.SECONDS)); + var current = successor; await(current::isInitializationComplete); + assertEquals(1, current.size()); add(current, next); current.close(); + var stored = rows(); assertEquals(2, stored.size()); + var ids = stored.stream().map(row -> ((Map) row).get("LocalOccurrenceId")).toList(); + assertTrue(ids.contains(original.toString())); assertTrue(ids.contains(next.toString())); + } finally { + release.countDown(); first.close(); + worker(first).awaitTermination(5, TimeUnit.SECONDS); + if (successor != null) successor.close(); + } + } + + @Test void closingSuccessorBeforeOwnershipAcquisitionCannotPublishAnEmptySnapshot() throws Exception { + var plugin = plugin(); UUID original = UUID.randomUUID(); + var first = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (s, u, t, id) -> true, () -> false); + VotifierVoteOverflowQueue successor = null; + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + try { + turn(first); add(first, original); + worker(first).execute(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); first.close(); + successor = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (s, u, t, id) -> true, () -> false); + turn(successor); assertFalse(successor.isInitializationComplete()); + successor.close(); + assertEquals(1, rows().size()); + assertEquals(original.toString(), ((Map) rows().getFirst()).get("LocalOccurrenceId")); + release.countDown(); assertTrue(worker(first).awaitTermination(5, TimeUnit.SECONDS)); + assertEquals(1, rows().size()); + } finally { + release.countDown(); first.close(); worker(first).awaitTermination(5, TimeUnit.SECONDS); + if (successor != null) successor.close(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java new file mode 100644 index 0000000000..16250f8f5f --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java @@ -0,0 +1,729 @@ +package com.bencodez.votingplugin.tests.listeners; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.logging.Logger; +import org.bukkit.plugin.PluginManager; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.listeners.VotiferEvent; +import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue; +import com.bencodez.votingplugin.util.BoundedScheduledExecutor; +import com.vexsoftware.votifier.model.Vote; + +/** Real ingress callback, real executor cancellation, real overflow publication/restart. */ +class VotifierStartedCaptureOwnershipTest { + @TempDir Path directory; + private VotingPluginMain plugin; + private PluginManager events; + private com.bencodez.votingplugin.data.ServerData serverData; + private ScheduledExecutorService timer; + private VotiferEvent listener; + private VotifierVoteOverflowQueue overflow; + private final AtomicBoolean ready = new AtomicBoolean(true); + private final List processed = new CopyOnWriteArrayList<>(); + private static final String SITE = "example.org"; + private static ScheduledExecutorService timer() { + return Executors.newSingleThreadScheduledExecutor(task -> { + var thread = new Thread(task, "test-native-vote-callback"); thread.setDaemon(true); return thread; + }); + } + @BeforeEach void setup() throws Exception { + plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn("."); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(false); + // Finish mock creation before overflow admission and normal processing + // concurrently use this plugin; deep-stub creation itself is not safe then. + when(plugin.getConfigFile()).thenReturn(mock(com.bencodez.votingplugin.config.Config.class)); + when(plugin.getTimeChecker()).thenReturn(mock(com.bencodez.advancedcore.api.time.TimeChecker.class)); + when(plugin.getVoteSiteManager().getVoteSiteName(anyBoolean(), anyString(), anyString())).thenAnswer(call -> call.getArgument(1)); + when(plugin.getVoteSiteManager().hasVoteSite(anyString())).thenReturn(true); + when(plugin.getVoteSiteManager().getVoteSite(anyString(), anyBoolean())) + .thenReturn(mock(com.bencodez.votingplugin.votesites.VoteSite.class)); + events = mock(PluginManager.class); when(plugin.getServer().getPluginManager()).thenReturn(events); + doAnswer(call -> { var event = call.getArgument(0, PlayerVoteEvent.class); event.setNativeProcessingCompleted(true); processed.add(event); return null; }).when(events).callEvent(any(PlayerVoteEvent.class)); + serverData = plugin.getServerData(); + timer = timer(); when(plugin.getVoteTimer()).thenReturn(timer); + listener = new VotiferEvent(plugin, ready::get); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + } + @AfterEach void close() { timer.shutdownNow(); overflow.close(); } + private com.vexsoftware.votifier.model.VotifierEvent receipt() { + return receipt("Steve"); + } + private com.vexsoftware.votifier.model.VotifierEvent receipt(String username) { + var vote = mock(Vote.class); when(vote.getServiceName()).thenReturn(SITE); + when(vote.getUsername()).thenReturn(username); when(vote.getAddress()).thenReturn("127.0.0.1"); + var event = mock(com.vexsoftware.votifier.model.VotifierEvent.class); when(event.getVote()).thenReturn(vote); return event; + } + private static ScheduledThreadPoolExecutor worker(VotifierVoteOverflowQueue queue) throws Exception { + var field = VotifierVoteOverflowQueue.class.getDeclaredField("worker"); field.setAccessible(true); + return (ScheduledThreadPoolExecutor) field.get(queue); + } + private static void workerTurn(VotifierVoteOverflowQueue queue) throws Exception { worker(queue).submit(() -> {}).get(5, TimeUnit.SECONDS); } + private void processingTurn() throws Exception { timer.submit(() -> {}).get(5, TimeUnit.SECONDS); } + private int pendingIngress() throws Exception { + var field = VotifierVoteOverflowQueue.class.getDeclaredField("pendingIngress"); field.setAccessible(true); + return field.getInt(overflow); + } + private List> disk() { return YamlConfiguration.loadConfiguration(directory.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); } + private record CapturedReceipt(UUID id, long time) { } + private CapturedReceipt retainedReceipt() throws Exception { + var field = VotiferEvent.class.getDeclaredField("captured"); field.setAccessible(true); + var entries = (Map) field.get(listener); assertEquals(1, entries.size()); + var vote = entries.values().iterator().next(); + var time = vote.getClass().getDeclaredField("occurredAt"); time.setAccessible(true); + return new CapturedReceipt((UUID) entries.keySet().iterator().next(), time.getLong(vote)); + } + private List recoverFullQueue() throws Exception { + var ids = new ArrayList(); + for (int index = 0; index < 256; index++) { + UUID id = UUID.randomUUID(); ids.add(id); + assertTrue(overflow.enqueue("Steve", SITE, 123L, id)); + } + overflow.close(); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + assertEquals(256, overflow.size()); + return ids; + } + @Test void saturatedIngressWaitsForInitializationAndPreservesEveryReceipt() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + var delivered = new CountDownLatch(257); + doAnswer(call -> { + var event = call.getArgument(0, PlayerVoteEvent.class); + event.setNativeProcessingCompleted(true); processed.add(event); delivered.countDown(); return null; + }).when(events).callEvent(any(PlayerVoteEvent.class)); + try { + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt()); + long before = System.currentTimeMillis(); listener.onVotiferEvent(receipt("Extra")); + long after = System.currentTimeMillis(); workerTurn(overflow); + assertFalse(overflow.isInitializationComplete()); + assertEquals(256, listener.getPendingCaptureCount()); + assertEquals(1, pendingIngress(), "saturated receipt must remain owned until initialization resolves"); + overflow.start(); predecessor.close(); + assertTrue(delivered.await(5, TimeUnit.SECONDS)); + processingTurn(); workerTurn(overflow); + assertEquals(257, processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + var extra = processed.stream().filter(event -> "Extra".equals(event.getPlayer())).findFirst().orElseThrow(); + assertTrue(extra.getCanonicalOccurrenceTime() >= before && extra.getCanonicalOccurrenceTime() <= after); + assertEquals(0, pendingIngress()); assertEquals(0, listener.getPendingCaptureCount()); + } finally { ready.set(false); listener.stop(); predecessor.close(); overflow.close(); } + } + + @Test void closingSaturatedReplacementPublishesBoundedPendingIngressWithCaptures() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + try { + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt()); + long before = System.currentTimeMillis(); + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt("Extra" + index)); + long after = System.currentTimeMillis(); + listener.onVotiferEvent(receipt("BeyondCapacity")); workerTurn(overflow); + assertEquals(256, listener.getPendingCaptureCount()); assertEquals(256, pendingIngress()); + ready.set(false); listener.stop(); overflow.close(); + assertFalse(worker(overflow).isShutdown(), "final publication must retain both bounded ownership lanes"); + predecessor.close(); assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + var rows = disk(); assertEquals(512, rows.size()); + assertEquals(512, rows.stream().map(row -> row.get("LocalOccurrenceId")).distinct().count()); + assertFalse(rows.stream().anyMatch(row -> "BeyondCapacity".equals(row.get("Username")))); + var extra = rows.stream().filter(row -> "Extra255".equals(row.get("Username"))).findFirst().orElseThrow(); + long occurredAt = ((Number) extra.get("Time")).longValue(); + assertTrue(occurredAt >= before && occurredAt <= after); + UUID id = UUID.fromString((String) extra.get("LocalOccurrenceId")); + try (var restarted = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, occurrence) -> {}, () -> false)) { + workerTurn(restarted); assertEquals(512, restarted.size()); + assertTrue(disk().stream().anyMatch(row -> id.toString().equals(row.get("LocalOccurrenceId")) + && ((Number) row.get("Time")).longValue() == occurredAt)); + } + } finally { ready.set(false); listener.stop(); predecessor.close(); overflow.close(); } + } + + @Test void closingIngressWithoutCaptureRetirementWaitsForFileOwner() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, () -> false); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + UUID id = UUID.randomUUID(); var completions = new AtomicInteger(); var admitted = new AtomicBoolean(); + try { + overflow.enqueueAfterInitialization("Extra", SITE, 123L, id, accepted -> { + admitted.set(accepted); completions.incrementAndGet(); + }); + workerTurn(overflow); assertEquals(1, pendingIngress()); assertEquals(0, completions.get()); + overflow.close(); assertFalse(worker(overflow).isShutdown()); + predecessor.close(); assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + assertEquals(1, completions.get()); assertTrue(admitted.get()); + var rows = disk(); assertEquals(1, rows.size()); + assertEquals(id.toString(), rows.get(0).get("LocalOccurrenceId")); + assertEquals(123L, ((Number) rows.get(0).get("Time")).longValue()); + } finally { predecessor.close(); overflow.close(); } + } + + @Test void malformedIngressCannotOccupyProvisionalCapacity() throws Exception { + var predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, () -> false); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + var completions = new AtomicInteger(); var accepted = new AtomicBoolean(); + java.util.function.Consumer completion = result -> { + if (result) accepted.set(true); completions.incrementAndGet(); + }; + try { + overflow.enqueueAfterInitialization(null, SITE, 123L, UUID.randomUUID(), completion); + overflow.enqueueAfterInitialization("Extra", null, 123L, UUID.randomUUID(), completion); + overflow.enqueueAfterInitialization("Extra", SITE, 0L, UUID.randomUUID(), completion); + overflow.enqueueAfterInitialization("Extra", SITE, 123L, null, completion); + workerTurn(overflow); + assertEquals(4, completions.get()); assertFalse(accepted.get()); assertEquals(0, pendingIngress()); + } finally { predecessor.close(); overflow.close(); } + } + + @Test void initializedFullOverflowStillRejectsWithoutIndefiniteIngressRetry() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + ready.set(false); recoverFullQueue(); + var completions = new AtomicInteger(); var accepted = new AtomicBoolean(true); + overflow.enqueueAfterInitialization("Extra", SITE, 123L, UUID.randomUUID(), result -> { + accepted.set(result); completions.incrementAndGet(); + }); + workerTurn(overflow); + assertEquals(1, completions.get()); assertFalse(accepted.get()); + assertEquals(0, pendingIngress()); assertEquals(256, overflow.size()); + } + + @Test void saturatedCapturesUseAllIndependentOverflowSlotsWithoutChangingReceiptIdentity() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + timer.shutdownNow(); timer = new BoundedScheduledExecutor(1, 256); + when(plugin.getVoteTimer()).thenReturn(timer); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + return null; + }).when(serverData).addServiceSite(SITE); + var delivered = new CountDownLatch(512); + doAnswer(call -> { var event = call.getArgument(0, PlayerVoteEvent.class); event.setNativeProcessingCompleted(true); processed.add(event); delivered.countDown(); return null; }) + .when(events).callEvent(any(PlayerVoteEvent.class)); + try { + listener.onVotiferEvent(receipt()); assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int index = 1; index < 256; index++) listener.onVotiferEvent(receipt()); + assertEquals(256, listener.getPendingCaptureCount()); + long before = System.currentTimeMillis(); + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt()); + long after = System.currentTimeMillis(); workerTurn(overflow); + assertEquals(256, overflow.size(), "Normal capture saturation must use the independent overflow capacity"); + assertEquals(256, listener.getPendingCaptureCount()); assertTrue(processed.isEmpty()); + listener.onVotiferEvent(receipt()); workerTurn(overflow); + assertEquals(256, overflow.size()); assertEquals(256, listener.getPendingCaptureCount()); + overflow.close(); var rows = disk(); assertEquals(256, rows.size()); + var occurrences = new HashMap(); + for (var row : rows) { + long time = ((Number) row.get("Time")).longValue(); assertTrue(time >= before && time <= after); + assertNull(occurrences.put(UUID.fromString((String) row.get("LocalOccurrenceId")), time)); + } + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); overflow.start(); + release.countDown(); assertTrue(delivered.await(10, TimeUnit.SECONDS)); processingTurn(); workerTurn(overflow); + assertEquals(512, processed.size()); assertEquals(512, processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + for (var event : processed) if (occurrences.containsKey(event.getLocalOccurrenceId())) + assertEquals(occurrences.get(event.getLocalOccurrenceId()).longValue(), event.getCanonicalOccurrenceTime()); + assertEquals(0, listener.getPendingCaptureCount()); assertEquals(0, overflow.size()); + } finally { release.countDown(); } + } + @Test void rejectedTransferRetriesAfterRecoveredFullQueueDrains() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var recovered = recoverFullQueue(); + var admissions = new AtomicInteger(); var wrapper = mock(ScheduledExecutorService.class); + doAnswer(call -> { + if (admissions.getAndIncrement() == 0) throw new RejectedExecutionException("initial admission rejected"); + return timer.submit(call.getArgument(0, Runnable.class)); + }).when(wrapper).submit(any(Runnable.class)); when(plugin.getVoteTimer()).thenReturn(wrapper); + var delivered = new CountDownLatch(257); + doAnswer(call -> { var event = call.getArgument(0, PlayerVoteEvent.class); event.setNativeProcessingCompleted(true); processed.add(event); delivered.countDown(); return null; }) + .when(events).callEvent(any(PlayerVoteEvent.class)); + listener.onVotiferEvent(receipt()); workerTurn(overflow); + var original = retainedReceipt(); assertEquals(256, overflow.size()); assertTrue(processed.isEmpty()); + overflow.start(); assertTrue(delivered.await(5, TimeUnit.SECONDS), "Rejected capture must retry when recovered entries drain"); + processingTurn(); workerTurn(overflow); + assertEquals(0, listener.getPendingCaptureCount()); assertEquals(0, overflow.size()); + assertEquals(257, processed.size()); assertEquals(257, processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + assertTrue(processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).toList().containsAll(recovered)); + var retry = processed.stream().filter(event -> original.id().equals(event.getLocalOccurrenceId())).toList(); + assertEquals(1, retry.size()); assertEquals(original.time(), retry.getFirst().getCanonicalOccurrenceTime()); + } + @Test void gracefulDisableWhichSkipsConsumerRetainsTransferredReceipt() throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + return null; + }).when(serverData).addServiceSite(SITE); + // Bukkit's disabled-listener path returns normally without dispatching the consumer. + doAnswer(call -> null).when(events).callEvent(any(PlayerVoteEvent.class)); + listener.onVotiferEvent(receipt()); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var original = retainedReceipt(); + ready.set(false); listener.stop(); workerTurn(overflow); + release.countDown(); processingTurn(); workerTurn(overflow); + assertTrue(processed.isEmpty()); + assertEquals(1, overflow.size()); + overflow.close(); + var rows = disk(); assertEquals(1, rows.size()); + assertEquals(original.id().toString(), rows.getFirst().get("LocalOccurrenceId")); + assertEquals(original.time(), ((Number) rows.getFirst().get("Time")).longValue()); + } finally { release.countDown(); } + } + @Test void confirmedDispatchRetainsFailureSwallowedByEventManager() { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getUserManager().getProperName(anyString())).thenThrow(new IllegalStateException("synthetic consumer failure")); + var consumer = new com.bencodez.votingplugin.listeners.PlayerVoteListener(plugin); + doAnswer(call -> { + try { consumer.onplayerVote(call.getArgument(0, PlayerVoteEvent.class)); } + catch (RuntimeException ignored) { /* Bukkit reports and swallows listener failures. */ } + return null; + }).when(events).callEvent(any(PlayerVoteEvent.class)); + assertFalse(listener.processVoteConfirmed(SITE, "TestPlayer", 1234L, UUID.randomUUID())); + assertTrue(processed.isEmpty()); + } + @Test void intentionalCancellationBeforeConsumerRemainsACompletedNoOp() { + doAnswer(call -> { call.getArgument(0, PlayerVoteEvent.class).setCancelled(true); return null; }) + .when(events).callEvent(any(PlayerVoteEvent.class)); + assertTrue(listener.processVoteConfirmed(SITE, "TestPlayer", 1234L, UUID.randomUUID())); + assertTrue(processed.isEmpty()); + } + @Test void acceptedConsumerDoesNotConfirmDisabledOrExceptionalProcessing() { + var consumer = new com.bencodez.votingplugin.listeners.PlayerVoteListener(plugin); + var disabled = new PlayerVoteEvent(mock(com.bencodez.votingplugin.votesites.VoteSite.class), "TestPlayer", SITE, true); + when(plugin.isEnabled()).thenReturn(false); + consumer.onplayerVote(disabled); + assertFalse(disabled.isNativeProcessingStarted()); + assertFalse(disabled.isNativeProcessingCompleted()); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getUserManager().getProperName(anyString())).thenThrow(new IllegalStateException("synthetic consumer failure")); + var failed = new PlayerVoteEvent(mock(com.bencodez.votingplugin.votesites.VoteSite.class), "TestPlayer", SITE, true); + assertThrows(IllegalStateException.class, () -> consumer.onplayerVote(failed)); + assertTrue(failed.isNativeProcessingStarted()); + assertFalse(failed.isNativeProcessingCompleted()); + } + @Test void stopRetriesRejectedTransferAfterCapacityReturnsWithExactReceipt() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var recovered = recoverFullQueue(); + var rejecting = mock(ScheduledExecutorService.class); + when(rejecting.submit(any(Runnable.class))).thenThrow(new RejectedExecutionException("vote worker full")); + when(plugin.getVoteTimer()).thenReturn(rejecting); + listener.onVotiferEvent(receipt()); workerTurn(overflow); var original = retainedReceipt(); + overflow.acknowledgeOccurrence(recovered.getFirst()); + listener.stop(); workerTurn(overflow); + assertEquals(0, listener.getPendingCaptureCount(), "Stop must retry a transfer previously rejected for capacity"); + assertEquals(256, overflow.size()); assertTrue(processed.isEmpty()); overflow.close(); + var rows = disk(); assertEquals(256, rows.size()); + var retained = rows.stream().filter(row -> original.id().toString().equals(row.get("LocalOccurrenceId"))).toList(); + assertEquals(1, retained.size()); assertEquals(original.time(), ((Number) retained.getFirst().get("Time")).longValue()); + assertTrue(rows.stream().noneMatch(row -> recovered.getFirst().toString().equals(row.get("LocalOccurrenceId")))); + } + @Test void startedBlockedBeforeAcceptedPipelineSurvivesStopCancellationAndRestart() throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw new IllegalStateException("native storage interrupted before vote consumer", interrupted); + } + return null; + }).when(serverData).addServiceSite(SITE); + long before = System.currentTimeMillis(); listener.onVotiferEvent(receipt()); long after = System.currentTimeMillis(); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); assertEquals(1, listener.getPendingCaptureCount()); + assertTrue(processed.isEmpty()); ready.set(false); listener.stop(); listener.stop(); workerTurn(overflow); + assertEquals(0, listener.getPendingCaptureCount()); assertEquals(1, overflow.size()); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); overflow.close(); + var rows = disk(); assertEquals(1, rows.size()); var row = rows.getFirst(); + UUID id = UUID.fromString((String) row.get("LocalOccurrenceId")); long time = ((Number) row.get("Time")).longValue(); + assertTrue(time >= before && time <= after); assertTrue(processed.isEmpty()); + doAnswer(call -> null).when(serverData).addServiceSite(SITE); + timer = timer(); when(plugin.getVoteTimer()).thenReturn(timer); + try (var restarted = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, () -> true)) { + workerTurn(restarted); assertEquals(1, restarted.size()); + var delivered = new CountDownLatch(1); + doAnswer(call -> { var event = call.getArgument(0, PlayerVoteEvent.class); event.setNativeProcessingCompleted(true); processed.add(event); delivered.countDown(); return null; }).when(events).callEvent(any(PlayerVoteEvent.class)); + restarted.start(); assertTrue(delivered.await(5, TimeUnit.SECONDS)); processingTurn(); workerTurn(restarted); + assertEquals(0, restarted.size()); assertEquals(1, processed.size()); + assertEquals(id, processed.getFirst().getLocalOccurrenceId()); assertEquals(time, processed.getFirst().getCanonicalOccurrenceTime()); + } + try (var again = new VotifierVoteOverflowQueue(plugin, (site, user) -> fail("completed receipt replayed"))) { assertEquals(0, again.size()); } + } finally { release.countDown(); } + } + @Test void completedBeforeTransferAdmissionReconcilesExactOccurrence() throws Exception { + completedCurrentCallbackReconcilesExactTransferredOccurrence("before-admission"); + } + @Test void completedAfterTransferAdmissionReconcilesExactOccurrence() throws Exception { + completedCurrentCallbackReconcilesExactTransferredOccurrence("after-admission"); + } + private void completedCurrentCallbackReconcilesExactTransferredOccurrence(String completionOrder) throws Exception { + UUID unrelated = UUID.randomUUID(); assertTrue(overflow.enqueue("Steve", SITE, 123L, unrelated)); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + return null; + }).when(serverData).addServiceSite(SITE); + listener.onVotiferEvent(receipt()); assertTrue(entered.await(5, TimeUnit.SECONDS)); assertEquals(1, listener.getPendingCaptureCount()); + var transferEntered = new CountDownLatch(1); var releaseTransfer = new CountDownLatch(1); + if (completionOrder.equals("before-admission")) { + worker(overflow).execute(() -> { + transferEntered.countDown(); + try { if (!releaseTransfer.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test transfer timeout"); } + catch (InterruptedException failure) { throw new CompletionException(failure); } + }); + assertTrue(transferEntered.await(5, TimeUnit.SECONDS)); + } + try { + ready.set(false); listener.stop(); + if (completionOrder.equals("after-admission")) { workerTurn(overflow); assertEquals(2, overflow.size()); } + release.countDown(); processingTurn(); assertEquals(1, processed.size()); + UUID completed = processed.getFirst().getLocalOccurrenceId(); assertNotEquals(unrelated, completed); + releaseTransfer.countDown(); workerTurn(overflow); assertEquals(0, listener.getPendingCaptureCount()); + assertEquals(1, overflow.size()); overflow.close(); assertEquals(1, disk().size()); + assertEquals(unrelated.toString(), disk().getFirst().get("LocalOccurrenceId")); + var delivered = new CountDownLatch(1); + doAnswer(call -> { var event = call.getArgument(0, PlayerVoteEvent.class); event.setNativeProcessingCompleted(true); processed.add(event); delivered.countDown(); return null; }).when(events).callEvent(any(PlayerVoteEvent.class)); + try (var restarted = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, () -> true)) { + workerTurn(restarted); restarted.start(); assertTrue(delivered.await(5, TimeUnit.SECONDS)); processingTurn(); workerTurn(restarted); + assertEquals(0, restarted.size()); assertEquals(2, processed.size()); + assertEquals(1, processed.stream().filter(event -> completed.equals(event.getLocalOccurrenceId())).count()); + assertEquals(unrelated, processed.getLast().getLocalOccurrenceId()); + } + } finally { release.countDown(); releaseTransfer.countDown(); } + } + @Test void uncertainPartialProcessingKeepsItsTransferredRecoveryRecord() throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var effects = new AtomicInteger(); + doAnswer(call -> { + effects.incrementAndGet(); entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + throw new IllegalStateException("consumer failed after a partial effect"); + }).when(events).callEvent(any(PlayerVoteEvent.class)); + listener.onVotiferEvent(receipt()); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); ready.set(false); listener.stop(); workerTurn(overflow); + release.countDown(); processingTurn(); assertEquals(1, effects.get()); assertEquals(1, overflow.size()); + overflow.close(); assertEquals(1, disk().size()); + } finally { release.countDown(); } + } + @Test void standaloneStopCannotDrainStartedTransferWhileItsOriginalCallbackRuns() throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + return null; + }).when(serverData).addServiceSite(SITE); + overflow.start(); listener.onVotiferEvent(receipt()); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); listener.stop(); + // Readiness remains true: the per-entry release fence must protect this + // path even when a caller stops capture without the main shutdown fence. + workerTurn(overflow); workerTurn(overflow); assertEquals(1, overflow.size()); + assertTrue(processed.isEmpty()); release.countDown(); processingTurn(); workerTurn(overflow); + assertEquals(1, processed.size(), "an eagerly submitted overflow retry would duplicate normal effects"); + assertEquals(0, overflow.size()); overflow.close(); assertTrue(disk().isEmpty()); + } finally { release.countDown(); } + } + @Test void ordinaryRejectedAdmissionTransfersAnUnstartedReceiptThroughTheSameQueue() throws Exception { + var admissions = new AtomicInteger(); var wrapper = mock(ScheduledExecutorService.class); + doAnswer(call -> { + if (admissions.getAndIncrement() == 0) throw new RejectedExecutionException("initial admission rejected"); + return timer.submit(call.getArgument(0, Runnable.class)); + }).when(wrapper).submit(any(Runnable.class)); when(plugin.getVoteTimer()).thenReturn(wrapper); + var delivered = new CountDownLatch(1); + doAnswer(call -> { var event = call.getArgument(0, PlayerVoteEvent.class); event.setNativeProcessingCompleted(true); processed.add(event); delivered.countDown(); return null; }).when(events).callEvent(any(PlayerVoteEvent.class)); + overflow.start(); listener.onVotiferEvent(receipt()); + assertTrue(delivered.await(5, TimeUnit.SECONDS)); processingTurn(); workerTurn(overflow); + assertEquals(1, processed.size()); assertEquals(0, listener.getPendingCaptureCount()); assertEquals(0, overflow.size()); + } + @Test void shutdownRetainsAnUnstartedCaptureBesideAFullRecoveredQueueAndRestartsItsExactOccurrence() throws Exception { + var recovered = recoverFullQueue(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + timer.submit(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + listener.onVotiferEvent(receipt()); + var original = retainedReceipt(); + try { + ready.set(false); listener.stop(); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); + workerTurn(overflow); overflow.close(); + var rows = disk(); + assertEquals(257, rows.size(), "Closing a full overflow must retain its accepted normal capture too"); + assertTrue(rows.stream().map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))).toList() + .containsAll(recovered)); + var retained = rows.stream().filter(row -> original.id().toString().equals(row.get("LocalOccurrenceId"))).toList(); + assertEquals(1, retained.size()); assertEquals(original.time(), ((Number) retained.getFirst().get("Time")).longValue()); + timer = timer(); when(plugin.getVoteTimer()).thenReturn(timer); + ready.set(true); + var delivered = new CountDownLatch(257); + doAnswer(call -> { + var event = call.getArgument(0, PlayerVoteEvent.class); + event.setNativeProcessingCompleted(true); processed.add(event); delivered.countDown(); return null; + }).when(events).callEvent(any(PlayerVoteEvent.class)); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + overflow.start(); assertTrue(delivered.await(10, TimeUnit.SECONDS)); + processingTurn(); workerTurn(overflow); + assertEquals(257, processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + var replay = processed.stream().filter(event -> original.id().equals(event.getLocalOccurrenceId())).toList(); + assertEquals(1, replay.size()); assertEquals(original.time(), replay.getFirst().getCanonicalOccurrenceTime()); + } finally { release.countDown(); } + } + + @Test void failedFinalWriteRetainsOwnershipUntilRepairAndPreservesTheCaptureForASuccessor() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + recoverFullQueue(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + timer.submit(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + listener.onVotiferEvent(receipt()); var original = retainedReceipt(); + Path target = directory.resolve("VotifierVoteQueue.yml"); + Files.delete(target); Files.createDirectory(target); + Path blocker = target.resolve("synthetic-blocker"); Files.writeString(blocker, "synthetic"); + VotifierVoteOverflowQueue successor = null; + try { + ready.set(false); listener.stop(); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); + overflow.close(); + successor = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> {}, () -> false); + workerTurn(successor); + assertFalse(successor.isInitializationComplete(), "successor cannot initialize while final publication is uncertain"); + Files.delete(blocker); Files.delete(target); + overflow.close(); assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!successor.isInitializationComplete() && System.nanoTime() < deadline) Thread.sleep(10); + assertTrue(successor.isInitializationComplete()); assertEquals(257, successor.size()); + var rows = disk(); assertEquals(257, rows.size()); + var row = rows.stream().filter(value -> original.id().toString().equals(value.get("LocalOccurrenceId"))).toList(); + assertEquals(1, row.size()); assertEquals(original.time(), ((Number) row.getFirst().get("Time")).longValue()); + } finally { + release.countDown(); + if (Files.isDirectory(target)) { Files.deleteIfExists(blocker); Files.delete(target); } + overflow.close(); + if (successor != null) successor.close(); + } + } + + @Test void closingReplacementKeepsProvisionalCaptureUntilPredecessorReleasesOwnership() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + VotifierVoteOverflowQueue predecessor = overflow; + UUID recovered = UUID.randomUUID(); + assertTrue(predecessor.enqueue("Steve", SITE, 123L, recovered)); + workerTurn(predecessor); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); + workerTurn(overflow); + assertFalse(overflow.isInitializationComplete()); + listener.onVotiferEvent(receipt()); + var original = retainedReceipt(); + try { + ready.set(false); listener.stop(); + long began = System.nanoTime(); overflow.close(); + assertTrue(System.nanoTime() - began < TimeUnit.SECONDS.toNanos(2)); + assertFalse(worker(overflow).isShutdown(), "closing worker must retain provisional retirement ownership"); + predecessor.close(); + assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + var rows = disk(); assertEquals(2, rows.size()); + assertEquals(Set.of(recovered, original.id()), rows.stream() + .map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet())); + var retained = rows.stream().filter(row -> original.id().toString().equals(row.get("LocalOccurrenceId"))).findFirst().orElseThrow(); + assertEquals(original.time(), ((Number) retained.get("Time")).longValue()); + } finally { + predecessor.close(); overflow.close(); + } + } + + @Test void closingReplacementRetainsCapturesAcrossUnknownStorageUntilValidRepair() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + VotifierVoteOverflowQueue predecessor = overflow; + UUID recovered = UUID.randomUUID(); + assertTrue(predecessor.enqueue("Steve", SITE, 123L, recovered)); workerTurn(predecessor); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + listener.onVotiferEvent(receipt()); var original = retainedReceipt(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + Path file = directory.resolve("VotifierVoteQueue.yml"); byte[] valid = null; + try { + ready.set(false); listener.stop(); overflow.close(); + worker(overflow).execute(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + predecessor.close(); valid = Files.readAllBytes(file); + String broken = "Votes: [unterminated"; Files.writeString(file, broken); release.countDown(); + var failed = VotifierVoteOverflowQueue.class.getDeclaredField("loadFailed"); failed.setAccessible(true); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!failed.getBoolean(overflow) && System.nanoTime() < deadline) Thread.sleep(10); + assertTrue(failed.getBoolean(overflow)); assertFalse(worker(overflow).isShutdown()); + assertEquals(broken, Files.readString(file)); + Files.delete(file); Thread.sleep(600); + assertFalse(Files.exists(file), "observed unknown history cannot become an empty install"); + assertFalse(worker(overflow).isShutdown()); + Files.write(file, valid); + assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + assertEquals(Set.of(recovered, original.id()), disk().stream() + .map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet())); + } finally { + release.countDown(); predecessor.close(); + if (!Files.exists(file) && valid != null) Files.write(file, valid); + overflow.close(); + } + } + + @Test void legacyConstructorRetirementRunsBeforeItsFinalSnapshot() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); overflow.close(); + overflow = new VotifierVoteOverflowQueue(plugin, (site, user) -> {}); + UUID id = UUID.randomUUID(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + worker(overflow).execute(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var accepted = new java.util.concurrent.atomic.AtomicBoolean(); + overflow.retireCaptures(List.of(new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 123L, id, + () -> true, () -> false, accepted::set))); + try { + overflow.close(); assertFalse(worker(overflow).isShutdown()); release.countDown(); + assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(accepted.get()); assertEquals(id.toString(), disk().getFirst().get("LocalOccurrenceId")); + } finally { release.countDown(); overflow.close(); } + } + + @Test void waitingRetirementAdmissionIsGloballyBounded() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + VotifierVoteOverflowQueue predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> {}, () -> false); + workerTurn(overflow); + var batch = new ArrayList(); + for (int i = 0; i < 256; i++) batch.add(new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 123L, + UUID.randomUUID(), () -> true, () -> false, accepted -> {})); + var rejected = new java.util.concurrent.atomic.AtomicReference(); + try { + overflow.retireCaptures(batch); + overflow.retireCaptures(List.of(new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 456L, + UUID.randomUUID(), () -> true, () -> false, rejected::set))); + assertEquals(Boolean.FALSE, rejected.get()); overflow.close(); predecessor.close(); + assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); assertEquals(256, disk().size()); + } finally { predecessor.close(); overflow.close(); } + } + + @Test void throwingRetirementCallbackDoesNotPreventTheRestOfTheBatchBeingRetained() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + assertEquals(VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED, overflow.reserveCapture(first)); + assertEquals(VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED, overflow.reserveCapture(second)); + var completed = new CountDownLatch(1); + overflow.retireCaptures(List.of( + new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 123L, first, () -> true, () -> false, + accepted -> { throw new IllegalStateException("synthetic callback failure"); }), + new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 456L, second, () -> true, () -> false, + accepted -> { assertTrue(accepted); completed.countDown(); }))); + assertTrue(completed.await(5, TimeUnit.SECONDS)); + overflow.close(); + assertEquals(Set.of(first, second), disk().stream().map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet())); + } + + @Test void combinedFullSnapshotRemainsBoundedAcrossRepeatedRecoveryAndFreshIngress() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var recovered = recoverFullQueue(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + timer.submit(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt()); + assertEquals(256, listener.getPendingCaptureCount()); + try { + ready.set(false); listener.stop(); listener.stop(); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); + workerTurn(overflow); overflow.close(); + var rows = disk(); assertEquals(512, rows.size()); + var ids = rows.stream().map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet()); + assertEquals(512, ids.size()); assertTrue(ids.containsAll(recovered)); + for (int cycle = 0; cycle < 2; cycle++) { + timer = timer(); when(plugin.getVoteTimer()).thenReturn(timer); + listener = new VotiferEvent(plugin, ready::get); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + assertEquals(512, overflow.size()); + listener.onVotiferEvent(receipt()); + assertEquals(0, listener.getPendingCaptureCount(), "full recovered ownership cannot admit another normal capture"); + assertFalse(overflow.enqueue("Steve", SITE, 123L)); + listener.stop(); workerTurn(overflow); overflow.close(); + assertEquals(ids, disk().stream().map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet())); + assertTrue(processed.isEmpty()); timer.shutdownNow(); + } + } finally { release.countDown(); } + } + + @Test void fullQueueRetirementTransfersStartedOwnershipWithoutLosingTheReceipt() throws Exception { + for (int index = 0; index < 256; index++) assertTrue(overflow.enqueue("Steve", SITE, 123L)); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IllegalStateException("interrupted", interrupted); } + return null; + }).when(serverData).addServiceSite(SITE); + listener.onVotiferEvent(receipt()); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); ready.set(false); listener.stop(); workerTurn(overflow); + assertEquals(0, listener.getPendingCaptureCount()); assertEquals(257, overflow.size()); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); + assertEquals(0, listener.getPendingCaptureCount()); assertTrue(processed.isEmpty()); + overflow.close(); assertEquals(257, disk().size()); + } finally { release.countDown(); } + } + @Test void completionAfterSealedSnapshotReportsUncertaintyWithoutRetiredOverwrite() throws Exception { + var logger = mock(Logger.class); when(plugin.getLogger()).thenReturn(logger); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + return null; + }).when(serverData).addServiceSite(SITE); + listener.onVotiferEvent(receipt()); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); ready.set(false); listener.stop(); workerTurn(overflow); overflow.close(); + byte[] sealed = Files.readAllBytes(directory.resolve("VotifierVoteQueue.yml")); assertEquals(1, disk().size()); + release.countDown(); processingTurn(); assertEquals(1, processed.size()); + verify(logger).warning(contains("outlived queue shutdown")); + assertArrayEquals(sealed, Files.readAllBytes(directory.resolve("VotifierVoteQueue.yml"))); + } finally { release.countDown(); } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index 6c3994cf37..b30492d339 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -137,7 +137,7 @@ void retainsUnacknowledgedCallbackAcrossShutdown(@TempDir Path dataFolder) throw @Test void retriesPersistenceAfterTransientWriteFailure(@TempDir Path temporaryDirectory) throws Exception { Path dataFolder = temporaryDirectory.resolve("data"); - Files.writeString(dataFolder, "temporarily blocking the data directory"); + Files.createDirectory(dataFolder); VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); Logger logger = mock(Logger.class); ScheduledExecutorService voteTimer = mock(ScheduledExecutorService.class); @@ -148,12 +148,13 @@ void retriesPersistenceAfterTransientWriteFailure(@TempDir Path temporaryDirecto .when(voteTimer).submit(any(Runnable.class)); VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user) -> { }); + // Fail publication after initialization establishes exclusive ownership. + Files.createDirectory(dataFolder.resolve("VotifierVoteQueue.yml")); try { assertTrue(queue.enqueue("Steve", "example.org")); verify(logger, timeout(2_000)).warning(org.mockito.ArgumentMatchers.contains( "Unable to persist queued Votifier votes")); - Files.delete(dataFolder); - Files.createDirectory(dataFolder); + Files.delete(dataFolder.resolve("VotifierVoteQueue.yml")); waitForFile(dataFolder.resolve("VotifierVoteQueue.yml")); } finally { queue.close(); @@ -163,7 +164,7 @@ void retriesPersistenceAfterTransientWriteFailure(@TempDir Path temporaryDirecto @Test void doesNotDrainUntilWriteFailureIsRecovered(@TempDir Path temporaryDirectory) throws Exception { Path dataFolder = temporaryDirectory.resolve("data"); - Files.writeString(dataFolder, "temporarily blocking the data directory"); + Files.createDirectory(dataFolder); VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); Logger logger = mock(Logger.class); ScheduledExecutorService voteTimer = mock(ScheduledExecutorService.class); @@ -177,6 +178,8 @@ void doesNotDrainUntilWriteFailureIsRecovered(@TempDir Path temporaryDirectory) }).when(voteTimer).submit(any(Runnable.class)); VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, (site, user) -> { }); + // Fail publication after initialization establishes exclusive ownership. + Files.createDirectory(dataFolder.resolve("VotifierVoteQueue.yml")); try { assertTrue(queue.enqueue("Steve", "example.org")); queue.start(); @@ -184,8 +187,7 @@ void doesNotDrainUntilWriteFailureIsRecovered(@TempDir Path temporaryDirectory) "Unable to persist queued Votifier votes")); verify(voteTimer, org.mockito.Mockito.never()).submit(any(Runnable.class)); - Files.delete(dataFolder); - Files.createDirectory(dataFolder); + Files.delete(dataFolder.resolve("VotifierVoteQueue.yml")); waitForFile(dataFolder.resolve("VotifierVoteQueue.yml")); assertTrue(submissionAccepted.await(2, TimeUnit.SECONDS), "queued vote was not admitted after persistence recovered"); @@ -255,6 +257,99 @@ void stalePersistenceWorkerCannotOverwriteShutdownSnapshot(@TempDir Path dataFol } } + @Test + void legacyRowsReceiveDistinctDurableIdsBeforeDrainAndMalformedIdsFailClosed(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor(); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getVoteTimer()).thenReturn(voteTimer); + Path file = dataFolder.resolve("VotifierVoteQueue.yml"); + Files.writeString(file, """ + Votes: + - {Username: Steve, ServiceSite: example.org, Time: 123} + - {Username: Steve, ServiceSite: example.org, Time: 123} + - {Username: Steve, ServiceSite: example.org, Time: 123, LocalOccurrenceId: bad} + - {Username: Steve, ServiceSite: example.org, Time: 123, LocalOccurrenceId: 1-1-1-1-1} + - {Username: Steve, ServiceSite: example.org, Time: 123, LocalOccurrenceId: 12} + """); + var received = new java.util.concurrent.CopyOnWriteArrayList(); + var done = new CountDownLatch(2); + VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, + (site, user, time, id) -> { received.add(id); done.countDown(); }, () -> true); + try { + assertEquals(2, queue.size()); + // Closing before admission forces the automatic legacy migration to disk. + queue.close(); + var durable = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(file.toFile()).getMapList("Votes"); + assertEquals(2, durable.size()); + var expected = durable.stream().map(row -> java.util.UUID.fromString((String) row.get("LocalOccurrenceId"))).toList(); + assertEquals(2, expected.stream().distinct().count(), "identical historical receipt fields are distinct rows"); + try (var restarted = new VotifierVoteOverflowQueue(plugin, (site, user, time, id) -> { + var admitted = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(file.toFile()).getMapList("Votes"); + assertTrue(admitted.stream().anyMatch(row -> id.toString().equals(row.get("LocalOccurrenceId"))), + "the occurrence must be published before admission"); + assertEquals(123L, time); + received.add(id); done.countDown(); + }, () -> true)) { + restarted.start(); + assertTrue(done.await(3, TimeUnit.SECONDS)); + assertEquals(expected, received); + } + } finally { queue.close(); voteTimer.shutdownNow(); } + } + + @Test + void asyncLoadingPreservesFullRecoveredQueueAndRejectsNewTransfers(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + try (var seed = new VotifierVoteOverflowQueue(plugin, (site, user) -> { })) { + for (int index = 0; index < 256; index++) assertTrue(seed.enqueue("Steve", "example.org", 123L)); + org.junit.jupiter.api.Assertions.assertFalse(seed.enqueue("Steve", "example.org", 123L)); + } + var before = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(dataFolder.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + var transferred = new java.util.concurrent.CompletableFuture(); + try (var resumed = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> { }, () -> false)) { + resumed.enqueueAfterInitialization("Alex", "new.example.org", 456L, java.util.UUID.randomUUID(), transferred::complete); + org.junit.jupiter.api.Assertions.assertFalse(transferred.get(3, TimeUnit.SECONDS)); + assertEquals(256, resumed.size()); + } + var after = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(dataFolder.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + assertEquals(before, after, "recovered identities must not be overwritten or truncated"); + } + + @Test + void asyncUnreadableStoreRejectsTransferAndClosePreservesOriginalFile(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + Path file = dataFolder.resolve("VotifierVoteQueue.yml"); + String corrupt = "Votes: {not: a-list}\n"; Files.writeString(file, corrupt); + var transferred = new java.util.concurrent.CompletableFuture(); + try (var queue = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> { }, () -> false)) { + queue.enqueueAfterInitialization("Steve", "example.org", 456L, java.util.UUID.randomUUID(), transferred::complete); + org.junit.jupiter.api.Assertions.assertFalse(transferred.get(3, TimeUnit.SECONDS)); + } + assertEquals(corrupt, Files.readString(file)); + } + + @Test + void overCapacityRecoveredStoreFailsClosedWithoutTruncation(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + Path file = dataFolder.resolve("VotifierVoteQueue.yml"); + String original = "Votes:\n" + ("- {Username: Steve, ServiceSite: example.org, Time: 123}\n").repeat(513); + Files.writeString(file, original); + var transferred = new java.util.concurrent.CompletableFuture(); + try (var queue = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> { }, () -> false)) { + queue.enqueueAfterInitialization("Alex", "new.example.org", 456L, java.util.UUID.randomUUID(), transferred::complete); + org.junit.jupiter.api.Assertions.assertFalse(transferred.get(3, TimeUnit.SECONDS)); + assertEquals(0, queue.size(), "an over-capacity file cannot be partially admitted"); + } + assertEquals(original, Files.readString(file)); + } + private static void waitForFile(Path file) throws Exception { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); while (!Files.exists(file) && System.nanoTime() < deadline) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 5f5618348f..26622e37cf 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -108,4 +108,119 @@ void rejectedProcessingSchedulesOneBoundedRetry() { org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS)); assertEquals(1, handler.getTimeChangeQueue().size()); } + + @Test + void actualCacheWriterAndReplayKeepProcessingAndOccurrenceClocksSeparate() throws Exception { + var stored = new org.bukkit.configuration.file.YamlConfiguration(); + ServerData writer = org.mockito.Mockito.spy(new ServerData(plugin)); + org.mockito.Mockito.doReturn(stored).when(writer).getData(); + org.mockito.Mockito.doNothing().when(writer).saveData(); + var captured = new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Steve", "example.org", 789L); + java.util.UUID occurrenceId = java.util.UUID.randomUUID(); + captured.setLocalOccurrenceId(occurrenceId); + captured.setCanonicalOccurrenceTime(456L); + writer.addTimeVoted(0, captured); + writer.addTimeVoted(1, new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Alex", "example.org", 123L)); + assertEquals(occurrenceId.toString(), stored.getString("TimedVoteCache.0.LocalOccurrenceId")); + assertEquals(789L, stored.getLong("TimedVoteCache.0.Time")); + assertEquals(456L, stored.getLong("TimedVoteCache.0.CanonicalOccurrenceTime")); + org.junit.jupiter.api.Assertions.assertFalse(stored.contains("TimedVoteCache.1.CanonicalOccurrenceTime")); + var restored = new org.bukkit.configuration.file.YamlConfiguration(); + restored.loadFromString(stored.saveToString()); + // Malformed optional metadata fails Date provenance, retaining ordinary replay. + restored.set("TimedVoteCache.2.Name", "Invalid"); restored.set("TimedVoteCache.2.Service", "example.org"); + restored.set("TimedVoteCache.2.Time", 321L); restored.set("TimedVoteCache.2.CanonicalOccurrenceTime", "bad"); + restored.set("TimedVoteCache.3.Name", "Corrupt"); restored.set("TimedVoteCache.3.Service", "example.org"); + restored.set("TimedVoteCache.3.Time", 654L); restored.set("TimedVoteCache.3.LocalOccurrenceId", "bad"); + restored.set("TimedVoteCache.4.Name", "ShortId"); restored.set("TimedVoteCache.4.Service", "example.org"); + restored.set("TimedVoteCache.4.Time", 654L); restored.set("TimedVoteCache.4.LocalOccurrenceId", "1-1-1-1-1"); + org.mockito.Mockito.doReturn(restored).when(writer).getData(); + when(plugin.getServerData()).thenReturn(writer); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + var replayed = new java.util.ArrayList(); + var eventManager = plugin.getServer().getPluginManager(); + org.mockito.Mockito.doAnswer(call -> { replayed.add(call.getArgument(0)); return null; }) + .when(eventManager).callEvent(any()); + var migratedId = handler.getTimeChangeQueue().stream().filter(vote -> "Alex".equals(vote.getName())) + .findFirst().orElseThrow().getLocalOccurrenceId(); + assertEquals(migratedId.toString(), restored.getString("TimedVoteCache.1.LocalOccurrenceId")); + var secondResume = new TimeQueueHandler(plugin); + assertEquals(migratedId, secondResume.getTimeChangeQueue().stream().filter(vote -> "Alex".equals(vote.getName())) + .findFirst().orElseThrow().getLocalOccurrenceId()); + handler.processQueue(); + assertEquals(3, replayed.size()); + var byName = replayed.stream().collect(java.util.stream.Collectors.toMap(PlayerVoteEvent::getPlayer, event -> event)); + assertEquals(occurrenceId, byName.get("Steve").getLocalOccurrenceId()); + org.junit.jupiter.api.Assertions.assertNull(byName.get("Steve").getProxyVoteId()); + org.junit.jupiter.api.Assertions.assertFalse(byName.get("Steve").isBungee()); + assertEquals(migratedId, byName.get("Alex").getLocalOccurrenceId()); + assertEquals(789L, byName.get("Steve").getTime()); assertEquals(Long.valueOf(456L), byName.get("Steve").getCanonicalOccurrenceTime()); + assertEquals(123L, byName.get("Alex").getTime()); org.junit.jupiter.api.Assertions.assertNull(byName.get("Alex").getCanonicalOccurrenceTime()); + assertEquals(321L, byName.get("Invalid").getTime()); assertEquals(Long.valueOf(-1L), byName.get("Invalid").getCanonicalOccurrenceTime()); + } + + @Test + void actualAdmissionSaveAndRestartReplayRetainLocalId() throws Exception { + var stored = new org.bukkit.configuration.file.YamlConfiguration(); + ServerData writer = org.mockito.Mockito.spy(new ServerData(plugin)); + org.mockito.Mockito.doReturn(stored).when(writer).getData(); + org.mockito.Mockito.doNothing().when(writer).saveData(); + when(plugin.getServerData()).thenReturn(writer); + TimeQueueHandler original = new TimeQueueHandler(plugin); + java.util.UUID occurrenceId = java.util.UUID.randomUUID(); + original.addVote("Steve", "example.org", 456L, occurrenceId); + long queueTime = original.getTimeChangeQueue().element().getTime(); + original.save(); + var restored = new org.bukkit.configuration.file.YamlConfiguration(); + restored.loadFromString(stored.saveToString()); + org.mockito.Mockito.doReturn(restored).when(writer).getData(); + TimeQueueHandler resumed = new TimeQueueHandler(plugin); + org.mockito.ArgumentCaptor replay = org.mockito.ArgumentCaptor.forClass(PlayerVoteEvent.class); + resumed.processQueue(); + verify(plugin.getServer().getPluginManager()).callEvent(replay.capture()); + assertEquals(occurrenceId, replay.getValue().getLocalOccurrenceId()); + assertEquals(queueTime, replay.getValue().getTime()); + assertEquals(Long.valueOf(456L), replay.getValue().getCanonicalOccurrenceTime()); + org.junit.jupiter.api.Assertions.assertNull(replay.getValue().getProxyVoteId()); + } + + @Test + void legacyTimedIdsAreStableEvenIfSaveHasNotPublishedAndIdenticalRowsRemainDistinct() throws Exception { + var legacy = new org.bukkit.configuration.file.YamlConfiguration(); + for (String key : java.util.List.of("0", "1")) { + legacy.set("TimedVoteCache." + key + ".Name", "Steve"); + legacy.set("TimedVoteCache." + key + ".Service", "example.org"); + legacy.set("TimedVoteCache." + key + ".Time", 123L); + } + String beforeMigration = legacy.saveToString(); + ServerData writer = org.mockito.Mockito.spy(new ServerData(plugin)); + org.mockito.Mockito.doReturn(legacy).when(writer).getData(); + org.mockito.Mockito.doNothing().when(writer).saveData(); + when(plugin.getServerData()).thenReturn(writer); + var first = new TimeQueueHandler(plugin).getTimeChangeQueue().stream() + .map(com.bencodez.votingplugin.timequeue.VoteTimeQueue::getLocalOccurrenceId).toList(); + assertEquals(2, first.stream().distinct().count()); + org.mockito.Mockito.verify(writer, org.mockito.Mockito.times(2)).saveData(); + // Simulate a restart that sees only the old on-disk representation. + var unpublished = new org.bukkit.configuration.file.YamlConfiguration(); + unpublished.loadFromString(beforeMigration); + org.mockito.Mockito.doReturn(unpublished).when(writer).getData(); + var second = new TimeQueueHandler(plugin).getTimeChangeQueue().stream() + .map(com.bencodez.votingplugin.timequeue.VoteTimeQueue::getLocalOccurrenceId).toList(); + assertEquals(first, second); + } + + @Test + void timeChangeAdmissionPreservesCapturedReceiptWithoutReplacingQueueTime() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + java.util.UUID occurrenceId = java.util.UUID.randomUUID(); + long before = System.currentTimeMillis(); handler.addVote("Steve", "example.org", 456L, occurrenceId); long after = System.currentTimeMillis(); + var queued = handler.getTimeChangeQueue().element(); + org.junit.jupiter.api.Assertions.assertTrue(queued.getTime() >= before && queued.getTime() <= after); + assertEquals(456L, queued.getCanonicalOccurrenceTime()); + assertEquals(occurrenceId, queued.getLocalOccurrenceId()); + org.junit.jupiter.api.Assertions.assertNull(queued.getVoteId()); + } + } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java new file mode 100644 index 0000000000..e52842c128 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java @@ -0,0 +1,77 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerVoteEvent; + +class ProxyVoteRealFlagTest { + @Test void accountingFailureFlagSurvivesBukkitDispatchAndRejectsCompletion() throws Exception { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + var user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(user, plugin); + doReturn("Player").when(user).getPlayerName(); + var dispatcher = plugin.getServer().getPluginManager(); + doAnswer(call -> { ((PlayerVoteEvent) call.getArgument(0)).setDateMilestoneAccountingFailed(true); return null; }) + .when(dispatcher).callEvent(any()); + assertThrows(IllegalStateException.class, () -> user.bungeeVotePluginMessaging("service", 123, null, + false, false, false, 1, false, true, true, UUID.randomUUID(), false, true)); + verify(plugin.getServer().getPluginManager(), times(1)).callEvent(any()); + } + @Test void nativeBackendAdapterPreservesCanonicalTestFlagAndLegacyDefaults() throws Exception { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + var user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(user, plugin); + doReturn("Player").when(user).getPlayerName(); + UUID id = UUID.randomUUID(); + user.bungeeVotePluginMessaging("service", 123, null, false, false, false, 1, + false, true, true, id, false, false); + user.bungeeVotePluginMessaging("service", 123, null, false, false, false, 1, + false, true, true, id, false); + var event = ArgumentCaptor.forClass(org.bukkit.event.Event.class); + verify(plugin.getServer().getPluginManager(), times(2)).callEvent(event.capture()); + var fake = (PlayerVoteEvent) event.getAllValues().get(0); + var legacy = (PlayerVoteEvent) event.getAllValues().get(1); + assertFalse(fake.isRealVote()); assertTrue(legacy.isRealVote()); + assertEquals(id, fake.getProxyVoteId()); assertEquals(123, fake.getTime()); + assertTrue(fake.isBungee()); assertFalse(fake.isTargetedProxyVote()); + } + @Test void returnedEventAdaptersPreserveCancellationAndIndependentOccurrenceTime() throws Exception { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + var user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(user, plugin); + doReturn("Player").when(user).getPlayerName(); + var dispatcher = plugin.getServer().getPluginManager(); + doAnswer(call -> { ((PlayerVoteEvent) call.getArgument(0)).setCancelled(true); return null; }) + .when(dispatcher).callEvent(any()); + UUID id = UUID.randomUUID(); + var legacy = user.bungeeVotePluginMessagingEvent("service", 123, null, false, false, false, 1, + false, true, true, id, true); + var explicit = user.bungeeVotePluginMessagingEvent("service", 123, null, false, false, false, 1, + false, true, true, id, true, false, 7L); + assertNotNull(legacy); assertNotNull(explicit); + assertInstanceOf(org.bukkit.event.Cancellable.class, legacy); + assertTrue(legacy.isCancelled()); assertTrue(explicit.isCancelled()); + assertTrue(legacy.isRealVote()); assertFalse(explicit.isRealVote()); + assertEquals(123L, legacy.getCanonicalOccurrenceTime()); + assertEquals(7L, explicit.getCanonicalOccurrenceTime()); + assertEquals(123L, explicit.getTime()); + assertEquals(id, explicit.getProxyVoteId()); + assertTrue(explicit.isTargetedProxyVote()); + var events = ArgumentCaptor.forClass(org.bukkit.event.Event.class); + verify(dispatcher, times(2)).callEvent(events.capture()); + assertSame(legacy, events.getAllValues().get(0)); + assertSame(explicit, events.getAllValues().get(1)); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(false); + assertNull(user.bungeeVotePluginMessagingEvent("service", 123, null, false, false, false, 1, + false, true, true, id, true)); + verify(dispatcher, times(2)).callEvent(any()); + } +} diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md new file mode 100644 index 0000000000..9963889d5e --- /dev/null +++ b/docs/date-vote-milestones.md @@ -0,0 +1,174 @@ +# DateVoteMilestones + +Owners define named voting events in `SpecialRewards.yml`. There are no built-in holidays, recurring rules, additional vote totals, or alternate reward engines. Existing lifetime/monthly totals and normal rewards continue unchanged. The shipped example event is explicitly `Enabled: false` and enables no date rewards. Configure its dates and rewards before opting in. + +Complete opt-in example: + +```yaml +DateVoteMilestones: + october_2026: + Enabled: true + DisplayName: 'October voting event' + Start: '2026-10-01T00:00:00' + End: '2026-11-01T00:00:00' + Timezone: 'UTC' + # Standalone: empty/omitted means local accounting. In proxy mode it disables + # new date accounting; set one exact backend Server name on every backend. + AccountingServer: '' + # Configured VoteSites.yml keys, NOT Votifier service names. Empty means all. + VoteSites: [] + Milestones: + TenVotes: + Enabled: true + At: 10 + Rewards: + Messages: + Player: '&aYou reached 10 votes in the October voting event!' + TwentyFiveVotes: + Enabled: true + At: 25 + Rewards: + Messages: + Player: '&aYou reached 25 votes in the October voting event!' + Commands: + - 'give %player% diamond 1' +``` + +Reload after configuration. `/vote dateevents` opens the event list. Select an event for milestone details; use the page arrows and back button to navigate. The menu shows the player's event counts, next enabled threshold, active/upcoming/ended/disabled status, date window and timezone. Milestone details show submitted, deferred or pending-review state and configured reward types without running or displaying raw reward commands. Permission: `VotingPlugin.Commands.Vote.DateEvents` or existing `VotingPlugin.Player`. Progress inspection runs on a storage worker and opens the menu on the player's owner scheduler. It is a snapshot: run the command again after another vote for updated counts. Reload invalidates earlier menu navigation; reopen the command after changing event configuration. Disabled valid events remain visible, remote-owner events identify their accounting backend, and unavailable progress is shown as unavailable rather than zero. It never submits rewards. No new PlaceholderAPI placeholder is added; the progress command is the supported display. + +## Milestone format and compatibility + +Date milestones use named reward entries, like `VoteMilestones`, with `Enabled` (defaults to true), `At` and `Every`. The total is always this event's accepted occurrence count. `At` supports an integer, a list, or ranges such as `'10..50 step 10'`; `At: 0` means the first vote, matching the existing milestone convention. `Every: 128` expands to multiples through the event's maximum of 4096 counted occurrences. `At` and `Every` together form a union. Each event supports at most 64 unique thresholds; an expansion exceeding that bound is rejected, so use explicit milestones or a larger interval. A threshold may belong to only one reward entry. `Total`, `Group` and `Limit` are not supported here: each threshold has one persistent award, and the explicit event window supplies its accounting period. + +Existing numeric sections such as `Milestones.'10'.Rewards` remain supported without conversion. You may rename them to named entries with `At: 10`, or regroup the same thresholds into one `At` list, keeping the event ID and accounting fields. The ledger seal and queued reward aliases remain based on the threshold, so this does not reset progress or repeat reserved/submitted rewards. Rewards shared by one entry apply at each of its thresholds; queued aliases resolve the currently configured reward section. Do not overlap thresholds between entries. Changing the expanded threshold set after accounting begins still requires a new event ID. + +A milestone's `Enabled: false` prevents new reward submissions while keeping its threshold, progress and queued reward handle. Reached, unattempted awards stay deferred. Re-enabling permits submission on the next qualifying accepted notification, including an identified replay, without recounting that occurrence. Reserved/submitted awards are never retried automatically. Keep disabled reward definitions until queued rewards finish. Invalid milestone types, malformed ranges and ambiguous reward definitions disable their event; valid sibling events continue. + +## Membership and ownership + +Start is inclusive; end is exclusive. Values are local ISO date-times interpreted with the explicit IANA timezone, such as `UTC` or `America/New_York`. Nonexistent or ambiguous daylight-saving boundaries are rejected; choose an unambiguous boundary. Invalid events are disabled with an explanation without disabling valid sibling events. Stable event IDs permit 1–64 letters, digits, `_` and `-`. Up to 64 configured definitions and 64 positive thresholds per event are supported; thresholds are bounded to 4096. + +Membership uses the accepted pipeline's authoritative epoch-millisecond occurrence time and occurrence UUID. Standalone ingress captures its timestamp and a fresh local occurrence UUID once at receipt, before executor admission; an external site's untrusted timestamp is not used. Overflow retries and local time-change cache recovery retain that UUID, so a crash after ledger publication but before queue acknowledgement cannot recount the receipt or unlock another date milestone. Distinct receipts receive distinct UUIDs even when their player, service and timestamp match. Current identified proxy deliveries carry a separate `canonicalOccurrenceTime` and original UUID, including delayed offline delivery. BungeeCord and Velocity capture that original occurrence time when admitting the receipt, before scheduler handoff or identity resolution; durable snapshots preserve it even when normal processing has not started. This timestamp remains separate from the receiving proxy's normal processing and cooldown time. Ordinary event processing time, LastVotes, cooldowns, streaks, and post-vote `voteTime` retain their existing receiving-proxy clock semantics. The native vote and post-vote events expose the separate occurrence timestamp. `PlayerVoteEvent.localOccurrenceId` carries only local receipt identity; it does not populate `proxyVoteId` or imply proxy provenance. The existing post-vote `voteUUID` carries the effective occurrence UUID. A vote accepted during a window can count even when delivered after its end. Votes before start or at/after end do not count. Fake/test votes are always excluded. A matching occurrence counts once per player/event; overlapping events each count it once. Enabled-site validation remains part of the normal accepted-vote pipeline. An optional filter selects configured site keys. + +For a proxy network, **one backend owns each event ledger**. Set `AccountingServer` to that backend's exact configured server name. Use identified **all-server** delivery so that accounting backend receives every occurrence. Other backends do not increment the event, and their progress command directs players to the owner. Targeted delivery, legacy proxy deliveries missing original IDs/times, and local backend ingress on a proxy-configured server are intentionally excluded. They cannot reliably provide both complete event progress and network deduplication. This does not change their existing normal vote processing. A proxy event without an accounting owner is disabled. Moving the owner requires preserving the owner-local ledger and a new event ID; this feature does not migrate or replicate ledgers automatically. + +## AccountingServer configuration + +`AccountingServer` selects the backend that maintains the event ledger. Its value is the exact, case-sensitive `Server` from that backend's `BungeeSettings.yml`. + +| Installation | AccountingServer | New date accounting | +| --- | --- | --- | +| Standalone (`UseBungeecord: false`) | Empty or omitted | Local accepted votes can count and reach configured milestones. | +| Proxy backend (`UseBungeecord: true`) | Empty, omitted or blank | The event is rejected during reload; no new event progress or milestone submission occurs. Other valid events continue. | +| Proxy backend | This backend's exact server name | Identified all-server deliveries can count here. | +| Proxy backend | Another backend's name | This backend does not increment that event; progress points to the accounting owner. | + +For a proxy network, reuse the complete example above on the participating backends and replace its owner line with the selected backend, for example: + +```yaml + AccountingServer: 'event-owner' +``` + +Set `BungeeSettings.yml -> Server: event-owner` on that selected backend and give every other backend its own distinct server name. Configure identified all-server vote delivery so the owner receives each occurrence. A missing owner or a name whose case differs from the owner's server name prevents accounting there. Changing the owner after the event is sealed requires a new event ID and preserving the original ledger for reconciliation. + +Configure the same event ID, window, timezone, accounting owner, site filters and expanded milestone thresholds on backends using the event. Each backend that may receive queued or shared rewards needs a corresponding `Milestones..Rewards` section with matching `At`/`Every` thresholds, or the supported legacy numeric reward sections. Use one entry per threshold; do not add numeric sections alongside named entries for the same counts. Keep reward contents consistent with the intended delivery, including any deliberate server-specific differences. The accounting owner stores progress locally; neither owner selection nor shared vote totals copy or compare another backend's event definitions or reward handles. + +An event omitted or invalid on the accounting owner cannot accumulate new progress. An event omitted on another backend has no local progress entry or reward handle. Missing threshold sections can prevent a queued reward from resolving, and different sections can resolve the same reward identity to different configured effects. Test the intended receiving backends before enabling the event and retain disabled definitions and reward sections while queued rewards remain. + +Reward submission uses the existing AdvancedCore reward handler and the normal vote-delivery server context. An empty `AccountingServer` does not enable global date rewards in proxy mode. Existing queued reward handles are registered separately from accounting validation, so an invalid owner setting can stop new accounting while an earlier award remains resolvable. + +## Persistence, configuration changes and rewards + +Progress is stored under the accounting backend's plugin data directory, `date-vote-milestones/`, without changing existing SQL schemas or user fields. Back up that directory with the normal plugin data. Each record retains up to 4096 original occurrence UUIDs and reserved/submitted milestone state. The store has a 100,000 player/event-record capacity and a 1024 historical-definition capacity; reaching a limit rejects additional accounting rather than deleting existing state. Retention is explicit: finished events and their files are not automatically removed or recycled. Do not delete ledgers and reuse their IDs, which would re-enable grants. + +Ledger transactions run on persistence workers. Instances using this transaction protocol, including instances in different plugin classloaders, serialize on the canonical accounting directory before opening its lock file. Each transaction also attempts a non-waiting cross-process file lock while it reads and publishes state; an unavailable lock fails closed, preserving existing progress. This prevents participating delayed and replacement instances from overwriting each other's state. New player/event records check the current directory contents under the same fence, so replacing an instance cannot bypass the 100,000-record capacity; existing records remain updatable at capacity. + +After the first qualifying vote, the event ID seals its start, end, timezone, thresholds, site filters and accounting owner. Changing those fields requires a **new ID**; unsafe edits are rejected while existing progress is preserved. Display names and enabled state can change without resetting counts. Changing reward contents affects only future submissions, never repeats a submitted/reserved threshold. Offline start/end needs no scheduled reset: each original occurrence is tested against its configured window. + +Before passing milestone rewards to AdvancedCore's existing handler, the vote count and award reservation are forced and atomically published together. After the handler returns, the award is recorded as `SUBMITTED`. Submission uses existing offline reward handling and proxy routing, with `DateVoteEvent` and `DateVoteThreshold` reward placeholders. It never calls the ordinary vote pipeline a second time. + +Arbitrary commands, inventory changes and ledger publication are not one transaction. A crash or an uncertain reward admission can leave an award `RESERVED`; it is shown as **pending review** and is deliberately not automatically resubmitted, avoiding duplicate external payouts. `SUBMITTED` means accepted by the existing handler, not proof that every external command succeeded. An owner must inspect ambiguous cases and decide on manual recovery. Normal restarts, duplicates, reloads and display-name edits do not reissue rewards. Corrupt, oversized or unsafe state fails closed rather than resetting progress. No universal exactly-once payout guarantee is claimed. + +Persisted player files use a SHA-256 event-ID filename so case-distinct event IDs remain distinct on Windows and other case-insensitive filesystems. Accounting contracts use length-prefixed fields; incomplete reached-award records fail closed for administrator review. Progress resolves the same stored player identity as vote accounting on a worker, including offline UUID normalization. Proxy test flags are preserved into the existing vote event; legacy Java overloads retain their real-vote default. Standalone occurrence membership uses the actual current instant, including daylight-saving rollback. + +Disabling an event stops new accounting but retains its existing reward handles for offline/delayed awards. Keep disabled event definitions and their reward sections until previously queued rewards have been delivered; deleting definitions is not a supported cancellation mechanism for submitted rewards. Internal reward references use the case-sensitive event-ID hash, preserving distinct rewards even though AdvancedCore's ordinary registry lookup is case-insensitive. Owner-facing reward configuration and edits remain under the named event's `Milestones..Rewards` section (or the supported legacy numeric section); internal aliases are never saved into `SpecialRewards.yml`. An existing empty definitions file is treated as corruption, not a fresh install. + +If definition metadata is missing or omits an event with surviving player history, accounting and progress fail closed for that event until the historical seal is repaired. A new player cannot reseal the old ID under edited accounting fields. Other intact seals and genuinely new event IDs remain usable. + +`ProcessRewards: false` still records qualifying date-event votes but stores reached thresholds as `DEFERRED`, without invoking the reward handler. Once processing is enabled, the next qualifying accepted vote notification for that player/event reserves and submits those known unattempted thresholds once; an identified replay reaching this observer does not increment the counter. Reload/restart preserves deferred state. There is no background sweep: an ended event with no further qualifying delivery keeps its deferred thresholds. Each threshold is reserved separately immediately before invoking the reward handler. If an earlier submission or its acknowledgement fails, later unattempted thresholds stay `DEFERRED` for a subsequent qualifying notification, including after restart. Turning processing off before the next threshold leaves it deferred. An uncertain attempted submission remains `RESERVED` for conservative owner reconciliation and is never automatically retried. + +Runtime reward aliases register from canonical milestone reward sections independently of date/timezone/accounting validation. An accounting-only typo disables new progress while existing queued/offline awards can still resolve their unchanged reward section after reload or restart. Keep the event ID and reward sections until queued awards finish; deleting those sections is not a supported cancellation mechanism. + +Proxy transports open only after the existing milestone, streak, vote-party, special-reward, top-voter, shop and placeholder handlers have initialized and reward registration has finished. Date definitions and reward aliases are published before that ingress opens. + +If canonical proxy occurrence accounting cannot be durably confirmed, the accepted pipeline finishes its normal processing but marks the dispatch incomplete. The router finishes its remaining normal per-server points mutation and service-site observation before surfacing the date-accounting failure. The ordered proxy lane quarantines and retains the original envelope without acknowledging release or automatically replaying normal totals/rewards. Repair accounting storage and reconcile that original event/site/player/occurrence before manually releasing the quarantined delivery. Receipt release retires the completed receipt and sends its acknowledgement; it does not re-enter the normal vote pipeline or replay totals/rewards. Releasing a receipt is not itself a retry of missing date accounting. Confirmed progress with a later reward-submission failure retains the existing DEFERRED/RESERVED award guarantees. Local ingress has no equivalent durable proxy envelope and logs unconfirmed progress for owner reconciliation. + +Canonical date accounting requires an explicit transport vote ID; a legacy totals snapshot's correlation UUID is insufficient. `PlayerVoteEvent.proxyVoteId` and `PlayerPostVoteEvent.proxyVoteId` preserve the original explicit wire ID (or remain null when absent). Legacy totals correlation remains the effective identity for normal milestones, streaks, post-vote `voteUUID`, and backend replay fencing. Reliable multi-proxy delivery retains the original wire timestamp in the separate occurrence field through receiving-proxy forwarding, so delivery/retry time does not move a vote into a different event window. Older reliable senders with an explicit origin can supply their original wire time to an updated receiving proxy. Origin-less forwarding and backend envelopes without occurrence provenance remain excluded from canonical date accounting while normal rewards and cooldowns continue. An older intermediate proxy cannot recover occurrence metadata it did not retain; event accounting is unavailable for that route until its receiving proxy is updated. This requires no synchronized upgrade for ordinary voting. + +Receiving-proxy retries and offline/time-change caches retain occurrence metadata separately from their normal time, including restart recovery. Existing JSON and SQL opaque totals payloads carry a bounded tagged trailing token; established totals and legacy UUID parsers ignore it. Totals updates preserve it. Legacy cache rows without the token retain normal voting effects but cannot prove date-event occurrence. Malformed, oversized, or conflicting metadata also excludes only date membership and never replaces ordinary processing time. Sender discovery and acknowledgement outboxes preserve the token across retries and restart. No SQL schema or dependency upgrade is required. + +Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries). A bounded 256-entry ingress registry holds validated receipts; known queue capacity is reserved before executor submission, while unknown initial capacity remains provisional. The registry releases an admitted capture only when normal processing completes or the queue confirms ownership transfer. STARTED callbacks remain owned while blocked; retirement transfers them as well as callbacks that have not started before vote-executor cancellation, preserving the receipt UUID/time. Each transferred STARTED entry remains fenced from overflow execution until the original callback finishes. Successful, non-interrupted completion acknowledges only that exact transferred occurrence, including completion before or after transfer admission; exceptional/interrupted processing retains the recovery record. Failed transfers retain bounded capture ownership and emit an explicit admission failure. The overflow is published as a memory owner before listener registration; its independent worker loads recovered rows before accepting transfers, so cancelling the vote executor cannot cancel queue setup or overwrite recovered receipts. Queue-file loading and ordinary persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. A full normal overflow lane rejects ordinary transfers, but reserved captures fit the bounded combined retirement snapshot. A full combined restored snapshot, an over-capacity persisted file or unreadable storage cannot admit another normal capture; recovered votes are not evicted or replaced with missing-state guesses. Initialization and queued transfers remain bounded; they are not additional reward or accounting engines. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key and retains the UUID under `LocalOccurrenceId`, while preserving the established `Time` field. Legacy overflow rows receive distinct UUIDs and are rewritten before admission; legacy timed-cache rows derive a stable migration UUID from their persisted row identity and save it before replay is scheduled. Explicit malformed IDs are withheld rather than replaced with fresh occurrences. Queue acknowledgement and ordinary vote rewards retain their existing semantics; stable date identity is not a transaction or exactly-once guarantee for external reward commands. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. + +Recovered time-change replay is constructed and registered only after date definitions, reward handles and the accepted-vote consumer are ready. This also gates the constructor’s initial replay timer; starting local Votifier or proxy ingress cannot race a missing replay consumer. + +Extension events dispatched from a Bukkit/Paper/Folia gameplay thread are admitted to the existing ordered storage worker (at most 64 pending). The asynchronous event flag is not treated as a thread check. Deferred proxy accounting remains unconfirmed/quarantined until reconciliation; submission alone never acknowledges durable accounting. Reload, disable, or overload can withhold such extension occurrences. Native VotingPlugin asynchronous ingress retains synchronous completion reporting. + +When the normal capture registry is full, validated receipts use the independent overflow admission lane directly; they do not need another normal executor/capture slot. If both bounded owners cannot admit a new receipt, rejection is explicit. A rejected transfer of an already captured receipt clears its in-flight state and retries with the same UUID/time through one coalesced 250 ms wakeup on the existing overflow worker. That wakeup targets only captures which already requested transfer, preserving normal callback execution and the STARTED release fence; listener stop submits one retirement batch that uses the captures' reserved snapshot capacity. No additional executor or unbounded waiter list is created, and retry wakeups stop when the overflow retires. + +Native queue retirement drains its accepted worker transfers and writes the final snapshot after loading, with a one-second lifecycle wait. A blocked worker or failed write is reported as incomplete publication, not a successful durable admission; sudden process death before asynchronous publication can still lose an unpublished receipt. This does not claim universal exactly-once normal rewards or uninterrupted receipt while bounded owners are full. + +A callback that outlives the bounded shutdown and completes only after the overflow seals its final snapshot remains an uncertain recovery case. The retired queue reports reconciliation is required and retains the occurrence rather than overwriting a successor queue. Partial ordinary side effects followed by an exception are also uncertain: recovery keeps the original UUID/time but can repeat ordinary effects under the existing at-least-once overflow contract. These boundaries do not weaken date-ledger identity, add a reward/accounting engine, or extend shutdown waits. + +Overflow replay removes a receipt only after the production listener reports successful, non-interrupted processing. A known failure or callback exception retains its original occurrence UUID/time and retries through one coalesced 250 ms wakeup on the existing overflow worker. Individual failed entries cannot retry immediately in the same drain; other eligible entries can continue. Retired generations retain their receipts without running the listener, and callbacks finishing after the final snapshot cannot overwrite successor state. Existing public void callbacks retain normal-return completion semantics. A failure before event dispatch causes no normal vote effects; failure after a prefix of ordinary effects remains the documented at-least-once uncertainty above. + +Native event dispatch alone is not completion: Bukkit can skip disabled listeners or catch consumer exceptions while returning normally. Explicit accepted-consumer completion keeps transferred receipts until the pipeline actually returns; a deliberate cancellation before consumer execution remains a completed no-op. A callback resuming during graceful shutdown therefore cannot discard an unprocessed receipt. + +Overflow writers hold an exclusive file lease until their final worker publication finishes. A replacement instance waits asynchronously on the existing bounded queue worker before loading that file; it cannot publish an empty or stale snapshot while another writer owns it. The small owner sidecar is retained across shutdown and is not queue data. A blocked predecessor can delay replacement admission, which remains bounded and reports unsuccessful transfers for retry. This coordinates participating plugin instances, including different class loaders; it cannot control older binaries or other software that writes without the lease. Failed final publication retains that lease and the existing worker for retry; timeout still leaves recovery unconfirmed until publication succeeds. + +## Testing the event menu + +Run the focused regression suite from the repository root: + +```shell +mvn -B -f VotingPlugin/pom.xml -Dtest=DateVoteEventsMenuTest,DateVoteMenuOpenBoundaryTest,DateVoteProgressSchedulingTest,VoteOccurrenceTotalsReplayTest,DateVoteMilestonesTest,DateVoteMilestoneConfigTest,DateVoteEventTest,DateVoteLedgerTest,BackendDateVoteAccountingRegressionTest,BukkitCompletionSchedulerTest,CorePlatformIsolationTest test +``` + +Then run the required clean package and packaged-artifact checks. Automated coverage uses complete synthetic YAML, the real ledger and reward registry, queued storage/entity callbacks, and an inventory boundary fixture. It verifies event and milestone pagination through 64 entries, list/detail/back navigation, start-inclusive/end-exclusive status, deferred/submitted/pending-review awards, literal display text, permission changes, reload fencing, offline/retired players, rejected storage admission, remote ownership, disabled events, empty configuration and corrupt storage. Navigation and inspection must not submit rewards, change accounting files or perform storage work on the player thread. + +For an authorized gameplay test on a test server: + +1. Leave the bundled example disabled. Create a separate synthetic event with a valid window, explicit timezone and test rewards, then enable it. +2. Open `/vote dateevents` with either supported permission. Inspect the event window, count, next enabled threshold and milestone preview. Navigate detail pages and back. The menu displays reward types, never executes previews. +3. Send a qualifying real test receipt through the normal test setup, reopen the menu and verify progress and award state. Duplicate the same identified receipt: it must not increment again or repeat a submitted award. +4. Verify disabled, upcoming and ended definitions, and a configuration with no events. On a proxy backend, verify a remote-owned event shows its exact owner without a fabricated local count; use matching definitions and identified all-server delivery for the accounting owner. +5. Revoke permission or reload while a menu is open. Old navigation must not reopen a stale or unauthorized view. Check fresh navigation after restoring permission and reopening the command. Repeat player scheduling checks on the supported server platforms. + +A passing local suite is not evidence of a live Paper/Folia/proxy gameplay run; record those results separately when performed. + +### Votifier capture retirement and capacity + +Native intake keeps the ordinary overflow lane at 256 entries and reserves separate final-snapshot capacity for up to +256 normal captured receipts. The queue is the capacity owner: restored rows and current capture reservations together +are capped at 512. A full restored combined snapshot rejects new normal capture admission until processing frees room; +repeated shutdown/restart cannot grow the retained file indefinitely. Unknown initial disk capacity is provisional: +initialization and bounded deferred admission run on the existing queue worker before normal executor submission. + +Shutdown stops intake, submits one coalesced capture-retirement batch, and then publishes the final queue snapshot on that +same worker. The existing YAML row format, receipt timestamp and occurrence ID are preserved, including captures whose +normal executor callback was cancelled. A started original callback retains execution ownership until its completion +status is known. Confirmed completion before the final seal removes its queue occurrence; completion after the seal +reports uncertainty and cannot overwrite a successor's file. + +The close caller waits at most one second per call. Failed final publication retains the worker and file owner for retry, +and a replacement waits to acquire ownership; a timeout means recovery is unconfirmed. This does not guarantee completion +if the process exits while storage remains unavailable. Unreadable or unowned initial storage is never replaced with a +missing-state guess. Normal overflow admission remains distinct from a confirmed durable checkpoint. + +A replacement that retires captures before acquiring its predecessor's queue owner keeps one bounded retirement batch +on the existing worker. It retries ownership and authoritative loading before merging and publishing those receipts; +close does not discard an uninitialized batch. Unknown or corrupt storage is retained for repair. If an already observed +queue file disappears, its absence cannot authorize an empty replacement. Retirement admissions across batches remain +capped at 256; normal and recovered row limits remain unchanged. Provisional receipt admission is not confirmed ownership +until recovery and capacity checks complete. A queue with no retirement receipts can close without publishing unknown +initial state. These retries do not extend the caller's shutdown wait or promise recovery if the process exits before +ownership/storage becomes available.