From c6a099fdb10d8919020d3751199f9eed01ea8326 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:02:42 -0600 Subject: [PATCH 1/2] Add a read-only vote streak progression menu --- .../votingplugin/VotingPluginMain.java | 8 + .../votingplugin/commands/CommandLoader.java | 1 + .../com/bencodez/votingplugin/config/GUI.java | 4 + .../votestreak/VoteStreakHandler.java | 21 ++ .../streaks/VoteStreaksCommand.java | 20 ++ .../votingplugin/streaks/VoteStreaksMenu.java | 277 +++++++++++++++++ .../streaks/VoteStreaksSnapshot.java | 37 +++ VotingPlugin/src/main/resources/GUI.yml | 4 + VotingPlugin/src/main/resources/plugin.yml | 6 + .../streaks/VoteStreaksCommandTest.java | 71 +++++ .../streaks/VoteStreaksMenuTest.java | 284 ++++++++++++++++++ .../streaks/VoteStreaksReadOnlyTest.java | 59 ++++ .../streaks/VoteStreaksSnapshotTest.java | 40 +++ docs/vote-streaks-menu.md | 87 ++++++ 14 files changed, 919 insertions(+) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksCommand.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksMenu.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksSnapshot.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksCommandTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksMenuTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksReadOnlyTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksSnapshotTest.java create mode 100644 docs/vote-streaks-menu.md diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index cde4d636b9..72886225b2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -174,6 +174,9 @@ public File getLoadedPluginJarFile() { @Getter private BroadcastHandler broadcastHandler; + @Getter + private com.bencodez.votingplugin.streaks.VoteStreaksMenu voteStreaksMenu; + @Getter private BackendProxyHandler backendProxyHandler; private ProcessedVoteCache backendProcessedVoteCache; @@ -1961,6 +1964,7 @@ private void shutdownVoteReminders() { @Override public void onUnLoad() { + if (voteStreaksMenu != null) voteStreaksMenu.close(); placeholderPlayerPresence.clear(); stopBackendHostedControlLifecycle(); stopBackendControlConnectorLifecycle(); @@ -2045,6 +2049,8 @@ private void registerCommands() { * Register events. */ private void registerEvents() { + voteStreaksMenu = new com.bencodez.votingplugin.streaks.VoteStreaksMenu(this); + getServer().getPluginManager().registerEvents(voteStreaksMenu, this); PluginManager pm = getServer().getPluginManager(); pm.registerEvents(new PlayerJoinEvent(this), this); @@ -2125,6 +2131,7 @@ private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl, boolean updateActiveBackendRuntime) { if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.pause(); try { + if (voteStreaksMenu != null) voteStreaksMenu.suspend(); configFile.reloadData(); configFile.loadValues(); @@ -2176,6 +2183,7 @@ private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl, setUpdate(true); } finally { + if (voteStreaksMenu != null) voteStreaksMenu.resume(); if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.resume(); } } 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..7ab17afdf8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -3486,6 +3486,7 @@ public void updateReplacements() { */ private void loadVoteCommand() { plugin.setVoteCommand(new ArrayList<>()); + com.bencodez.votingplugin.streaks.VoteStreaksCommand.register(plugin); plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" }, "VotingPlugin.Commands.Vote.Help|" + playerPerm, "View help page") { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/GUI.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/GUI.java index db474c51fa..c0dfc12274 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/GUI.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/GUI.java @@ -26,6 +26,10 @@ */ public class GUI extends YMLFile { + @ConfigDataString(path = "CHEST.VoteStreaks.Name") + @Getter + private String chestVoteStreaksName = "&6Vote streaks"; + @ConfigDataString(path = "BOOK.VoteURLBookGUI.AlreadyVotedColor") @Getter private String bookVoteURLBookGUIAlreadyVotedColor = "RED"; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakHandler.java index 3196056bae..203352ca52 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/votestreak/VoteStreakHandler.java @@ -40,6 +40,7 @@ public class VoteStreakHandler { private final Map byId = new LinkedHashMap<>(); private final Map byProgressGroup = new LinkedHashMap<>(); private final List ordered = new ArrayList<>(); + private volatile List displayDefinitions = List.of(); public VoteStreakHandler(VotingPluginMain plugin) { this.plugin = plugin; @@ -56,6 +57,26 @@ public void reload() { // VoteStreak definitions live in specialrewards.yml now (Option B) ConfigurationSection root = plugin.getSpecialRewardsConfig().getData(); new VoteStreakConfigLoader().load(root); + displayDefinitions = ordered.stream().filter(VoteStreakDefinition::isEnabled).toList(); + } + + /** Worker-only, observational view. Does not advance periods, save state or execute rewards. */ + public com.bencodez.votingplugin.streaks.VoteStreaksSnapshot snapshotForDisplay(VotingPluginUser user) { + var definitions = displayDefinitions; + var periods = List.of( + new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Period(VoteStreakType.DAILY, user.getDayVoteStreak(), user.getBestDayVoteStreak()), + new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Period(VoteStreakType.WEEKLY, user.getWeekVoteStreak(), user.getBestWeekVoteStreak()), + new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Period(VoteStreakType.MONTHLY, user.getMonthVoteStreak(), user.getBestMonthVoteStreak())); + var states = new LinkedHashMap(); + var rows = new ArrayList(); + for (var definition : definitions) { + if (rows.size() >= com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.MAX_MILESTONES) break; + var state = states.computeIfAbsent(getColumnName(definition), ignored -> readState(user, definition)); + String label = definition.getId().codePoints().limit(80).collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString(); + rows.add(new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Milestone(label, definition.getType(), state.streakCount, + state.bestStreakCount, definition.getRequiredAmount(), definition.getVotesRequired(), definition.isRecurring())); + } + return new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot(periods, rows, definitions.size() > rows.size()); } public List getDefinitions() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksCommand.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksCommand.java new file mode 100644 index 0000000000..7194cffc74 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksCommand.java @@ -0,0 +1,20 @@ +package com.bencodez.votingplugin.streaks; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.votingplugin.VotingPluginMain; + +public final class VoteStreaksCommand { + private VoteStreaksCommand() { } + public static void register(VotingPluginMain plugin) { + plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Streaks" }, + VoteStreaksMenu.PERMISSION + "|VotingPlugin.Player", "View your vote streak progression", false) { + @Override public void execute(CommandSender sender, String[] args) { + if (sender instanceof Player player && plugin.getVoteStreaksMenu() != null) { + plugin.getVoteStreaksMenu().open(player); + } + } + }); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksMenu.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksMenu.java new file mode 100644 index 0000000000..2d84a62c76 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksMenu.java @@ -0,0 +1,277 @@ +package com.bencodez.votingplugin.streaks; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.BiFunction; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import com.bencodez.simpleapi.messages.MessageAPI; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; + +/** One isolated native inventory view; no registrations in existing production GUI managers. */ +public final class VoteStreaksMenu implements Listener, AutoCloseable { + public static final String PERMISSION = "VotingPlugin.Commands.Vote.Streaks"; + private static final int MAX_SESSIONS = 128, MAX_LOADS = 16; + private final VotingPluginMain plugin; + private final Consumer worker; + private final BiFunction snapshot; + private final Map sessions = new ConcurrentHashMap<>(); + private final AtomicInteger loading = new AtomicInteger(); + private final AtomicInteger reloadDepth = new AtomicInteger(); + private volatile boolean closed; + + public VoteStreaksMenu(VotingPluginMain plugin) { + this(plugin, task -> plugin.getUserManager().getDataManager().getTimer().execute(task), (uuid, name) -> + plugin.getVoteStreakHandler().snapshotForDisplay(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, name))); + } + VoteStreaksMenu(VotingPluginMain plugin, Consumer worker, BiFunction snapshot) { + this.plugin = plugin; this.worker = worker; this.snapshot = snapshot; + } + private static final class Session { + final Player player; + final UUID owner; + final String name; + final Inventory previous; + final boolean ownsPrevious; + final long created = System.nanoTime(); + volatile Holder view; + Session(Player player, VoteStreaksMenu manager) { + this.player = player; owner = player.getUniqueId(); name = player.getName(); + previous = player.getOpenInventory().getTopInventory(); + Holder holder = manager.holder(previous); + ownsPrevious = holder != null && holder.session.owner.equals(owner); + } + } + static final class Holder implements InventoryHolder { + final VoteStreaksMenu manager; + final Session session; + final VoteStreaksSnapshot snapshot; + final int page; + Inventory inventory; + Holder(VoteStreaksMenu manager, Session session, VoteStreaksSnapshot snapshot, int page) { + this.manager = manager; this.session = session; this.snapshot = snapshot; this.page = snapshot.clampPage(page); + } + @Override public Inventory getInventory() { return inventory; } + } + private static boolean permitted(Player player) { + return player.isOnline() && (player.hasPermission(PERMISSION) || player.hasPermission("VotingPlugin.Player")); + } + private void onPlayer(Player player, Runnable work, Runnable rejected) { + BukkitCompletionScheduler.run(plugin, player, work, rejected, rejected); + } + public void open(Player player) { + onPlayer(player, () -> begin(player), () -> { }); + } + private void begin(Player player) { + if (closed || reloadDepth.get() != 0 || !permitted(player)) return; + if (loading.incrementAndGet() > MAX_LOADS) { + loading.decrementAndGet(); player.sendMessage("Vote streaks are busy. Please try again shortly."); return; + } + Session session; + synchronized (sessions) { + if (closed || reloadDepth.get() != 0 || (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(player.getUniqueId()))) { + loading.decrementAndGet(); player.sendMessage("Vote streaks are unavailable. Please try again shortly."); return; + } + session = new Session(player, this); sessions.put(session.owner, session); + } + try { + worker.accept(() -> { + try { + if (!current(session)) return; + VoteStreaksSnapshot data = snapshot.apply(session.owner, session.name); + onPlayer(player, () -> { + if (!current(session)) return; + if (!permitted(player) || System.nanoTime() - session.created > 30_000_000_000L + || player.getOpenInventory().getTopInventory() != session.previous) { + retire(session); return; + } + renderSafely(session, data, 0); + }, () -> retire(session)); + } catch (RuntimeException failure) { + onPlayer(player, () -> { + if (retire(session) && player.isOnline()) + player.sendMessage("Unable to read vote streaks. Please try again later."); + }, () -> retire(session)); + } finally { loading.decrementAndGet(); } + }); + } catch (RuntimeException rejected) { + loading.decrementAndGet(); retire(session); + player.sendMessage("Vote streaks are unavailable. Please try again later."); + } + } + private boolean current(Session session) { return !closed && sessions.get(session.owner) == session; } + private Holder holder(Inventory inventory) { + return inventory != null && inventory.getHolder() instanceof Holder holder && holder.manager == this ? holder : null; + } + private void renderSafely(Session session, VoteStreaksSnapshot data, int page) { + try { render(session, data, page); } + catch (RuntimeException failure) { + if (sessions.remove(session.owner, session)) { + closeView(session); + if (session.player.isOnline()) session.player.sendMessage("Unable to open vote streaks. Please try again later."); + } + } + } + private void navigate(Holder holder, int page) { + onPlayer(holder.session.player, () -> { + if (current(holder.session) && holder.session.view == holder + && holder.session.player.getOpenInventory().getTopInventory() == holder.inventory) + renderSafely(holder.session, holder.snapshot, page); + }, () -> sessions.remove(holder.session.owner, holder.session)); + } + private void render(Session session, VoteStreaksSnapshot data, int page) { + if (!current(session) || !permitted(session.player)) return; + Holder holder = new Holder(this, session, data, page); + String title = plugin.getGui().getChestVoteStreaksName(); + holder.inventory = Bukkit.createInventory(holder, 54, MessageAPI.colorize(title)); + for (int i = 0; i < data.periods().size(); i++) { + var period = data.periods().get(i); String unit = VoteStreaksSnapshot.unit(period.type()); + holder.inventory.setItem(11 + i * 2, item(Material.CLOCK, "&6" + period.type() + " streak", + List.of("&7Current: &e" + period.current() + " " + unit, "&7Best: &e" + period.best() + " " + unit))); + } + List milestones = data.page(holder.page); + for (int i = 0; i < milestones.size(); i++) { + var milestone = milestones.get(i); String unit = VoteStreaksSnapshot.unit(milestone.type()); + var lore = new ArrayList(); + lore.add("&7Current: &e" + milestone.current() + " " + unit); + lore.add("&7Best: &e" + milestone.best() + " " + unit); + lore.add("&7Milestone: &e" + milestone.threshold() + " " + unit); + lore.add("&7Votes per period: &e" + milestone.votesRequired()); + lore.add(milestone.recurring() ? "&7Repeats every " + milestone.threshold() + " " + unit + "; next: " + milestone.nextThreshold() + : milestone.current() >= milestone.threshold() ? "&aCurrent streak meets this milestone" : "&7Upcoming milestone"); + lore.add("&7Progress only; this does not claim rewards."); + holder.inventory.setItem(20 + i, item(Material.PAPER, "&e" + milestone.id(), lore)); + } + if (milestones.isEmpty()) holder.inventory.setItem(22, item(Material.PAPER, "&7No enabled streak milestones", + List.of("&7Configure VoteStreaks in SpecialRewards.yml."))); + holder.inventory.setItem(45, item(Material.ARROW, "&ePrevious", List.of())); + holder.inventory.setItem(49, item(Material.BARRIER, "&cClose", List.of())); + holder.inventory.setItem(50, item(Material.CLOCK, "&eRefresh", List.of("&7Read current streaks again."))); + holder.inventory.setItem(53, item(Material.ARROW, "&eNext", List.of("&7Page " + (holder.page + 1) + "/" + data.pages(), + data.truncated() ? "&7Showing the first 512 enabled milestones." : "&7Read-only streak progression."))); + session.view = holder; + if (!current(session) || !permitted(session.player)) return; + session.player.openInventory(holder.inventory); + if (!current(session) && session.player.getOpenInventory().getTopInventory() == holder.inventory) + session.player.closeInventory(); + if (session.player.getOpenInventory().getTopInventory() != holder.inventory) sessions.remove(session.owner, session); + } + private static ItemStack item(Material material, String name, List lore) { + ItemStack item = new ItemStack(material); ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(MessageAPI.colorize(name)); + meta.setLore(lore.stream().map(MessageAPI::colorize).toList()); item.setItemMeta(meta); + } + return item; + } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false) + public void inventoryOpen(InventoryOpenEvent event) { + if (!(event.getPlayer() instanceof Player player)) return; + Holder holder = holder(event.getInventory()); + if (holder == null) return; + if (holder.session.player != player || !current(holder.session) || holder.session.view != holder || !permitted(player)) { + event.setCancelled(true); return; + } + if (event.isCancelled()) sessions.remove(holder.session.owner, holder.session); + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void unrelatedInventoryOpen(InventoryOpenEvent event) { + if (!event.isCancelled() && holder(event.getInventory()) == null && event.getPlayer() instanceof Player player) + sessions.remove(player.getUniqueId()); + } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false) + public void click(InventoryClickEvent event) { + Holder holder = holder(event.getView().getTopInventory()); + if (holder == null) return; + boolean cancelled = event.isCancelled(); event.setCancelled(true); + if (cancelled || event.getWhoClicked() != holder.session.player || !current(holder.session) + || holder.session.view != holder || !permitted(holder.session.player)) return; + // Only ordinary top-inventory clicks navigate; shift/number/drop actions cannot move previews. + if (event.getRawSlot() < 0 || event.getRawSlot() >= 54 || !event.isLeftClick() || event.isShiftClick()) return; + switch (event.getRawSlot()) { + case 45 -> navigate(holder, holder.page - 1); + case 53 -> navigate(holder, holder.page + 1); + case 49 -> { sessions.remove(holder.session.owner, holder.session); closeView(holder.session); } + case 50 -> begin(holder.session.player); + default -> { } + } + } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false) + public void drag(InventoryDragEvent event) { + if (holder(event.getView().getTopInventory()) != null) event.setCancelled(true); + } + @EventHandler public void inventoryClose(InventoryCloseEvent event) { + Holder holder = holder(event.getInventory()); + if (holder != null && holder.session.view == holder) sessions.remove(holder.session.owner, holder.session); + else if (event.getPlayer() instanceof Player player) { + Session pending = sessions.get(player.getUniqueId()); + if (pending != null && pending.view == null && pending.previous == event.getInventory()) sessions.remove(pending.owner, pending); + } + } + @EventHandler public void quit(PlayerQuitEvent event) { sessions.remove(event.getPlayer().getUniqueId()); } + @EventHandler public void worldChange(PlayerChangedWorldEvent event) { end(event.getPlayer()); } + @EventHandler public void death(PlayerDeathEvent event) { end(event.getEntity()); } + private void end(Player player) { Session session = sessions.remove(player.getUniqueId()); if (session != null) closeView(session); } + private boolean retire(Session session) { + if (!sessions.remove(session.owner, session)) return false; + closeView(session); return true; + } + private void closeView(Session session) { + if (session.view == null && !session.ownsPrevious) return; + Runnable cleanup = () -> { + // A queued cleanup must never close an inventory adopted by a newer request. + Session replacement = sessions.get(session.owner); + if (replacement != null && replacement != session) return; + Inventory top = session.player.getOpenInventory().getTopInventory(); + if ((session.view != null && top == session.view.inventory) + || (session.ownsPrevious && top == session.previous)) session.player.closeInventory(); + }; + // Bukkit rejects scheduling after disable. Its primary thread already owns this interaction. + if (closed && ownsForShutdown(Bukkit.class, session.player)) cleanup.run(); + else onPlayer(session.player, cleanup, () -> { }); + } + /** Public Folia ownership also recognizes its stopped-region shutdown owner. */ + static boolean ownsForShutdown(Class platform, Player player) { + try { + return Boolean.TRUE.equals(platform.getMethod("isOwnedByCurrentRegion", org.bukkit.entity.Entity.class).invoke(null, player)); + } catch (NoSuchMethodException legacy) { + try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer", false, VoteStreaksMenu.class.getClassLoader()); + return false; // A regional server without its ownership API must fail closed. + } catch (ClassNotFoundException ordinaryServer) { return Bukkit.isPrimaryThread(); } + catch (LinkageError unavailable) { return false; } + } catch (ReflectiveOperationException | LinkageError unavailable) { return false; } + } + public void suspend() { reloadDepth.incrementAndGet(); reset(); } + public void resume() { reloadDepth.updateAndGet(value -> Math.max(0, value - 1)); } + public void reset() { + List old; + synchronized (sessions) { old = List.copyOf(sessions.values()); sessions.clear(); } + old.forEach(this::closeView); + } + @Override public void close() { synchronized (sessions) { closed = true; } reset(); } + int sessionCount() { return sessions.size(); } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksSnapshot.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksSnapshot.java new file mode 100644 index 0000000000..f48c480e7a --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/streaks/VoteStreaksSnapshot.java @@ -0,0 +1,37 @@ +package com.bencodez.votingplugin.streaks; + +import java.util.List; +import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType; + +/** Read-only observations of existing state; never evidence that a reward was delivered. */ +public record VoteStreaksSnapshot(List periods, List milestones, boolean truncated) { + public static final int MAX_MILESTONES = 512; + public static final int PAGE_SIZE = 5; + public VoteStreaksSnapshot { + periods = List.copyOf(periods); + milestones = List.copyOf(milestones); + if (periods.size() != 3 || milestones.size() > MAX_MILESTONES) throw new IllegalArgumentException("Invalid streak snapshot size"); + } + public record Period(VoteStreakType type, int current, int best) { + public Period { current = Math.max(0, current); best = Math.max(current, best); } + } + public record Milestone(String id, VoteStreakType type, int current, int best, + int threshold, int votesRequired, boolean recurring) { + public Milestone { + current = Math.max(0, current); best = Math.max(current, best); + threshold = Math.max(1, threshold); votesRequired = Math.max(1, votesRequired); + } + public long nextThreshold() { + return recurring ? ((long) current / threshold + 1L) * threshold : threshold; + } + } + public int pages() { return Math.max(1, (milestones.size() + PAGE_SIZE - 1) / PAGE_SIZE); } + public int clampPage(int page) { return Math.max(0, Math.min(page, pages() - 1)); } + public List page(int page) { + int start = clampPage(page) * PAGE_SIZE; + return milestones.subList(start, Math.min(start + PAGE_SIZE, milestones.size())); + } + public static String unit(VoteStreakType type) { + return switch (type) { case DAILY -> "days"; case WEEKLY -> "weeks"; case MONTHLY -> "months"; }; + } +} diff --git a/VotingPlugin/src/main/resources/GUI.yml b/VotingPlugin/src/main/resources/GUI.yml index ba4409af7f..0cd01e6f53 100644 --- a/VotingPlugin/src/main/resources/GUI.yml +++ b/VotingPlugin/src/main/resources/GUI.yml @@ -294,6 +294,10 @@ CHEST: Lore: '&c&lBest: &c%Best%' Amount: 1 + # Separate read-only /vote streaks progression menu. + VoteStreaks: + Name: '&6Vote streaks' + VoteStreak: Name: 'VoteStreak: %player%' BackButton: false diff --git a/VotingPlugin/src/main/resources/plugin.yml b/VotingPlugin/src/main/resources/plugin.yml index 633dc78417..ee4c2ca7b4 100644 --- a/VotingPlugin/src/main/resources/plugin.yml +++ b/VotingPlugin/src/main/resources/plugin.yml @@ -195,3 +195,9 @@ permissions: VotingPlugin.Login.RemindVotes.All: description: Remind if player can vote on all votesites, rather than any default: false + + VotingPlugin.Commands.Vote.Streaks: + description: View your read-only vote streak progression + default: op + children: + VotingPlugin.Commands.Vote: true diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksCommandTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksCommandTest.java new file mode 100644 index 0000000000..071e745611 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksCommandTest.java @@ -0,0 +1,71 @@ +package com.bencodez.votingplugin.streaks; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.command.CommandHandler; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.votingplugin.VotingPluginMain; + +class VoteStreaksCommandTest { + @Test void actualCommandLoaderInitializesAndRetainsOneStreaksHandlerOnEveryLoad() throws Exception { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + var list = new java.util.concurrent.atomic.AtomicReference>(); + when(plugin.getVoteCommand()).thenAnswer(call -> list.get()); + doAnswer(call -> { list.set(call.getArgument(0)); return null; }).when(plugin).setVoteCommand(any()); + var loader = new com.bencodez.votingplugin.commands.CommandLoader(plugin); + var load = loader.getClass().getDeclaredMethod("loadVoteCommand"); load.setAccessible(true); + var basic = mock(com.bencodez.advancedcore.command.CommandLoader.class); + var inherited = mock(CommandHandler.class); when(inherited.getArgs()).thenReturn(new String[]{"Version"}); + when(inherited.getPerm()).thenReturn("VotingPlugin.Version"); + when(basic.getBasicCommands("VotingPlugin")).thenAnswer(call -> new ArrayList<>(List.of(inherited))); + try (var singleton = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) { + singleton.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(basic); + for (int i = 0; i < 2; i++) { + load.invoke(loader); + assertEquals(1, list.get().stream().filter(c -> java.util.Arrays.equals(c.getArgs(), new String[]{"Streaks"})).count()); + assertTrue(list.get().stream().anyMatch(c -> java.util.Arrays.equals(c.getArgs(), new String[]{"Help&?"}))); + assertTrue(list.get().stream().anyMatch(c -> java.util.Arrays.equals(c.getArgs(), new String[]{"Shop"}))); + assertTrue(list.get().contains(inherited), "normal shared commands must remain registered"); + } + } + } + @Test void realDispatchPreservesExistingHandlersAndUsesDedicatedPlayerPermission() { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); var old = mock(CommandHandler.class); + var commands = new ArrayList<>(List.of(old)); when(plugin.getVoteCommand()).thenReturn(commands); + var scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + var tasks = new java.util.ArrayDeque(); + doAnswer(call -> { tasks.add(call.getArgument(1)); return null; }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + var menu = mock(VoteStreaksMenu.class); when(plugin.getVoteStreaksMenu()).thenReturn(menu); + VoteStreaksCommand.register(plugin); assertEquals(2, commands.size()); assertSame(old, commands.getFirst()); + CommandHandler command = commands.getLast(); assertArrayEquals(new String[] {"Streaks"}, command.getArgs()); + assertEquals("VotingPlugin.Commands.Vote.Streaks|VotingPlugin.Player", command.getPerm()); + Player player = mock(Player.class); when(player.hasPermission(anyString())).thenReturn(true); + assertTrue(command.runCommand(player, new String[] {"streaks"})); verifyNoInteractions(menu); + tasks.remove().run(); verify(menu).open(player); verifyNoInteractions(old); + command.execute(mock(CommandSender.class), new String[] {"Streaks"}); verify(menu, times(1)).open(any()); + } + @Test void deniedPermissionCannotReadUserDataOrOpenAMenu() { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); var commands = new ArrayList(); + when(plugin.getOptions().getFormatNoPerms()).thenReturn("Denied"); + when(plugin.getVoteCommand()).thenReturn(commands); VoteStreaksCommand.register(plugin); + var scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + Player denied = mock(Player.class); + assertTrue(commands.getFirst().runCommand(denied, new String[] {"Streaks"})); + verifyNoInteractions(plugin.getVotingPluginUserManager()); + verify(scheduler, never()).runTaskAsynchronously(any(), any(Runnable.class)); + } + @Test void leafPermissionDeclaresOnlyTheRootGateAsAChild() throws Exception { + try (var input = getClass().getResourceAsStream("/plugin.yml")) { + assertNotNull(input); var description = new org.bukkit.plugin.PluginDescriptionFile(input); + var permission = description.getPermissions().stream().filter(p -> p.getName().equals(VoteStreaksMenu.PERMISSION)).findFirst().orElseThrow(); + assertEquals(java.util.Map.of("VotingPlugin.Commands.Vote", true), permission.getChildren()); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksMenuTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksMenuTest.java new file mode 100644 index 0000000000..1af68d3020 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksMenuTest.java @@ -0,0 +1,284 @@ +package com.bencodez.votingplugin.streaks; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.votingplugin.VotingPluginMain; + +class VoteStreaksMenuTest { + private static class Fixture implements AutoCloseable { + final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + final BukkitScheduler scheduler = mock(BukkitScheduler.class); + final ArrayDeque owners = new ArrayDeque<>(), workers = new ArrayDeque<>(); + final List inventories = new ArrayList<>(); + final java.util.Map> tops = new java.util.HashMap<>(); + final MockedStatic bukkit = mockStatic(Bukkit.class); + final java.util.concurrent.atomic.AtomicInteger reads = new java.util.concurrent.atomic.AtomicInteger(); + final VoteStreaksMenu menu; + boolean workerOwner, failRead, rejectWorker, rejectScheduler; + Runnable duringOpen = () -> { }; + Fixture() { + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getGui().getChestVoteStreaksName()).thenReturn("&6Vote streaks"); + doAnswer(call -> { if (rejectScheduler) throw new java.util.concurrent.RejectedExecutionException("Synthetic scheduler rejection"); owners.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class)); + doAnswer(call -> { if (rejectScheduler) throw new java.util.concurrent.RejectedExecutionException("Synthetic scheduler rejection"); owners.add(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + var factory = mock(ItemFactory.class); + bukkit.when(Bukkit::getItemFactory).thenReturn(factory); + when(factory.getItemMeta(any(Material.class))).thenAnswer(call -> { + var meta = mock(ItemMeta.class); var name = new AtomicReference(); var lore = new AtomicReference>(); + doAnswer(change -> { name.set(change.getArgument(0)); return null; }).when(meta).setDisplayName(anyString()); + doAnswer(change -> { lore.set(change.getArgument(0)); return null; }).when(meta).setLore(anyList()); + when(meta.getDisplayName()).thenAnswer(ignored -> name.get()); when(meta.getLore()).thenAnswer(ignored -> lore.get()); + when(meta.clone()).thenReturn(meta); return meta; + }); + when(factory.isApplicable(any(ItemMeta.class), any(Material.class))).thenReturn(true); + when(factory.asMetaFor(any(ItemMeta.class), any(Material.class))).thenAnswer(call -> call.getArgument(0)); + bukkit.when(() -> Bukkit.createInventory(any(InventoryHolder.class), eq(54), anyString())).thenAnswer(call -> { + assertFalse(workerOwner, "inventory creation must be player-owned"); + Inventory inv = mock(Inventory.class); when(inv.getHolder()).thenReturn(call.getArgument(0)); + ItemStack[] slots = new ItemStack[54]; + doAnswer(change -> { slots[change.getArgument(0)] = change.getArgument(1); return null; }).when(inv).setItem(anyInt(), any(ItemStack.class)); + when(inv.getItem(anyInt())).thenAnswer(read -> slots[read.getArgument(0)]); inventories.add(inv); return inv; + }); + menu = new VoteStreaksMenu(plugin, task -> { + if (rejectWorker) throw new java.util.concurrent.RejectedExecutionException("Synthetic worker rejection"); + workers.add(task); + }, (uuid, name) -> { + assertTrue(workerOwner, "storage reads must be worker-owned"); reads.incrementAndGet(); + if (failRead) throw new IllegalStateException("Synthetic storage diagnostic"); + return VoteStreaksSnapshotTest.snapshot(7); + }); + } + Player player(int n) { + Player p = mock(Player.class); UUID id = new UUID(0, n); when(p.getUniqueId()).thenReturn(id); + when(p.getName()).thenReturn("test-player"); when(p.isOnline()).thenReturn(true); when(p.hasPermission(VoteStreaksMenu.PERMISSION)).thenReturn(true); + Inventory original = mock(Inventory.class); + var top = new AtomicReference<>(original); tops.put(id, top); + InventoryView view = mock(InventoryView.class); when(view.getTopInventory()).thenAnswer(ignored -> top.get()); + when(p.getOpenInventory()).thenReturn(view); + when(p.openInventory(any(Inventory.class))).thenAnswer(call -> { assertFalse(workerOwner); top.set(call.getArgument(0)); duringOpen.run(); return view; }); + doAnswer(call -> { assertFalse(workerOwner); top.set(original); return null; }).when(p).closeInventory(); + return p; + } + void owner() { assertFalse(workerOwner); owners.remove().run(); } + void work() { workerOwner = true; try { workers.remove().run(); } finally { workerOwner = false; } } + Inventory open(Player p) { menu.open(p); owner(); work(); owner(); return p.getOpenInventory().getTopInventory(); } + InventoryClickEvent click(Player p, int slot, boolean left) { + var event = mock(InventoryClickEvent.class); doReturn(p.getOpenInventory()).when(event).getView(); + when(event.getWhoClicked()).thenReturn(p); when(event.getRawSlot()).thenReturn(slot); when(event.isLeftClick()).thenReturn(left); return event; + } + @Override public void close() { menu.close(); bukkit.close(); } + } + @Test void refreshRetainsVisibleOwnershipAcrossResetAndReadFailure() { + for (boolean failed : List.of(false, true)) { + try (var f = new Fixture()) { + Player p = f.player(1); Inventory visible = f.open(p); + f.menu.click(f.click(p, 50, true)); + if (failed) { f.failRead = true; f.work(); } + else { f.menu.reset(); f.work(); } + while (!f.owners.isEmpty()) f.owner(); + assertNotSame(visible, p.getOpenInventory().getTopInventory()); + assertEquals(0, f.menu.sessionCount()); + } + } + } + @Test void disabledPluginClosesItsOwnViewDirectlyOnThePrimaryOwner() throws Exception { + try (var f = new Fixture()) { + Player p = f.player(1); Inventory visible = f.open(p); + f.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + try { + var publicOwner = Bukkit.class.getMethod("isOwnedByCurrentRegion", Entity.class); + f.bukkit.when(() -> publicOwner.invoke(null, p)).thenReturn(true); + } catch (NoSuchMethodException legacyBukkit) { /* primary-thread ownership is authoritative here */ } + f.rejectScheduler = true; f.menu.close(); + assertNotSame(visible, p.getOpenInventory().getTopInventory()); + verify(p).closeInventory(); assertTrue(f.owners.isEmpty()); + } + } + public static final class OwnedPlatform { + public static boolean isOwnedByCurrentRegion(Entity player) { return true; } + } + public static final class ForeignPlatform { + public static boolean isOwnedByCurrentRegion(Entity player) { return false; } + } + public static final class FailedPlatform { + public static boolean isOwnedByCurrentRegion(Entity player) { throw new IllegalStateException("Synthetic ownership failure"); } + } + @Test void shutdownCompatibilityRequiresPositivePublicOwnershipAndNeverAssumesMissingOrFailedApis() { + try (var f = new Fixture()) { + Player p = f.player(1); + assertTrue(VoteStreaksMenu.ownsForShutdown(OwnedPlatform.class, p)); + assertFalse(VoteStreaksMenu.ownsForShutdown(ForeignPlatform.class, p)); + assertFalse(VoteStreaksMenu.ownsForShutdown(FailedPlatform.class, p)); + assertFalse(VoteStreaksMenu.ownsForShutdown(Object.class, p)); + f.bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertFalse(VoteStreaksMenu.ownsForShutdown(ForeignPlatform.class, p), "a tick thread need not own another entity"); + assertFalse(VoteStreaksMenu.ownsForShutdown(FailedPlatform.class, p)); + } + } + @Test void readsOnWorkerRendersOnOwnerAndDisplaysFinalVisibleStreakText() { + try (var f = new Fixture()) { + Player player = f.player(1); f.menu.open(player); assertEquals(0, f.reads.get()); + f.owner(); assertEquals(0, f.reads.get()); assertTrue(f.inventories.isEmpty()); + f.work(); assertEquals(1, f.reads.get()); assertTrue(f.inventories.isEmpty()); + f.owner(); Inventory inventory = player.getOpenInventory().getTopInventory(); + assertInstanceOf(VoteStreaksMenu.Holder.class, inventory.getHolder()); + assertEquals("§6DAILY streak", inventory.getItem(11).getItemMeta().getDisplayName()); + assertEquals(List.of("§7Current: §e3 days", "§7Best: §e7 days"), inventory.getItem(11).getItemMeta().getLore()); + assertTrue(inventory.getItem(20).getItemMeta().getLore().contains("§7Progress only; this does not claim rewards.")); + } + } + @Test void productionConstructorUsesPersistenceWorkerForUuidUserLoadingAndStateReads() { + try (var f = new Fixture()) { + var timer = f.plugin.getUserManager().getDataManager().getTimer(); + doAnswer(call -> { f.workers.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + var users = f.plugin.getVotingPluginUserManager(); + var user = mock(com.bencodez.votingplugin.user.VotingPluginUser.class); + when(users.getVotingPluginUser(any(UUID.class), anyString())).thenAnswer(call -> { + assertTrue(f.workerOwner); return user; + }); + when(f.plugin.getVoteStreakHandler().snapshotForDisplay(user)).thenAnswer(call -> { + assertTrue(f.workerOwner); return VoteStreaksSnapshotTest.snapshot(1); + }); + var production = new VoteStreaksMenu(f.plugin); + try { + Player p = f.player(1); production.open(p); f.owner(); + verify(users, never()).getVotingPluginUser(any(UUID.class)); + f.work(); f.owner(); verify(users).getVotingPluginUser(p.getUniqueId(), "test-player"); + assertEquals(1, f.inventories.size()); + } finally { production.close(); } + } + } + @Test void replacementAndUnrelatedMenuOpeningInvalidateOldSnapshotCallbacks() { + try (var f = new Fixture()) { + Player p = f.player(1); f.menu.open(p); f.owner(); f.menu.open(p); f.owner(); + f.work(); assertTrue(f.owners.isEmpty(), "replaced request must not reopen"); + f.work(); f.owner(); assertEquals(1, f.inventories.size()); + f.menu.open(p); f.owner(); f.work(); + var other = mock(InventoryOpenEvent.class); when(other.getPlayer()).thenReturn(p); doReturn(mock(Inventory.class)).when(other).getInventory(); + f.menu.inventoryOpen(other); f.menu.unrelatedInventoryOpen(other); verify(other, never()).setCancelled(anyBoolean()); + f.owner(); assertEquals(1, f.inventories.size()); assertEquals(0, f.menu.sessionCount()); + } + } + @Test void navigationIsDeferredClampedAndDoesNotRereadStorageOrRunRewards() { + try (var f = new Fixture()) { + Player p = f.player(1); f.open(p); int reads = f.reads.get(); + var next = f.click(p, 53, true); f.menu.click(next); verify(next).setCancelled(true); + assertEquals(1, f.inventories.size(), "opening inside InventoryClickEvent is unsafe"); + f.owner(); assertEquals(2, f.inventories.size()); assertEquals(reads, f.reads.get()); + var holder = (VoteStreaksMenu.Holder)p.getOpenInventory().getTopInventory().getHolder(); assertEquals(1, holder.page); + f.menu.click(f.click(p, 53, true)); f.owner(); holder = (VoteStreaksMenu.Holder)p.getOpenInventory().getTopInventory().getHolder(); assertEquals(1, holder.page); + verifyNoInteractions(f.plugin.getRewardHandler()); + } + } + @Test void unrelatedClicksDragsAndClosesRemainUnmodified() { + try (var f = new Fixture()) { + Player p = f.player(1); var click = f.click(p, 0, true); f.menu.click(click); verify(click, never()).setCancelled(anyBoolean()); + var drag = mock(InventoryDragEvent.class); doReturn(p.getOpenInventory()).when(drag).getView(); f.menu.drag(drag); verify(drag, never()).setCancelled(anyBoolean()); + var close = mock(InventoryCloseEvent.class); doReturn(mock(Inventory.class)).when(close).getInventory(); when(close.getPlayer()).thenReturn(p); f.menu.inventoryClose(close); + assertEquals(0, f.menu.sessionCount()); + } + } + @Test void ownDragsShiftSwapsAndCancelledClicksCannotNavigateOrMoveItems() { + try (var f = new Fixture()) { + Player p = f.player(1); f.open(p); + var drag = mock(InventoryDragEvent.class); doReturn(p.getOpenInventory()).when(drag).getView(); f.menu.drag(drag); verify(drag).setCancelled(true); + for (int mode = 0; mode < 3; mode++) { + var click = f.click(p, 53, mode != 0); when(click.isShiftClick()).thenReturn(mode == 1); when(click.isCancelled()).thenReturn(mode == 2); + f.menu.click(click); verify(click).setCancelled(true); assertTrue(f.owners.isEmpty()); + } + } + } + @Test void closeQuitReloadAndPermissionChangesPreventStaleOpens() { + for (String mode : List.of("close", "quit", "reload", "permission", "disconnect")) { + try (var f = new Fixture()) { + Player p = f.player(1); f.menu.open(p); f.owner(); f.work(); + switch (mode) { + case "close" -> { var event = mock(InventoryCloseEvent.class); doReturn(p.getOpenInventory().getTopInventory()).when(event).getInventory(); when(event.getPlayer()).thenReturn(p); f.menu.inventoryClose(event); } + case "quit" -> f.menu.quit(new PlayerQuitEvent(p, "")); + case "reload" -> f.menu.suspend(); + case "permission" -> when(p.hasPermission(VoteStreaksMenu.PERMISSION)).thenReturn(false); + case "disconnect" -> when(p.isOnline()).thenReturn(false); + } + f.owner(); assertTrue(f.inventories.isEmpty()); assertEquals(0, f.menu.sessionCount(), mode); + } + } + } + @Test void multiplePlayersHaveIndependentInventoriesAndOneCloseDoesNotAffectTheOther() { + try (var f = new Fixture()) { + Player first = f.player(1), second = f.player(2); Inventory a = f.open(first), b = f.open(second); assertNotSame(a, b); + var close = mock(InventoryCloseEvent.class); when(close.getInventory()).thenReturn(a); when(close.getPlayer()).thenReturn(first); + f.menu.inventoryClose(close); assertEquals(1, f.menu.sessionCount()); + f.menu.click(f.click(second, 53, true)); f.owner(); assertEquals(1, ((VoteStreaksMenu.Holder)second.getOpenInventory().getTopInventory().getHolder()).page); + } + } + @Test void workerAndSchedulerFailureReleaseStateWithoutLeakingDiagnostics() { + for (String mode : List.of("read", "worker", "scheduler")) { + try (var f = new Fixture()) { + Player p = f.player(1); + f.failRead = mode.equals("read"); f.rejectWorker = mode.equals("worker"); + f.menu.open(p); f.owner(); + if (!f.rejectWorker) { + f.rejectScheduler = mode.equals("scheduler"); f.work(); + if (!f.owners.isEmpty()) f.owner(); + } + assertEquals(0, f.menu.sessionCount()); assertTrue(f.inventories.isEmpty()); + verify(p, never()).sendMessage(contains("Synthetic")); + } + } + } + @Test void boundedWorkerAdmissionAndNestedReloadsKeepOldCallbacksInert() { + try (var f = new Fixture()) { + for (int i = 0; i < 17; i++) { f.menu.open(f.player(i + 1)); f.owner(); } + assertEquals(16, f.workers.size()); assertEquals(16, f.menu.sessionCount()); + f.menu.suspend(); f.menu.suspend(); f.menu.resume(); + while (!f.workers.isEmpty()) f.work(); assertTrue(f.inventories.isEmpty()); assertEquals(0, f.menu.sessionCount()); + Player p = f.player(100); f.menu.open(p); f.owner(); assertTrue(f.workers.isEmpty()); + f.menu.resume(); f.open(p); assertEquals(1, f.inventories.size()); + } + } + @Test void failureAfterPartialNativeOpenCleansOnlyItsOwnInventory() { + try (var f = new Fixture()) { + Player p = f.player(1); f.duringOpen = () -> { throw new IllegalStateException("Synthetic native diagnostic"); }; + f.open(p); while (!f.owners.isEmpty()) f.owner(); + assertEquals(0, f.menu.sessionCount()); verify(p).closeInventory(); + verify(p).sendMessage("Unable to open vote streaks. Please try again later."); + verify(p, never()).sendMessage(contains("Synthetic")); + } + } + @Test void reloadDuringNativeOpenClosesOnlyTheInvalidatedView() { + try (var f = new Fixture()) { + Player p = f.player(1); f.duringOpen = f.menu::reset; + f.open(p); assertEquals(0, f.menu.sessionCount()); verify(p).closeInventory(); + Inventory replacement = mock(Inventory.class); f.tops.get(p.getUniqueId()).set(replacement); + while (!f.owners.isEmpty()) f.owner(); verify(p, times(1)).closeInventory(); + assertSame(replacement, p.getOpenInventory().getTopInventory()); + } + } + @Test void foreignPlayerCannotOpenAnotherPlayersSnapshotAndClosedServiceIsInert() { + try (var f = new Fixture()) { + Player first = f.player(1), second = f.player(2); Inventory inv = f.open(first); + var open = mock(InventoryOpenEvent.class); when(open.getInventory()).thenReturn(inv); when(open.getPlayer()).thenReturn(second); + f.menu.inventoryOpen(open); verify(open).setCancelled(true); + f.menu.close(); f.menu.open(second); while (!f.owners.isEmpty()) f.owner(); assertEquals(0, f.menu.sessionCount()); assertEquals(1, f.reads.get()); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksReadOnlyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksReadOnlyTest.java new file mode 100644 index 0000000000..404b0df7b9 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksReadOnlyTest.java @@ -0,0 +1,59 @@ +package com.bencodez.votingplugin.streaks; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.util.List; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakHandler; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class VoteStreaksReadOnlyTest { + private VoteStreakHandler handler(VotingPluginMain plugin, YamlConfiguration config) { + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + var handler = new VoteStreakHandler(plugin); handler.reload(); return handler; + } + private YamlConfiguration config() { + var c = new YamlConfiguration(); + c.set("VoteStreaks.ProgressGroups.daily.Type", "DAILY"); + c.set("VoteStreaks.ProgressGroups.daily.VotesRequired", 2); + for (String id : List.of("first", "next", "disabled")) { + c.set("VoteStreaks.ProgressGroups.daily.Milestones." + id + ".Enabled", !id.equals("disabled")); + c.set("VoteStreaks.ProgressGroups.daily.Milestones." + id + ".Amount", id.equals("first") ? 3 : 5); + } + return c; + } + @Test void sharedMilestonesReadOneExistingStateWithoutWritingOrGivingRewards() { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); var handler = handler(plugin, config()); + var user = mock(VotingPluginUser.class); when(user.getDayVoteStreak()).thenReturn(3); when(user.getBestDayVoteStreak()).thenReturn(8); + when(user.getVoteStreakState("VoteStreakGroup_DAILY_daily")).thenReturn("2026-01-10|3|2|true||0||8"); + var data = handler.snapshotForDisplay(user); + assertEquals(3, data.periods().getFirst().current()); assertEquals(8, data.periods().getFirst().best()); + assertEquals(List.of("first", "next"), data.milestones().stream().map(VoteStreaksSnapshot.Milestone::id).toList()); + assertTrue(data.milestones().stream().allMatch(row -> row.current() == 3 && row.best() == 8)); + verify(user, times(1)).getVoteStreakState("VoteStreakGroup_DAILY_daily"); + verify(user, never()).setVoteStreakState(anyString(), anyString()); + verifyNoInteractions(plugin.getRewardHandler()); + } + @Test void oldSnapshotSurvivesReloadAndDisabledDefinitionsDisappear() { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); var config = config(); var handler = handler(plugin, config); + var user = mock(VotingPluginUser.class); var old = handler.snapshotForDisplay(user); + config.set("VoteStreaks.ProgressGroups.daily.Milestones.first.Enabled", false); handler.reload(); var current = handler.snapshotForDisplay(user); + assertEquals(2, old.milestones().size()); assertEquals(List.of("next"), current.milestones().stream().map(VoteStreaksSnapshot.Milestone::id).toList()); + verify(user, never()).setVoteStreakState(anyString(), anyString()); + } + @Test void visibleRowsAndStorageReadsAreBounded() { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); var config = new YamlConfiguration(); + for (int i = 0; i < 520; i++) { + config.set("VoteStreaks.row" + i + ".Type", "DAILY"); + config.set("VoteStreaks.row" + i + ".Enabled", true); + config.set("VoteStreaks.row" + i + ".Requirements.Amount", 3); + } + var handler = handler(plugin, config); var user = mock(VotingPluginUser.class); var view = handler.snapshotForDisplay(user); + assertEquals(512, view.milestones().size()); assertTrue(view.truncated()); + verify(user, times(512)).getVoteStreakState(anyString()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksSnapshotTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksSnapshotTest.java new file mode 100644 index 0000000000..924557d887 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksSnapshotTest.java @@ -0,0 +1,40 @@ +package com.bencodez.votingplugin.streaks; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType; + +class VoteStreaksSnapshotTest { + static List periods() { + return List.of(new VoteStreaksSnapshot.Period(VoteStreakType.DAILY, 3, 7), + new VoteStreaksSnapshot.Period(VoteStreakType.WEEKLY, 1, 2), + new VoteStreaksSnapshot.Period(VoteStreakType.MONTHLY, 0, 1)); + } + static VoteStreaksSnapshot snapshot(int count) { + var rows = new ArrayList(); + for (int i = 1; i <= count; i++) rows.add(new VoteStreaksSnapshot.Milestone("milestone-" + i, + VoteStreakType.DAILY, 3, 7, i, 1, false)); + return new VoteStreaksSnapshot(periods(), rows, false); + } + @Test void pagesClampEmptyNegativeAndExcessiveNavigation() { + assertEquals(1, snapshot(0).pages()); assertTrue(snapshot(0).page(Integer.MAX_VALUE).isEmpty()); + var data = snapshot(12); assertEquals(3, data.pages()); + assertEquals("milestone-1", data.page(-1).getFirst().id()); + assertEquals(List.of("milestone-11", "milestone-12"), data.page(Integer.MAX_VALUE).stream().map(VoteStreaksSnapshot.Milestone::id).toList()); + } + @Test void collectionsAreDetachedAndCannotBeChanged() { + var rows = new ArrayList<>(snapshot(1).milestones()); + var data = new VoteStreaksSnapshot(periods(), rows, false); rows.clear(); + assertEquals(1, data.milestones().size()); assertThrows(UnsupportedOperationException.class, () -> data.page(0).clear()); + assertThrows(IllegalArgumentException.class, () -> new VoteStreaksSnapshot(List.of(), List.of(), false)); + } + @Test void recurrenceUsesExistingIntervalMultiplesWithoutIntegerOverflow() { + var row = new VoteStreaksSnapshot.Milestone("recurring", VoteStreakType.MONTHLY, Integer.MAX_VALUE, + Integer.MAX_VALUE, Integer.MAX_VALUE, 1, true); + assertEquals(4294967294L, row.nextThreshold()); + assertEquals(6, new VoteStreaksSnapshot.Milestone("repeat", VoteStreakType.WEEKLY, 3, 3, 3, 1, true).nextThreshold()); + assertEquals("weeks", VoteStreaksSnapshot.unit(VoteStreakType.WEEKLY)); + } +} diff --git a/docs/vote-streaks-menu.md b/docs/vote-streaks-menu.md new file mode 100644 index 0000000000..a9c7263b14 --- /dev/null +++ b/docs/vote-streaks-menu.md @@ -0,0 +1,87 @@ +# Vote streak progression menu + +`/vote streaks` opens a separate read-only inventory menu showing your existing +vote streaks. The singular `/vote streak`, normal `/vote`, voting-site menus, +and configured reward delivery keep their existing behavior. This feature is +independent of the experimental GUI test commands. + +Grant `VotingPlugin.Commands.Vote.Streaks` to players who should use it. This +permission also passes the `/vote` root command gate and grants no other voting +subcommands. The existing `VotingPlugin.Player` command bundle is accepted as +well; its normal root-command access still applies. Console and other-player +inspection are not part of this command. + +The three summary icons show the current and best stored daily, weekly and +monthly streaks. The progression row shows enabled `VoteStreaks` definitions +from **SpecialRewards.yml**, including progress-group milestones. Each entry +shows its own current/best progress, threshold, required votes per period and +recurrence. Units are days, weeks or months, not a mixture of vote totals. + +A threshold being reached is progress information, not proof that a reward was +delivered. There are no claim buttons. Opening, clicking, navigating and +refreshing never advance a streak, write progression or execute rewards. +The normal voting pipeline remains responsible for reward processing. + +## Configuration and navigation + +No configuration migration or companion-plugin update is required. Existing +streak definitions are used unchanged; no streak is enabled automatically. +An empty configuration still shows the three stored summary values and a clear +message that no enabled milestones exist. + +The new optional title in **GUI.yml** defaults when absent: + +```yaml +CHEST: + VoteStreaks: + Name: '&6Vote streaks' +``` + +Merge `VoteStreaks` into the existing `CHEST` section; do not duplicate it. +Run `/av reload` after changing it. The title accepts legacy colors. Other +configured menus and their titles are unaffected. + +Use Previous/Next for five milestones per page, Refresh to read current values, +and Close or Escape to leave. Milestones follow the existing configuration +order. At most 512 enabled milestones are shown, with an explicit truncation +notice; this display limit does not affect reward eligibility or processing. +Legacy summary values and configured progress groups are separate existing +counters and can differ. This menu observes stored values without simulating a +vote or changing missed-period accounting. + +## Scheduling and lifecycle + +Player UUID and name are captured on the player owner, then resolved together +on the existing persistence worker so configured offline-mode storage identities +remain authoritative. User loading and state reads stay on that worker; inventory +creation and interaction run on the player owner. The menu uses an exact custom +inventory holder, never a title match or a production GUI registration. Click, +drag, shift, hotbar and drop actions cannot move preview items. Unrelated +inventories and their events are left alone. + +A newer request, another menu opening, close, disconnect, world change, death, +reload or shutdown invalidates pending callbacks. A delayed read cannot force +a closed menu back open or overwrite a newer menu. Reload blocks admission +while configuration changes and closes only this menu. Reads and menus are +bounded (16 pending data loads and 128 sessions); overload reports a useful +retry message. A data read taking over 30 seconds is discarded when it returns. +There are no animation loops, new worker pools or new persistent files. + +This is a native Bukkit inventory interface, without display entities, dialogs, +client mods or a resource pack. It uses the existing entity completion scheduler +for Paper/Folia ownership. Normal shutdown closes views directly only when the +public platform ownership check permits it (including Folia's stopped-region +shutdown owner); it otherwise uses the owner scheduler. External live plugin +unload/reload tools that disable owner scheduling are unsupported: use the +plugin's own reload or a normal server restart. Mocked scheduling and inventory tests are not a live +Spigot/Paper/Folia or visual-client verification claim. + +## Testing + +On a disposable server with existing streak definitions, grant the permission, +run `/vote streaks`, and check all three summaries and the configured milestones +against the stored streak values. Test multiple pages, Refresh, Escape, two +players, opening another menu during loading, and reload with a menu open. +Try shift-clicks, number keys and dragging: no preview items should move. +Confirm normal `/vote`, `/vote streak` and unrelated inventories still work. +Opening the menu repeatedly must not grant rewards or change any counters. From ccfe5f1cf132ceef257fa87e1598901b52adbe70 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:28:47 -0600 Subject: [PATCH 2/2] Preserve custom vote streaks command precedence --- .../votingplugin/commands/CommandLoader.java | 4 ++- .../streaks/VoteStreaksCommandTest.java | 35 +++++++++++++++++++ docs/vote-streaks-menu.md | 2 ++ 3 files changed, 40 insertions(+), 1 deletion(-) 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 7ab17afdf8..c0cf269215 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,6 @@ public void updateReplacements() { */ private void loadVoteCommand() { plugin.setVoteCommand(new ArrayList<>()); - com.bencodez.votingplugin.streaks.VoteStreaksCommand.register(plugin); plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" }, "VotingPlugin.Commands.Vote.Help|" + playerPerm, "View help page") { @@ -4070,6 +4069,9 @@ public void run() { } } + // Preserve administrator-defined matches ahead of this new fallback. + com.bencodez.votingplugin.streaks.VoteStreaksCommand.register(plugin); + ArrayList avCommands = com.bencodez.advancedcore.command.CommandLoader.getInstance() .getBasicCommands("VotingPlugin"); for (CommandHandler cmd : avCommands) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksCommandTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksCommandTest.java index 071e745611..7fd3b3d947 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksCommandTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/streaks/VoteStreaksCommandTest.java @@ -36,6 +36,41 @@ class VoteStreaksCommandTest { } } } + @Test void existingCustomStreaksCommandWinsThroughActualRootDispatch() throws Exception { + for (String argument : List.of("streaks", "Other&Streaks")) { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + var list = new java.util.concurrent.atomic.AtomicReference>(); + when(plugin.getVoteCommand()).thenAnswer(call -> list.get()); + doAnswer(call -> { list.set(call.getArgument(0)); return null; }).when(plugin).setVoteCommand(any()); + when(plugin.getConfigFile().isAddCustomCommands()).thenReturn(true); + when(plugin.getConfigFile().getCustomCommands()).thenReturn(new java.util.HashSet<>(List.of("custom"))); + var custom = new org.bukkit.configuration.file.YamlConfiguration(); + custom.set("Args", new ArrayList<>(List.of(argument))); + when(plugin.getConfigFile().getCustomCommands("custom")).thenReturn(custom); + var basic = mock(com.bencodez.advancedcore.command.CommandLoader.class); + when(basic.getBasicCommands("VotingPlugin")).thenReturn(new ArrayList<>()); + var scheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + var tasks = new java.util.ArrayDeque(); + doAnswer(call -> { tasks.add(call.getArgument(1)); return null; }).when(scheduler) + .runTaskAsynchronously(eq(plugin), any(Runnable.class)); + var loader = new com.bencodez.votingplugin.commands.CommandLoader(plugin); + var load = loader.getClass().getDeclaredMethod("loadVoteCommand"); load.setAccessible(true); + try (var singleton = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) { + singleton.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(basic); + load.invoke(loader); + } + var configured = list.get().stream().filter(c -> java.util.Arrays.equals(c.getArgs(), new String[]{argument})) + .findFirst().orElseThrow(); + var observed = spy(configured); doNothing().when(observed).execute(any(), any()); + list.get().set(list.get().indexOf(configured), observed); + Player player = mock(Player.class); when(player.hasPermission(anyString())).thenReturn(true); + assertTrue(new com.bencodez.votingplugin.commands.executers.CommandVote(plugin) + .onCommand(player, null, "vote", new String[]{"streaks"})); + assertEquals(1, tasks.size()); tasks.remove().run(); + verify(observed).execute(eq(player), org.mockito.AdditionalMatchers.aryEq(new String[]{"streaks"})); + verifyNoInteractions(plugin.getVoteStreaksMenu()); + } + } @Test void realDispatchPreservesExistingHandlersAndUsesDedicatedPlayerPermission() { var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); var old = mock(CommandHandler.class); var commands = new ArrayList<>(List.of(old)); when(plugin.getVoteCommand()).thenReturn(commands); diff --git a/docs/vote-streaks-menu.md b/docs/vote-streaks-menu.md index a9c7263b14..f52dec8090 100644 --- a/docs/vote-streaks-menu.md +++ b/docs/vote-streaks-menu.md @@ -4,6 +4,8 @@ vote streaks. The singular `/vote streak`, normal `/vote`, voting-site menus, and configured reward delivery keep their existing behavior. This feature is independent of the experimental GUI test commands. +If an enabled custom command already matches `streaks` (including an alias), +that configured command keeps precedence; the new menu does not replace it. Grant `VotingPlugin.Commands.Vote.Streaks` to players who should use it. This permission also passes the `/vote` root command gate and grants no other voting