Compare commits

...
42 changed files with 5522 additions and 0 deletions

No files matched your search

+6
View File
@@ -15,6 +15,12 @@ mvn -B -f VotingPlugin/pom.xml package
VotingPlugin Control is an optional management plane. Voting, routing, rewards, joins, and shutdown do not depend on it;
connectors use outbound requests and capability negotiation so either repository can be upgraded independently.
## Experimental voting menu
An opt-in native holographic vote menu is available through `/av testhologram` on Minecraft 1.19.4+.
See [configuration, testing instructions, and interaction limits](docs/experimental-hologram-vote-gui.md).
The normal `/vote` inventory GUI is unchanged.
## Optional Control feature set
- Authenticated outbound discovery/configuration connectors for Bukkit, BungeeCord, and Velocity nodes.
@@ -155,6 +155,12 @@ import lombok.Getter;
import lombok.Setter;
public class VotingPluginMain extends AdvancedCorePlugin {
private com.bencodez.votingplugin.hologram.HologramVoteMenu hologramVoteMenu;
public com.bencodez.votingplugin.hologram.HologramVoteMenu getHologramVoteMenu() { return hologramVoteMenu; }
private com.bencodez.votingplugin.experimental.ExperimentalGUIManager experimentalGUIManager;
public com.bencodez.votingplugin.experimental.ExperimentalGUIManager getExperimentalGUIManager() { return experimentalGUIManager; }
@Getter
public static VotingPluginMain plugin;
@@ -694,6 +700,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
@Override
public void onPostLoad() {
hologramVoteMenu = new com.bencodez.votingplugin.hologram.HologramVoteMenu(this);
experimentalGUIManager = new com.bencodez.votingplugin.experimental.ExperimentalGUIManager(this);
ensureCommunicationSecret();
// auto conversion for Shop.yml
if (plugin.getShopFile().isJustCreated()) {
@@ -1944,6 +1952,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
@Override
public void onDisable() {
if (experimentalGUIManager != null) experimentalGUIManager.shutdown();
if (hologramVoteMenu != null) hologramVoteMenu.shutdown();
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
try {
shutdownVoteReminders();
@@ -2045,6 +2055,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
* Register events.
*/
private void registerEvents() {
Bukkit.getPluginManager().registerEvents(hologramVoteMenu, this);
Bukkit.getPluginManager().registerEvents(experimentalGUIManager, this);
Bukkit.getPluginManager().registerEvents(experimentalGUIManager.inventoryListener(), this);
Bukkit.getPluginManager().registerEvents(experimentalGUIManager.npcListener(), this);
Bukkit.getPluginManager().registerEvents(experimentalGUIManager.displaysListener(), this);
PluginManager pm = getServer().getPluginManager();
pm.registerEvents(new PlayerJoinEvent(this), this);
@@ -2123,6 +2138,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl,
boolean updateActiveBackendRuntime) {
if (experimentalGUIManager != null) experimentalGUIManager.clear();
if (hologramVoteMenu != null) hologramVoteMenu.clear();
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.pause();
try {
configFile.reloadData();
@@ -475,6 +475,52 @@ public class CommandLoader {
*/
private void loadAdminVoteCommand() {
plugin.setAdminVoteCommand(new ArrayList<>());
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, new String[] { "TestHologram" },
"VotingPlugin.Commands.AdminVote.TestHologram|" + adminPerm,
"Open the experimental holographic vote menu") {
@Override
public void execute(CommandSender sender, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("This test command requires a player.");
return;
}
plugin.getExperimentalGUIManager().open(player, com.bencodez.votingplugin.experimental.ExperimentalGUIType.HOLOGRAM);
}
});
for (var type : com.bencodez.votingplugin.experimental.ExperimentalGUIType.values()) {
if (type == com.bencodez.votingplugin.experimental.ExperimentalGUIType.HOLOGRAM) continue;
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, new String[] {type.command()},
"VotingPlugin.Commands.AdminVote." + type.command() + "|" + adminPerm,
"Test experimental " + type.configurationKey() + " voting GUI") {
@Override public void execute(CommandSender sender, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("This test command requires a player.");
return;
}
plugin.getExperimentalGUIManager().open(player, type);
}
});
}
for (String action : new String[] {"create", "remove", "list", "inspect"}) {
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, new String[] {"testterminalgui", action},
"VotingPlugin.Commands.AdminVote.testterminalgui|" + adminPerm,
"Manage your temporary experimental voting terminal: " + action) {
@Override public void execute(CommandSender sender, String[] args) {
plugin.getExperimentalGUIManager().terminalControl(sender, action);
}
});
}
for (String action : new String[] {"list", "close", "status", "cleanup"}) {
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, new String[] {"TestGUI", action},
"VotingPlugin.Commands.AdminVote.TestGUI|" + adminPerm,
"Manage experimental voting GUI sessions: " + action) {
@Override public void execute(CommandSender sender, String[] args) {
plugin.getExperimentalGUIManager().control(sender, action);
}
});
}
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, new String[] { "CurrentPluginTime" },
"VotingPlugin.Commands.AdminVote.CurrentPluginTime|" + adminPerm, "Current plugin time") {
@@ -0,0 +1,109 @@
package com.bencodez.votingplugin.experimental;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.hologram.HologramMenuScheduler;
import com.bencodez.votingplugin.hologram.HologramVoteModel;
/** Bounded, session-owned native dialogs. No shared production dialog registrations. */
final class ExperimentalDialogs {
record Button(String label, String tooltip, String url, String action) {
Button {
if (label == null || label.length() > 256 || tooltip == null || tooltip.length() > 512)
throw new IllegalArgumentException("Invalid dialog text");
if (url != null && HologramVoteModel.votingUrl(url).isEmpty())
throw new IllegalArgumentException("Invalid voting URL");
if ((url == null) == (action == null) || action != null && action.length() > 128)
throw new IllegalArgumentException("Exactly one dialog action is required");
}
}
interface Backend {
Runnable show(Player player, String title, String body, List<Button> buttons,
java.util.function.BiConsumer<UUID, String> action);
void close(Player player);
}
private static final class Owned {
final ExperimentalSessions.Session session;
final Player player;
volatile Frame frame;
Owned(ExperimentalSessions.Session session, Player player) { this.session = session; this.player = player; }
}
private static final class Frame {
final AtomicBoolean selected = new AtomicBoolean();
volatile Runnable release = () -> {};
}
private final VotingPluginMain plugin;
private final HologramMenuScheduler scheduler;
private final ExperimentalSessions sessions;
private final Map<UUID, Owned> resources = new ConcurrentHashMap<>();
private final Map<UUID, Owned> players = new ConcurrentHashMap<>();
private Backend backend;
ExperimentalDialogs(VotingPluginMain plugin, HologramMenuScheduler scheduler, ExperimentalSessions sessions) {
this.plugin = plugin; this.scheduler = scheduler; this.sessions = sessions;
}
ExperimentalDialogs(VotingPluginMain plugin, HologramMenuScheduler scheduler, ExperimentalSessions sessions, Backend backend) {
this(plugin, scheduler, sessions); this.backend = backend;
}
static boolean supported() { return NativeExperimentalDialog.supported(); }
// Invoked only on the player scheduler after asynchronous user snapshot completion.
void render(ExperimentalSessions.Session session, Player player, String title, String body,
List<Button> buttons, Consumer<String> selection) {
if (!sessions.current(session)) return;
if (buttons.size() > 8 || title.length() > 256 || body.length() > 4096)
throw new IllegalArgumentException("Dialog presentation exceeds bounds");
Owned owned = resources.get(session.id);
if (owned == null) {
synchronized (resources) {
if (resources.size() >= 64) throw new IllegalStateException("Dialog capacity reached");
owned = resources.get(session.id);
if (owned == null) {
owned = new Owned(session, player);
resources.put(session.id, owned);
players.put(session.player, owned);
Owned captured = owned;
session.own(() -> retire(captured), failure -> plugin.getLogger().warning("Experimental dialog cleanup failed: " + failure));
}
}
}
Owned captured = owned;
Frame frame = new Frame();
Frame previous = owned.frame;
owned.frame = frame;
if (previous != null) previous.release.run();
synchronized (this) { if (backend == null) backend = new NativeExperimentalDialog(plugin); }
try {
frame.release = backend.show(player, title, body, List.copyOf(buttons), (owner, action) -> {
if (!session.player.equals(owner) || captured.frame != frame || !sessions.current(session)) return;
scheduler.player(player, () -> {
if (captured.frame != frame || !sessions.current(session) || !plugin.isEnabled()
|| !player.isOnline() || !(player.hasPermission("VotingPlugin.Admin")
|| player.hasPermission("VotingPlugin.Commands.AdminVote." + session.type.command()))
|| !frame.selected.compareAndSet(false, true)) return;
try { selection.accept(action); }
catch (RuntimeException failure) { sessions.close(session); }
}, () -> sessions.close(session));
});
if (!sessions.current(session) || captured.frame != frame) frame.release.run();
else session.activate();
} catch (RuntimeException | LinkageError failure) { sessions.close(session); throw failure; }
}
private void retire(Owned owned) {
resources.remove(owned.session.id, owned);
Frame frame = owned.frame;
owned.frame = null;
if (frame != null) frame.release.run();
// Supported native APIs cannot identify the currently displayed dialog. An
// unconditional close here could dismiss a production dialog opened afterward.
// Native close buttons/Escape dismiss the screen; retirement invalidates callbacks.
players.remove(owned.session.player, owned);
}
}
@@ -0,0 +1,230 @@
package com.bencodez.votingplugin.experimental;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import org.bukkit.util.Vector;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.hologram.HologramMenuScheduler;
/** Bounded, reflectively loaded display/interaction renderer for experimental menus. */
public final class ExperimentalDisplays implements Listener {
private static final int MAX_TARGETS = 10;
private static final int MAX_ENTITIES = 32;
private static final int MAX_FRAMES = 64;
public enum Action { SITE, PREVIOUS, NEXT, CLOSE, INFO, PREVIOUS_REWARD, NEXT_REWARD, SITE_PAGE, TERMINAL_STYLE }
public record Target(String id, String text, ItemStack icon, double x, double y, Action action, String siteKey) {
public Target(String id, String text, ItemStack icon, double x, double y, Action action) {
this(id, text, icon, x, y, action, null);
}
public Target {
if (id == null || id.isBlank() || id.length() > 128) throw new IllegalArgumentException("Invalid display target id");
if (text == null || text.length() > 256) throw new IllegalArgumentException("Invalid display target text");
if (!Double.isFinite(x) || !Double.isFinite(y) || Math.abs(x) > 8 || Math.abs(y) > 8)
throw new IllegalArgumentException("Invalid display target position");
Objects.requireNonNull(action, "action");
if (icon != null) icon = icon.clone();
}
}
private static final class Frame {
final ExperimentalSessions.Session session; final Player player; final Location anchor;
final Consumer<Target> selection; final long generation; final Signature signature;
final List<Location> footprint;
final List<Entity> owned = new ArrayList<>(); final Map<UUID, Target> hitTargets = new HashMap<>();
final AtomicBoolean closed = new AtomicBoolean(); boolean cleanupQueued;
Frame(ExperimentalSessions.Session session, Player player, Location anchor, Consumer<Target> selection,
long generation, Signature signature, List<Location> footprint) { this.session=session; this.player=player; this.anchor=anchor.clone();
this.selection=selection; this.generation=generation; this.signature=signature; this.footprint=footprint; }
}
private final VotingPluginMain plugin; private final HologramMenuScheduler scheduler;
private final ExperimentalSessions sessions; private final Map<UUID, Frame> currentFrames = new ConcurrentHashMap<>();
private final Map<UUID, Frame> pendingFrames = new ConcurrentHashMap<>();
private final Map<UUID, Frame> entities = new ConcurrentHashMap<>(); private final AtomicLong generations = new AtomicLong();
private final Map<UUID, Boolean> registeredSessions = new ConcurrentHashMap<>();
public ExperimentalDisplays(VotingPluginMain plugin, HologramMenuScheduler scheduler, ExperimentalSessions sessions) {
this.plugin=Objects.requireNonNull(plugin); this.scheduler=Objects.requireNonNull(scheduler); this.sessions=Objects.requireNonNull(sessions);
}
public static boolean supported() {
try { Class<?> text=Class.forName("org.bukkit.entity.TextDisplay"); Class<?> interaction=Class.forName("org.bukkit.entity.Interaction");
Entity.class.getMethod("setPersistent", boolean.class); World.class.getMethod("spawn", Location.class, Class.class, Consumer.class);
text.getMethod("setText", String.class); interaction.getMethod("setInteractionWidth", float.class);
interaction.getMethod("setInteractionHeight", float.class);
interaction.getMethod("setResponsive", boolean.class);
Class<?> matrix = Class.forName("org.joml.Matrix4f");
text.getMethod("setTransformationMatrix", matrix);
Class.forName("org.bukkit.entity.ItemDisplay").getMethod("setItemStack", ItemStack.class);
return true;
} catch (ReflectiveOperationException | LinkageError unavailable) { return false; }
}
public int entityCount() { return entities.size(); }
public void retryCleanup() { for (Frame frame: pendingFrames.values()) if (frame.closed.get()) retire(frame); }
public void render(ExperimentalSessions.Session session, Player player, Location anchor, List<Target> requested,
Consumer<Target> selection) {
Objects.requireNonNull(session); Objects.requireNonNull(player); Objects.requireNonNull(anchor); Objects.requireNonNull(selection);
List<Target> targets=requested == null ? List.of() : List.copyOf(requested);
if (!supported() || !sessions.current(session) || targets.size()>MAX_TARGETS
|| targets.stream().mapToInt(target -> target.icon() == null ? 2 : 3).sum() > MAX_ENTITIES || !validAnchor(anchor)) { sessions.close(session); return; }
Signature signature=signature(targets,anchor); Frame existing=currentFrames.get(session.id);
synchronized (currentFrames) {
existing=currentFrames.get(session.id);
if (existing != null && !existing.closed.get() && existing.signature.equals(signature)) return;
if (existing != null) { currentFrames.remove(session.id, existing); closeFrame(existing); }
if (currentFrames.size() + pendingFrames.size() >= MAX_FRAMES) { sessions.close(session); return; }
Frame frame=new Frame(session,player,anchor,selection,generations.incrementAndGet(),signature,footprint(anchor,targets)); currentFrames.put(session.id,frame);
try { if (registeredSessions.putIfAbsent(session.id, Boolean.TRUE) == null)
session.own(() -> closeSession(session), ignored -> { }); schedulePlayerValidation(frame,targets); }
catch (RuntimeException failure) { registeredSessions.remove(session.id); currentFrames.remove(session.id,frame); sessions.close(session); }
}
}
private void schedulePlayerValidation(Frame frame,List<Target> targets) {
try { scheduler.player(frame.player, () -> { if (!validPlayer(frame)) { closeSession(frame.session); return; }
scheduler.region(frame.anchor, () -> spawn(frame,targets)); }, () -> closeSession(frame.session)); }
catch (RuntimeException | LinkageError failure) { closeSession(frame.session); }
}
private boolean validPlayer(Frame f) { return !f.closed.get() && plugin.isEnabled() && sessions.current(f.session)
&& currentFrames.get(f.session.id)==f && f.player.isOnline() && f.player.getUniqueId().equals(f.session.player)
&& (f.player.hasPermission("VotingPlugin.Admin") || f.player.hasPermission("VotingPlugin.Commands.AdminVote."+f.session.type.command()))
&& f.player.getWorld().equals(f.anchor.getWorld()) && f.player.getEyeLocation().distanceSquared(f.anchor)<=64; }
private boolean current(Frame f) { return !f.closed.get() && plugin.isEnabled() && sessions.current(f.session)
&& currentFrames.get(f.session.id)==f; }
private void spawn(Frame frame,List<Target> targets) {
try { if (!current(frame) || !preflight(frame.footprint)) { closeSession(frame.session); return; }
for (Target target:targets) { if (!current(frame)) throw new IllegalStateException("Display frame retired"); Location point=offset(frame.anchor,target.x(),target.y());
spawnOwned(frame,point.clone().add(0, target.icon() == null ? 0 : .25, 0),"org.bukkit.entity.TextDisplay",e -> configureText(e,target.text()));
Entity hit=spawnOwned(frame,point.clone().subtract(0,.08,0),"org.bukkit.entity.Interaction",this::configureHit);
synchronized(frame) { frame.hitTargets.put(hit.getUniqueId(),target); }
if (target.icon()!=null && classExists("org.bukkit.entity.ItemDisplay")) spawnOwned(frame,point.clone().subtract(0,.14,0),"org.bukkit.entity.ItemDisplay",e -> configureItem(e,target.icon()));
}
scheduler.player(frame.player, () -> { if (validPlayer(frame)) frame.session.activate(); else closeSession(frame.session); }, () -> closeSession(frame.session));
} catch (RuntimeException | LinkageError | ReflectiveOperationException failure) { closeSession(frame.session); }
}
private boolean ownsFootprint(List<Location> footprint) { for (Location location : footprint) if (!scheduler.owns(location)) return false; return true; }
private boolean preflight(List<Location> footprint) { for (Location location : footprint)
if (!scheduler.owns(location) || !loaded(location)) return false; return true; }
private static List<Location> footprint(Location anchor,List<Target> targets) { List<Location> result=new ArrayList<>(); result.add(anchor.clone());
for(Target t:targets) { Location p=offset(anchor,t.x(),t.y()); result.add(p); for(double dx:new double[]{-.7,.7}) for(double dz:new double[]{-.7,.7}) result.add(p.clone().add(dx,0,dz)); } return List.copyOf(result); }
private Entity spawnOnce(Location location,String className,Consumer<Entity> configure) throws ReflectiveOperationException {
Class<?> type=Class.forName(className); Method spawn=World.class.getMethod("spawn",Location.class,Class.class,Consumer.class);
try { return (Entity)spawn.invoke(location.getWorld(),location,type,(Consumer<Entity>)configure); }
catch(InvocationTargetException failure) { Throwable cause=failure.getCause(); if(cause instanceof RuntimeException r) throw r; if(cause instanceof Error e) throw e; throw failure; }
}
private Entity spawnOwned(Frame frame,Location location,String className,Consumer<Entity> configure) throws ReflectiveOperationException {
return spawnOnce(location,className,entity -> { synchronized(frame) { frame.owned.add(entity); } entities.put(entity.getUniqueId(),frame);
invokeRequired(entity,"setPersistent",new Class<?>[]{boolean.class},false);
if (!current(frame)) { closeFrame(frame); throw new IllegalStateException("Display frame retired during spawn"); } invokeRequired(entity,"setGravity",new Class<?>[]{boolean.class},false); invokeRequired(entity,"setInvulnerable",new Class<?>[]{boolean.class},true); configure.accept(entity); scheduleShow(frame,entity); });
}
private void scheduleShow(Frame frame, Entity entity) {
try {
Method visible = Entity.class.getMethod("setVisibleByDefault", boolean.class);
Method show = Player.class.getMethod("showEntity", Plugin.class, Entity.class);
visible.invoke(entity, false);
scheduler.player(frame.player, () -> {
if (!validPlayer(frame) || !ownsFootprint(frame.footprint)) {
closeSession(frame.session);
return;
}
try { show.invoke(frame.player, plugin, entity); }
catch (ReflectiveOperationException failure) { closeSession(frame.session); }
}, () -> closeSession(frame.session));
} catch (NoSuchMethodException olderVisibilityAPI) {
// Public visibility on old implementations; interactions remain owner-only.
} catch (ReflectiveOperationException | LinkageError failure) {
closeSession(frame.session);
throw new IllegalStateException("Display visibility initialization failed", failure);
}
}
private void configureText(Entity e,String text) { invokeRequired(e,"setText",new Class<?>[]{String.class},text); scale(e, .5f); try { Class<?> b=Class.forName("org.bukkit.entity.Display$Billboard"); invokeRequired(e,"setBillboard",new Class<?>[]{b},Enum.valueOf((Class)b,"FIXED")); } catch(ReflectiveOperationException|IllegalArgumentException x) { throw new IllegalStateException("Text billboard unavailable",x); } }
private void configureHit(Entity e) { invokeRequired(e,"setInteractionWidth",new Class<?>[]{float.class},1.15f); invokeRequired(e,"setInteractionHeight",new Class<?>[]{float.class},.38f); invokeRequired(e,"setResponsive",new Class<?>[]{boolean.class},false); }
private void configureItem(Entity e,ItemStack i) { invokeRequired(e,"setItemStack",new Class<?>[]{ItemStack.class},i.clone()); scale(e, .3f); }
private static void scale(Entity entity, float amount) {
try {
Class<?> matrixType = Class.forName("org.joml.Matrix4f");
Object matrix = matrixType.getConstructor().newInstance();
matrixType.getMethod("scale", float.class).invoke(matrix, amount);
invokeRequired(entity, "setTransformationMatrix", new Class<?>[]{matrixType}, matrix);
} catch (ReflectiveOperationException | LinkageError unavailable) {
throw new IllegalStateException("Display transformation unavailable", unavailable);
}
}
private static void invokeRequired(Object t,String n,Class<?>[] types,Object v) { try { t.getClass().getMethod(n,types).invoke(t,v); } catch(ReflectiveOperationException|LinkageError x) { throw new IllegalStateException(n+" unavailable",x); } }
private static boolean classExists(String n) { try { Class.forName(n); return true; } catch(ReflectiveOperationException|LinkageError x) { return false; } }
private static Location offset(Location a,double side,double up) { double yaw=Math.toRadians(a.getYaw()); return a.clone().add(new Vector(Math.cos(yaw)*side,up,Math.sin(yaw)*side)); }
private static boolean validAnchor(Location l) { return l.getWorld()!=null&&Double.isFinite(l.getX())&&Double.isFinite(l.getY())&&Double.isFinite(l.getZ())&&Float.isFinite(l.getYaw())&&Float.isFinite(l.getPitch()); }
private static boolean loaded(Location l) { World w=l.getWorld(); return Bukkit.getWorld(w.getUID())==w&&w.isChunkLoaded(l.getBlockX()>>4,l.getBlockZ()>>4); }
private record Signature(UUID world, double x, double y, double z, float yaw, float pitch, List<Target> targets) {}
private static Signature signature(List<Target> targets, Location anchor) {
return new Signature(anchor.getWorld().getUID(), anchor.getX(), anchor.getY(), anchor.getZ(),
anchor.getYaw(), anchor.getPitch(), List.copyOf(targets));
}
/** One owner-region update per second; only the showcase preview model rotates. */
public void rotateShowcase(ExperimentalSessions.Session session, long step) {
Frame frame = currentFrames.get(session.id);
if (frame == null || session.type != ExperimentalGUIType.REWARD_SHOWCASE || !current(frame)) return;
try {
scheduler.region(frame.anchor, () -> {
if (!current(frame)) return;
if (!preflight(frame.footprint)) { closeSession(session); return; }
try {
Class<?> type = Class.forName("org.bukkit.entity.ItemDisplay");
synchronized (frame) {
// First item display is the reward preview; site icons stay stationary.
for (Entity entity : frame.owned) if (type.isInstance(entity)) {
entity.setRotation(frame.anchor.getYaw() + (float) ((step % 24) * 15), 0);
break;
}
}
} catch (ReflectiveOperationException | RuntimeException | LinkageError failure) {
closeSession(session);
}
});
} catch (RuntimeException | LinkageError failure) { closeSession(session); }
}
public void close(ExperimentalSessions.Session session) { closeSession(session); }
private void closeSession(ExperimentalSessions.Session session) { sessions.close(session); registeredSessions.remove(session.id); Frame f=currentFrames.remove(session.id); if(f!=null)closeFrame(f); for(Frame p:List.copyOf(pendingFrames.values()))if(p.session==session)closeFrame(p); }
private void closeFrame(Frame f) { f.closed.set(true); pendingFrames.put(frameKey(f),f); retire(f); }
private UUID frameKey(Frame f) { return new UUID(f.session.id.getMostSignificantBits() ^ f.generation,f.session.id.getLeastSignificantBits() ^ Long.rotateLeft(f.generation,17)); }
private void retire(Frame f) { synchronized(f) { if(f.owned.isEmpty()){pendingFrames.remove(frameKey(f),f);return;} if(f.cleanupQueued)return; f.cleanupQueued=true; }
try { if(scheduler.owns(f.anchor))remove(f); else scheduler.region(f.anchor,()->remove(f)); } catch(RuntimeException|LinkageError x) { synchronized(f){f.cleanupQueued=false;} } }
private void remove(Frame f) { try { if(!preflight(f.footprint))throw new IllegalStateException("Display cleanup outside owned footprint");
synchronized(f){ for(Entity e:List.copyOf(f.owned)){ if(e.isValid())e.remove(); entities.remove(e.getUniqueId(),f); }
f.owned.clear();f.hitTargets.clear();f.cleanupQueued=false;}pendingFrames.remove(frameKey(f),f); } catch(RuntimeException|LinkageError x){synchronized(f){f.cleanupQueued=false;}} }
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=false) public void interact(PlayerInteractEntityEvent event) { Frame f=entities.get(event.getRightClicked().getUniqueId()); if(f==null)return; boolean was=event.isCancelled(); event.setCancelled(true);
if(was||event.getHand()!=EquipmentSlot.HAND||!f.session.player.equals(event.getPlayer().getUniqueId())||!current(f))return; Target target; synchronized(f){target=f.hitTargets.get(event.getRightClicked().getUniqueId());} if(target==null)return; Target selected=target;
scheduler.player(event.getPlayer(),()->{if(validPlayer(f))f.selection.accept(selected);},()->closeSession(f.session)); }
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true) public void unload(WorldUnloadEvent event) { for(Frame f:currentFrames.values())if(f.anchor.getWorld()==event.getWorld())closeSession(f.session); for(Frame f:pendingFrames.values())if(f.anchor.getWorld()==event.getWorld()){synchronized(f){entities.entrySet().removeIf(entry -> entry.getValue() == f); f.owned.clear();f.hitTargets.clear();}pendingFrames.remove(frameKey(f),f);} }
}
@@ -0,0 +1,735 @@
package com.bencodez.votingplugin.experimental;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import java.util.logging.Level;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
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.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.hologram.HologramMenuScheduler;
import com.bencodez.votingplugin.hologram.HologramVoteMenu;
import com.bencodez.votingplugin.hologram.HologramVoteModel;
import com.bencodez.votingplugin.hologram.HologramVoteSettings;
import com.bencodez.votingplugin.topvoter.TopVoter;
import com.bencodez.votingplugin.votesites.VoteSite;
import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakDefinition;
import com.bencodez.advancedcore.api.item.ItemBuilder;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.TextComponent;
/** Opt-in presentation only: no reward actions, votes, totals or production GUI registrations. */
public final class ExperimentalGUIManager implements Listener {
private final VotingPluginMain plugin;
private final HologramMenuScheduler scheduler;
private final ExperimentalSessions sessions;
private final Map<UUID, Menu> menus = new ConcurrentHashMap<>();
private final AtomicInteger loading = new AtomicInteger();
private final ExperimentalInventoryListener inventoryListener;
private final ExperimentalNPC npc;
private final ExperimentalDialogs dialogs;
private final ExperimentalDisplays displays;
// Mutable view state below is accessed only by the player's scheduler.
private static final class Menu {
final ExperimentalSessions.Session session;
final Player player;
final Location anchor;
final ExperimentalGUISettings settings;
final Map<String, ItemStack> icons;
Inventory inventory;
List<HologramVoteModel.Site> sites = List.of();
int page;
int dailyStreak;
int weeklyStreak;
int monthlyStreak;
int total;
boolean pulse;
long ticks;
final AtomicBoolean loading = new AtomicBoolean();
volatile List<HologramVoteModel.Site> displayed = List.of();
List<VoteStreakDefinition> definitions = List.of();
List<Milestone> milestones = List.of();
final Map<String, ItemStack> rewardIcons = new HashMap<>();
final Map<String, List<String>> rewardDescriptions = new HashMap<>();
int rewardPage;
Menu(ExperimentalSessions.Session session, Player player, Location anchor,
ExperimentalGUISettings settings, Map<String, ItemStack> icons) {
this.session = session;
this.player = player;
this.anchor = anchor;
this.settings = settings;
this.icons = icons;
}
}
private record Milestone(String id, String type, int threshold, int progress, int best, boolean recorded, boolean recurring) {}
private record Snapshot(List<HologramVoteModel.Site> sites, int daily, int weekly, int monthly, int total,
List<Milestone> milestones) {}
public ExperimentalGUIManager(VotingPluginMain plugin) {
this(plugin, new HologramMenuScheduler(plugin));
}
ExperimentalGUIManager(VotingPluginMain plugin, HologramMenuScheduler scheduler) {
this.plugin = plugin;
this.scheduler = scheduler;
this.sessions = new ExperimentalSessions(System::nanoTime, failure ->
plugin.getLogger().log(Level.WARNING, "Experimental GUI cleanup failed", failure));
this.inventoryListener = new ExperimentalInventoryListener(sessions, this::click);
this.npc = new ExperimentalNPC(plugin, scheduler, sessions);
this.dialogs = new ExperimentalDialogs(plugin, scheduler, sessions);
this.displays = new ExperimentalDisplays(plugin, scheduler, sessions);
}
public Listener inventoryListener() { return inventoryListener; }
public Listener npcListener() { return npc; }
public Listener displaysListener() { return displays; }
public void open(Player player, ExperimentalGUIType type) {
scheduler.player(player, () -> openOwned(player, type), () -> sessions.close(player.getUniqueId()));
}
private void openOwned(Player player, ExperimentalGUIType type) {
if (!plugin.isEnabled() || !player.isOnline()) return;
if (!permitted(player, type)) {
player.sendMessage("\u00a7cYou do not have permission to test this GUI.");
return;
}
ExperimentalGUISettings settings;
HologramVoteSettings hologram;
try {
var config = plugin.getConfigFile().getData();
settings = ExperimentalGUISettings.read(config);
hologram = type == ExperimentalGUIType.HOLOGRAM ? settings.hologram(config) : null;
}
catch (RuntimeException invalid) {
player.sendMessage("\u00a7cInvalid experimental GUI settings: " + invalid.getMessage());
return;
}
if (!settings.enabled().contains(type)) {
player.sendMessage("\u00a7eThis experimental style is disabled. Enable Experimental.VoteGUIs and its style setting.");
return;
}
String unsupported = unsupported(type);
if (unsupported != null) {
player.sendMessage("\u00a7c" + unsupported);
return;
}
var session = sessions.open(player.getUniqueId(), type, settings.maxSessions(),
type == ExperimentalGUIType.HOLOGRAM ? hologram.timeoutSeconds() : settings.timeoutSeconds());
if (session == null) {
player.sendMessage("\u00a7eExperimental menus are at capacity or shutting down.");
return;
}
if (scheduler.regionized()) {
try {
// Folia's async teleport path currently does not publish PlayerTeleportEvent.
// Keep private test menus stationary: translating the player closes the session.
Location openingPosition = player.getLocation().clone();
session.own(scheduler.watchPlayer(player, () -> {
if (!sessions.current(session)) return;
Location position = player.getLocation();
if (!player.isOnline() || position.getWorld() != openingPosition.getWorld()
|| position.distanceSquared(openingPosition) > 0.000001) {
sessions.close(session);
}
}, () -> sessions.close(session)), this::cleanupError);
} catch (RuntimeException failure) { fail(session, player, failure); return; }
}
if (type == ExperimentalGUIType.HOLOGRAM) {
try {
BooleanSupplier guard = () -> sessions.current(session);
session.own(() -> plugin.getHologramVoteMenu().close(session.player, guard), this::cleanupError);
plugin.getHologramVoteMenu().open(player, hologram,
guard, () -> sessions.close(session));
session.activate();
} catch (RuntimeException failure) { fail(session, player, failure); }
return;
}
try {
Location eyes = player.getEyeLocation();
Location anchor = eyes.clone().add(eyes.getDirection().multiply(settings.distance()));
anchor.setYaw(eyes.getYaw() + 180);
anchor.setPitch(0);
List<VoteSite> visible = visible(player);
Map<String, ItemStack> icons = new HashMap<>();
for (VoteSite site : visible) {
try { icons.put(site.getKey(), site.getItem().toItemStack().clone()); }
catch (RuntimeException invalidItem) { icons.put(site.getKey(), new ItemStack(Material.PAPER)); }
}
Menu menu = new Menu(session, player, anchor, settings, icons);
menus.put(session.id, menu);
session.own(() -> menus.remove(session.id, menu), this::cleanupError);
session.own(() -> scheduler.player(player, () -> {
Inventory top = player.getOpenInventory().getTopInventory();
if (menu.inventory != null && top == menu.inventory) player.closeInventory();
}, () -> {}), this::cleanupError);
session.own(scheduler.watchPlayer(player, () -> watch(menu), () -> sessions.close(session)), this::cleanupError);
captureRewards(menu);
load(menu, visible);
} catch (RuntimeException failure) { fail(session, player, failure); }
}
private List<VoteSite> visible(Player player) {
return plugin.getVoteSiteManager().getVoteSitesEnabled().stream()
.filter(site -> !site.isHidden() && (site.getPermissionToView().isEmpty()
|| player.hasPermission(site.getPermissionToView())))
.limit(200).toList();
}
private void captureRewards(Menu menu) {
menu.definitions = plugin.getVoteStreakHandler().getDefinitions().stream()
.filter(VoteStreakDefinition::isEnabled)
.sorted(java.util.Comparator.comparing((VoteStreakDefinition definition) -> definition.getType().name())
.thenComparingInt(VoteStreakDefinition::getRequiredAmount).thenComparing(VoteStreakDefinition::getId))
.limit(100).toList();
var config = plugin.getSpecialRewardsConfig().getData();
for (var definition : menu.definitions) {
ItemStack preview = new ItemStack(Material.CHEST);
List<String> description = new ArrayList<>();
var items = config.getConfigurationSection(definition.getRewardPath() + ".Items");
if (items != null) {
for (String key : items.getKeys(false).stream().limit(3).toList()) {
var configured = items.getConfigurationSection(key);
if (configured == null || !configured.getBoolean("Enabled", true)) continue;
description.add("\u00a77Configured item: " + key);
if (preview.getType() == Material.CHEST) {
try {
ItemStack parsed = new ItemBuilder(configured).toItemStack();
if (parsed != null && !parsed.getType().isAir()) preview = parsed;
}
catch (RuntimeException unsupportedItem) { /* Safe chest preview for unsupported material. */ }
}
}
}
Object message = config.get(definition.getRewardPath() + ".Messages.Player");
if (message instanceof String text) description.add("\u00a77Message: " + text.substring(0, Math.min(120, text.length())));
description.add("\u00a77Configured reward preview; conditions/chance still apply.");
menu.rewardIcons.put(definition.getId(), preview.clone());
menu.rewardDescriptions.put(definition.getId(), List.copyOf(description));
}
}
private void load(Menu menu, List<VoteSite> visible) {
if (!sessions.current(menu.session) || !menu.loading.compareAndSet(false, true)) return;
if (loading.incrementAndGet() > 64) {
loading.decrementAndGet();
menu.loading.set(false);
fail(menu.session, menu.player, new IllegalStateException("Voting snapshot worker capacity reached"));
return;
}
try {
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
try {
if (!sessions.current(menu.session)) return;
var user = plugin.getUser(menu.session.player);
List<HologramVoteModel.Site> sites = new ArrayList<>();
for (VoteSite site : visible) {
if (!sessions.current(menu.session)) return;
sites.add(new HologramVoteModel.Site(site.getKey(), site.getDisplayName(),
site.getVoteURL(false), user.canVoteSite(site), user.voteNextDurationTime(site)));
}
List<Milestone> milestones = new ArrayList<>();
for (var definition : menu.definitions) {
var progress = plugin.getVoteStreakHandler().getStoredPreview(user, definition);
milestones.add(new Milestone(definition.getId(), definition.getType().name(),
definition.getRequiredAmount(), progress.amount(), progress.bestAmount(),
progress.awardRecorded(), definition.isRecurring()));
}
Snapshot snapshot = new Snapshot(List.copyOf(sites), user.getDayVoteStreak(),
user.getWeekVoteStreak(), user.getMonthVoteStreak(), user.getTotal(TopVoter.AllTime),
List.copyOf(milestones));
scheduler.player(menu.player, () -> {
try {
if (!sessions.current(menu.session) || !permitted(menu.player, menu.session.type)) return;
menu.sites = snapshot.sites();
menu.dailyStreak = snapshot.daily();
menu.weeklyStreak = snapshot.weekly();
menu.monthlyStreak = snapshot.monthly();
menu.total = snapshot.total();
menu.milestones = snapshot.milestones();
render(menu);
} catch (RuntimeException failure) { fail(menu.session, menu.player, failure); }
finally { menu.loading.set(false); }
}, () -> { menu.loading.set(false); sessions.close(menu.session); });
} catch (RuntimeException failure) { menu.loading.set(false); fail(menu.session, menu.player, failure); }
finally { loading.decrementAndGet(); }
});
} catch (RuntimeException rejected) {
loading.decrementAndGet();
menu.loading.set(false);
fail(menu.session, menu.player, rejected);
}
}
private void watch(Menu menu) {
try { watchOwned(menu); }
catch (RuntimeException failure) { fail(menu.session, menu.player, failure); }
}
private void watchOwned(Menu menu) {
if (!sessions.current(menu.session) || !menu.player.isOnline()
|| !permitted(menu.player, menu.session.type)
|| !menu.player.getWorld().equals(menu.anchor.getWorld())
|| menu.player.getEyeLocation().distanceSquared(menu.anchor) > 64) {
sessions.close(menu.session);
return;
}
if (menu.inventory != null && menu.player.getOpenInventory().getTopInventory() != menu.inventory) {
sessions.close(menu.session);
return;
}
// One bounded update per second, never a per-tick animation or storage query.
menu.pulse = !menu.pulse;
menu.ticks++;
if (menu.inventory != null) renderInventory(menu);
if (menu.session.type == ExperimentalGUIType.REWARD_SHOWCASE) displays.rotateShowcase(menu.session, menu.ticks);
}
private void renderInventory(Menu menu) {
if (!sessions.current(menu.session)) return;
if (menu.inventory == null) {
ExperimentalInventory holder = new ExperimentalInventory(menu.session);
menu.inventory = Bukkit.createInventory(holder, 54,
menu.session.type == ExperimentalGUIType.STREAK_TRACK ? "Experimental Streak Track" : "Experimental Voting");
holder.bind(menu.inventory);
menu.player.openInventory(menu.inventory);
menu.session.activate();
}
Inventory inventory = menu.inventory;
inventory.clear();
for (int slot = 0; slot < 54; slot++) {
if (slot < 9 || slot >= 45 || slot % 9 == 0 || slot % 9 == 8)
inventory.setItem(slot, item(menu.pulse ? Material.BLUE_STAINED_GLASS_PANE : Material.GRAY_STAINED_GLASS_PANE,
" ", List.of()));
}
inventory.setItem(4, item(Material.NETHER_STAR, "\u00a76Voting rewards", List.of(
"\u00a77Total votes: " + menu.total, "\u00a77Daily streak: " + menu.dailyStreak,
"\u00a77Weekly streak: " + menu.weeklyStreak, "\u00a77Monthly streak: " + menu.monthlyStreak,
"\u00a77Preview only; links never grant votes or rewards.")));
List<HologramVoteModel.Site> page = HologramVoteModel.page(menu.sites, menu.page, menu.settings.sitesPerPage());
// Capture exact visible targets so a delayed click cannot select a different page's URL.
if (!menu.displayed.equals(page)) menu.displayed = page;
for (int i = 0; i < page.size(); i++) {
var site = page.get(i);
ItemStack icon = menu.icons.getOrDefault(site.key(), new ItemStack(Material.PAPER)).clone();
ItemMeta meta = icon.getItemMeta();
if (meta != null) {
List<String> lore = meta.hasLore() ? new ArrayList<>(meta.getLore()) : new ArrayList<>();
lore.add(site.label());
lore.add("\u00a77Click for the configured voting URL in chat.");
meta.setDisplayName(site.title());
meta.setLore(lore);
if (site.canVote()) {
try { ItemMeta.class.getMethod("setEnchantmentGlintOverride", Boolean.class).invoke(meta, Boolean.TRUE); }
catch (ReflectiveOperationException olderAPI) { /* Preserve native icon on older Bukkit APIs. */ }
}
icon.setItemMeta(meta);
}
inventory.setItem((menu.session.type == ExperimentalGUIType.STREAK_TRACK ? 29 : 20) + i, icon);
}
if (page.isEmpty()) inventory.setItem(menu.session.type == ExperimentalGUIType.STREAK_TRACK ? 31 : 22,
item(Material.PAPER, "No visible enabled voting sites", List.of()));
if (menu.session.type == ExperimentalGUIType.STREAK_TRACK) {
int start = Math.min(menu.rewardPage, Math.max(0, (menu.milestones.size() - 1) / 5)) * 5;
for (int i = start; i < Math.min(start + 5, menu.milestones.size()); i++)
inventory.setItem(20 + i - start, rewardItem(menu, menu.milestones.get(i)));
if (menu.milestones.isEmpty()) inventory.setItem(22, item(Material.PAPER, "No enabled streak milestones",
List.of("Configure VoteStreaks in SpecialRewards.yml to show a reward track.")));
inventory.setItem(36, item(Material.ARROW, "Previous voting sites", List.of()));
inventory.setItem(44, item(Material.ARROW, "Next voting sites", List.of()));
} else {
menu.milestones.stream().filter(milestone -> milestone.progress() < milestone.threshold())
.findFirst().ifPresent(milestone -> inventory.setItem(31, rewardItem(menu, milestone)));
}
inventory.setItem(45, item(Material.ARROW, "Previous page", List.of()));
inventory.setItem(49, item(Material.BARRIER, "Close", List.of()));
inventory.setItem(50, item(Material.CLOCK, "Refresh status", List.of("Reads voting data asynchronously.")));
inventory.setItem(53, item(Material.ARROW, "Next page", List.of("Page "
+ (menu.session.type == ExperimentalGUIType.STREAK_TRACK ? menu.rewardPage + 1 : menu.page + 1) + "/"
+ (menu.session.type == ExperimentalGUIType.STREAK_TRACK ? Math.max(1, (menu.milestones.size() + 4) / 5)
: HologramVoteModel.pages(menu.sites.size(), menu.settings.sitesPerPage())))));
}
private void render(Menu menu) {
if (!sessions.current(menu.session)) return;
if (menu.session.type == ExperimentalGUIType.VOTING_TERMINAL) {
renderTerminal(menu);
} else if (menu.session.type == ExperimentalGUIType.REWARD_SHOWCASE) {
renderShowcase(menu);
} else if (menu.session.type == ExperimentalGUIType.RADIAL) {
renderRadial(menu);
} else if (menu.session.type == ExperimentalGUIType.NATIVE_DIALOG) {
renderDialog(menu);
} else if (menu.session.type == ExperimentalGUIType.NPC && menu.inventory == null) {
String name = plugin.getConfigFile().getData().getString("Experimental.VoteGUIs.NPC.DisplayName", "Voting Guide");
if (name == null || name.isBlank()) name = "Voting Guide";
npc.open(menu.session, menu.player, menu.anchor, name.substring(0, Math.min(80, name.length())), () -> {
if (sessions.current(menu.session) && permitted(menu.player, menu.session.type)) renderInventory(menu);
});
menu.session.activate();
} else renderInventory(menu);
}
private void renderRadial(Menu menu) {
List<ExperimentalDisplays.Target> targets = new ArrayList<>();
var page = HologramVoteModel.page(menu.sites, menu.page, menu.settings.sitesPerPage());
double radius = plugin.getConfigFile().getData().getDouble("Experimental.VoteGUIs.Radial.Radius", 1.25);
if (!Double.isFinite(radius) || radius < 0.9 || radius > 1.8)
throw new IllegalArgumentException("Radial.Radius must be 0.9..1.8");
for (int i = 0; i < page.size(); i++) {
var site = page.get(i);
double angle = -Math.PI / 2 + i * Math.PI * 2 / page.size();
targets.add(new ExperimentalDisplays.Target("site-" + i, site.label(), menu.icons.get(site.key()),
Math.cos(angle) * radius, Math.sin(angle) * radius, ExperimentalDisplays.Action.SITE, site.key()));
}
targets.add(new ExperimentalDisplays.Target("title", "Voting - Page " + (menu.page + 1) + "/"
+ HologramVoteModel.pages(menu.sites.size(), menu.settings.sitesPerPage())
+ (page.isEmpty() ? "\nNo visible voting sites" : "\nAim and right-click a site"), null, 0, 0,
ExperimentalDisplays.Action.INFO));
targets.add(new ExperimentalDisplays.Target("previous", "[Previous]", null, -1, -radius - .55, ExperimentalDisplays.Action.PREVIOUS));
targets.add(new ExperimentalDisplays.Target("next", "[Next]", null, 0, -radius - .55, ExperimentalDisplays.Action.NEXT));
targets.add(new ExperimentalDisplays.Target("close", "[Close]", null, 1, -radius - .55, ExperimentalDisplays.Action.CLOSE));
displays.render(menu.session, menu.player, menu.anchor, targets, target -> {
try {
if (!sessions.current(menu.session) || !permitted(menu.player, menu.session.type)) return;
switch (target.action()) {
case CLOSE -> sessions.close(menu.session);
case PREVIOUS, NEXT -> {
menu.page = Math.min(Math.max(menu.page + (target.action() == ExperimentalDisplays.Action.PREVIOUS ? -1 : 1), 0),
HologramVoteModel.pages(menu.sites.size(), menu.settings.sitesPerPage()) - 1);
renderRadial(menu);
}
case SITE -> sendSiteUrl(menu, target.siteKey());
default -> { }
}
} catch (RuntimeException failure) { fail(menu.session, menu.player, failure); }
});
}
private void renderTerminal(Menu menu) {
List<ExperimentalDisplays.Target> targets = new ArrayList<>();
targets.add(new ExperimentalDisplays.Target("terminal", "Experimental Voting Terminal\n" + menu.session.id
+ "\nAim and right-click a style", null, 0, .65, ExperimentalDisplays.Action.INFO));
int index = 0;
for (ExperimentalGUIType type : ExperimentalGUIType.values()) {
if (type == ExperimentalGUIType.VOTING_TERMINAL) continue;
String unavailable = unsupported(type);
boolean enabled = menu.settings.enabled().contains(type);
targets.add(new ExperimentalDisplays.Target(type.name(), type.configurationKey()
+ (!enabled ? " [disabled]" : unavailable == null ? "" : " [unavailable]"), null,
(index % 2 == 0 ? -.8 : .8), -.05 - (index / 2) * .45, ExperimentalDisplays.Action.TERMINAL_STYLE));
index++;
}
targets.add(new ExperimentalDisplays.Target("close", "[Remove temporary terminal]", null, 0, -2,
ExperimentalDisplays.Action.CLOSE));
displays.render(menu.session, menu.player, menu.anchor, targets, target -> {
if (!sessions.current(menu.session) || !permitted(menu.player, menu.session.type)) return;
if (target.action() == ExperimentalDisplays.Action.CLOSE) sessions.close(menu.session);
else if (target.action() == ExperimentalDisplays.Action.TERMINAL_STYLE) {
// Opening revalidates this style's own switch, capability and permission.
// A successful replacement retires only this temporary terminal session.
open(menu.player, ExperimentalGUIType.valueOf(target.id()));
}
});
}
public void terminalControl(CommandSender sender, String action) {
Runnable operation = () -> {
if (!(sender instanceof Player player)) {
sender.sendMessage("Temporary terminal commands require a player.");
return;
}
if (!permitted(player, ExperimentalGUIType.VOTING_TERMINAL)) {
player.sendMessage("\u00a7cYou do not have permission to manage experimental terminals.");
return;
}
if (action.equals("create")) { open(player, ExperimentalGUIType.VOTING_TERMINAL); return; }
Menu terminal = menus.values().stream().filter(menu -> menu.session.player.equals(player.getUniqueId())
&& menu.session.type == ExperimentalGUIType.VOTING_TERMINAL && sessions.current(menu.session)).findFirst().orElse(null);
if (terminal == null) { player.sendMessage("No active temporary voting terminal owned by you."); return; }
if (action.equals("remove")) {
sessions.close(terminal.session);
player.sendMessage("Temporary voting terminal removed: " + terminal.session.id);
} else if (action.equals("list") || action.equals("inspect")) {
player.sendMessage("Temporary terminal " + terminal.session.id + " | " + terminal.session.state()
+ " | " + terminal.anchor.getWorld().getName() + " " + terminal.anchor.getBlockX() + ","
+ terminal.anchor.getBlockY() + "," + terminal.anchor.getBlockZ()
+ " | private, nonpersistent; timeout " + terminal.settings.timeoutSeconds() + "s");
} else player.sendMessage("Use /av testterminalgui create|remove|list|inspect");
};
if (sender instanceof Player player) scheduler.player(player, operation, () -> {});
else operation.run();
}
private void renderShowcase(Menu menu) {
List<ExperimentalDisplays.Target> targets = new ArrayList<>();
int count = menu.milestones.size();
menu.rewardPage = Math.min(menu.rewardPage, Math.max(0, count - 1));
if (count == 0) {
targets.add(new ExperimentalDisplays.Target("reward", "Voting Rewards\nNo enabled streak rewards configured\nTotal votes: " + menu.total,
new ItemStack(Material.CHEST), 0, .85, ExperimentalDisplays.Action.INFO));
} else {
Milestone milestone = menu.milestones.get(menu.rewardPage);
String text = milestone.id() + " (" + milestone.type() + ")\n" + milestone.progress() + "/" + milestone.threshold()
+ " | Total votes: " + menu.total + "\n"
+ (milestone.recorded() ? "Award recorded" : milestone.progress() >= milestone.threshold() ? "Threshold met" : "Upcoming reward")
+ " | Preview " + (menu.rewardPage + 1) + "/" + count;
List<String> description = menu.rewardDescriptions.getOrDefault(milestone.id(), List.of());
if (!description.isEmpty()) text += "\n" + description.getFirst();
text = text.substring(0, Math.min(256, text.length()));
targets.add(new ExperimentalDisplays.Target("reward", text,
menu.rewardIcons.getOrDefault(milestone.id(), new ItemStack(Material.CHEST)), 0, .85, ExperimentalDisplays.Action.INFO));
}
var page = HologramVoteModel.page(menu.sites, menu.page, menu.settings.sitesPerPage());
for (int i = 0; i < page.size(); i++) {
var site = page.get(i);
targets.add(new ExperimentalDisplays.Target("site-" + i, site.label(), menu.icons.get(site.key()),
0, -.05 - i * .42, ExperimentalDisplays.Action.SITE, site.key()));
}
targets.add(new ExperimentalDisplays.Target("previous-reward", "[Previous reward]", null, -1.25, .85, ExperimentalDisplays.Action.PREVIOUS_REWARD));
targets.add(new ExperimentalDisplays.Target("next-reward", "[Next reward]", null, 1.25, .85, ExperimentalDisplays.Action.NEXT_REWARD));
targets.add(new ExperimentalDisplays.Target("site-page", "[Voting sites " + (menu.page + 1) + "/"
+ HologramVoteModel.pages(menu.sites.size(), menu.settings.sitesPerPage()) + "]", null, -.65, -2.35, ExperimentalDisplays.Action.SITE_PAGE));
targets.add(new ExperimentalDisplays.Target("close", "[Close]", null, .65, -2.35, ExperimentalDisplays.Action.CLOSE));
displays.render(menu.session, menu.player, menu.anchor, targets, target -> {
try {
if (!sessions.current(menu.session) || !permitted(menu.player, menu.session.type)) return;
switch (target.action()) {
case CLOSE -> sessions.close(menu.session);
case SITE -> sendSiteUrl(menu, target.siteKey());
case PREVIOUS_REWARD, NEXT_REWARD -> {
menu.rewardPage = Math.min(Math.max(menu.rewardPage + (target.action() == ExperimentalDisplays.Action.PREVIOUS_REWARD ? -1 : 1), 0),
Math.max(0, menu.milestones.size() - 1));
renderShowcase(menu);
}
case SITE_PAGE -> {
menu.page = (menu.page + 1) % HologramVoteModel.pages(menu.sites.size(), menu.settings.sitesPerPage());
renderShowcase(menu);
}
default -> { }
}
} catch (RuntimeException failure) { fail(menu.session, menu.player, failure); }
});
}
private void sendSiteUrl(Menu menu, String key) {
VoteSite current = visible(menu.player).stream().filter(candidate -> candidate.getKey().equals(key)).findFirst().orElse(null);
if (current == null) return;
HologramVoteModel.votingUrl(current.getVoteURL(false)).ifPresentOrElse(url -> {
TextComponent link = new TextComponent("\u00a7aVote at " + current.getDisplayName() + " \u00a7b[Open link]");
link.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url));
menu.player.spigot().sendMessage(link);
}, () -> menu.player.sendMessage("\u00a7cThis site has no valid HTTP/HTTPS voting URL."));
}
private void renderDialog(Menu menu) {
List<ExperimentalDialogs.Button> buttons = new ArrayList<>();
var page = HologramVoteModel.page(menu.sites, menu.page, menu.settings.sitesPerPage());
for (var site : page) {
VoteSite current = visible(menu.player).stream().filter(candidate -> candidate.getKey().equals(site.key())).findFirst().orElse(null);
if (current == null) continue;
var url = HologramVoteModel.votingUrl(current.getVoteURL(false));
buttons.add(url.isPresent()
? new ExperimentalDialogs.Button(site.title(), site.label(), url.get(), null)
: new ExperimentalDialogs.Button(site.title(), "No valid HTTP/HTTPS URL", null, "invalid"));
}
buttons.add(new ExperimentalDialogs.Button("Previous", "Previous voting sites", null, "previous"));
buttons.add(new ExperimentalDialogs.Button("Next", "Next voting sites", null, "next"));
buttons.add(new ExperimentalDialogs.Button("Close", "Close experimental menu", null, "close"));
String body = "Total votes: " + menu.total + " | Daily streak: " + menu.dailyStreak
+ " | Weekly: " + menu.weeklyStreak + " | Monthly: " + menu.monthlyStreak + "\n"
+ (page.isEmpty() ? "No visible enabled voting sites.\n" : "")
+ "Page " + (menu.page + 1) + "/" + HologramVoteModel.pages(menu.sites.size(), menu.settings.sitesPerPage())
+ "\nLinks are handled by your client; previews never grant rewards.";
var nextReward = menu.milestones.stream().filter(m -> m.progress() < m.threshold()).findFirst();
if (nextReward.isPresent()) {
var reward = nextReward.get();
body += "\nNext streak milestone: " + reward.id() + " " + reward.progress() + "/" + reward.threshold();
}
dialogs.render(menu.session, menu.player, "Experimental Voting", body, buttons, action -> {
if (!sessions.current(menu.session)) return;
if (action.equals("close")) { sessions.close(menu.session); return; }
if (action.equals("previous") || action.equals("next")) {
menu.page = Math.min(Math.max(menu.page + (action.equals("previous") ? -1 : 1), 0),
HologramVoteModel.pages(menu.sites.size(), menu.settings.sitesPerPage()) - 1);
renderDialog(menu);
} else menu.player.sendMessage("\u00a7cThis site has no valid HTTP/HTTPS voting URL.");
});
}
private static ItemStack rewardItem(Menu menu, Milestone milestone) {
ItemStack item = menu.rewardIcons.getOrDefault(milestone.id(), new ItemStack(Material.CHEST)).clone();
ItemMeta meta = item.getItemMeta();
if (meta != null) {
boolean reached = milestone.progress() >= milestone.threshold();
meta.setDisplayName((reached ? "\u00a7a" : "\u00a7e") + milestone.id());
List<String> lore = new ArrayList<>(menu.rewardDescriptions.getOrDefault(milestone.id(), List.of()));
lore.add("\u00a77" + milestone.type() + " progress: " + milestone.progress() + "/" + milestone.threshold());
int filled = (int) Math.min(10L, Math.max(0L, (long) milestone.progress() * 10 / Math.max(1, milestone.threshold())));
lore.add("\u00a7a" + "|".repeat(filled) + "\u00a78" + ".".repeat(10 - filled));
lore.add("\u00a77Best stored streak: " + milestone.best());
lore.add(milestone.recorded() ? "\u00a7aAward recorded by the streak engine"
: reached ? "\u00a7aThreshold met (preview only)" : "\u00a7eUpcoming milestone");
if (milestone.recurring()) lore.add("\u00a77Recurring milestone");
lore.add("\u00a77No claiming or reward execution through this menu.");
meta.setLore(lore);
item.setItemMeta(meta);
}
return item;
}
private static ItemStack item(Material material, String title, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta != null) { meta.setDisplayName(title); meta.setLore(lore); item.setItemMeta(meta); }
return item;
}
private void click(ExperimentalSessions.Session session, int slot) {
Menu menu = menus.get(session.id);
if (menu == null) return;
List<HologramVoteModel.Site> displayed = menu.displayed;
// Inventory callbacks are re-dispatched rather than assuming their execution context.
scheduler.player(menu.player, () -> {
try {
if (!sessions.current(session) || !permitted(menu.player, session.type)
|| menu.player.getOpenInventory().getTopInventory() != menu.inventory) return;
if (slot == 49) { sessions.close(session); return; }
if (slot == 45 || slot == 53) {
if (session.type == ExperimentalGUIType.STREAK_TRACK) {
menu.rewardPage = Math.min(Math.max(menu.rewardPage + (slot == 45 ? -1 : 1), 0),
Math.max(0, (menu.milestones.size() - 1) / 5));
renderInventory(menu);
return;
}
menu.page = Math.min(Math.max(menu.page + (slot == 45 ? -1 : 1), 0),
HologramVoteModel.pages(menu.sites.size(), menu.settings.sitesPerPage()) - 1);
renderInventory(menu);
return;
}
if (session.type == ExperimentalGUIType.STREAK_TRACK && (slot == 36 || slot == 44)) {
menu.page = Math.min(Math.max(menu.page + (slot == 36 ? -1 : 1), 0),
HologramVoteModel.pages(menu.sites.size(), menu.settings.sitesPerPage()) - 1);
renderInventory(menu);
return;
}
if (slot == 50) { load(menu, visible(menu.player)); return; }
int index = slot - (session.type == ExperimentalGUIType.STREAK_TRACK ? 29 : 20);
if (displayed != menu.displayed || index < 0 || index >= displayed.size()) return;
var site = displayed.get(index);
// Configuration or permissions may have changed since the worker snapshot.
VoteSite current = visible(menu.player).stream().filter(candidate -> candidate.getKey().equals(site.key())).findFirst().orElse(null);
if (current == null) return;
HologramVoteModel.votingUrl(current.getVoteURL(false)).ifPresentOrElse(url -> {
TextComponent link = new TextComponent("\u00a7aVote at " + site.title() + " \u00a7b[Open link]");
link.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url));
menu.player.spigot().sendMessage(link);
}, () -> menu.player.sendMessage("\u00a7cThis site has no valid HTTP/HTTPS voting URL."));
} catch (RuntimeException failure) { fail(session, menu.player, failure); }
}, () -> sessions.close(session));
}
private static boolean permitted(Player player, ExperimentalGUIType type) {
return player.hasPermission("VotingPlugin.Admin")
|| player.hasPermission("VotingPlugin.Commands.AdminVote." + type.command());
}
private String unsupported(ExperimentalGUIType type) {
if (scheduler.regionized() && (type == ExperimentalGUIType.ANIMATED_INVENTORY
|| type == ExperimentalGUIType.STREAK_TRACK || type == ExperimentalGUIType.NPC)) {
return "This inventory-based experiment is unavailable on Folia: disabled-plugin player scheduling cannot guarantee safe inventory cleanup.";
}
return switch (type) {
case HOLOGRAM -> HologramVoteMenu.supported() ? null : "Hologram requires native display and interaction APIs (1.19.4+).";
case ANIMATED_INVENTORY, STREAK_TRACK -> null;
case NPC -> ExperimentalNPC.supported() ? null : "NPC requires safe native entity spawning and nonpersistent entity APIs.";
case RADIAL, REWARD_SHOWCASE, VOTING_TERMINAL -> ExperimentalDisplays.supported() ? null : "Floating menus require native display and interaction APIs (1.19.4+).";
case NATIVE_DIALOG -> ExperimentalDialogs.supported() ? null : "Native dialog requires compatible Paper (1.21.7+) or Spigot dialog APIs and a compatible client.";
};
}
private void cleanupError(RuntimeException failure) { plugin.getLogger().log(Level.WARNING, "Experimental cleanup failed", failure); }
private void fail(ExperimentalSessions.Session session, Player player, RuntimeException failure) {
sessions.close(session);
plugin.getLogger().log(Level.WARNING, "Experimental " + session.type + " failed", failure);
try { scheduler.player(player, () -> player.sendMessage("\u00a7cExperimental menu failed; see the server console."), () -> {}); }
catch (RuntimeException rejected) { cleanupError(rejected); }
}
public void close(Player player) { sessions.close(player.getUniqueId()); }
public void clear() { sessions.clear(false); npc.retryCleanup(); displays.retryCleanup(); }
public void shutdown() { sessions.clear(true); npc.retryCleanup(); displays.retryCleanup(); }
public void control(CommandSender sender, String action) {
Runnable operation = () -> {
if (!sender.hasPermission("VotingPlugin.Admin")
&& !sender.hasPermission("VotingPlugin.Commands.AdminVote.TestGUI")) {
sender.sendMessage("\u00a7cYou do not have permission to manage experimental GUIs.");
return;
}
switch (action) {
case "list" -> list(sender);
case "status" -> status(sender);
case "close" -> {
if (sender instanceof Player player) close(player);
else sender.sendMessage("Close requires a player; cleanup retires all temporary sessions.");
}
case "cleanup" -> { clear(); sender.sendMessage("Experimental temporary sessions retired."); }
default -> sender.sendMessage("Use /av testgui list|close|status|cleanup");
}
};
if (sender instanceof Player player) scheduler.player(player, operation, () -> {});
else operation.run();
}
public void list(CommandSender sender) {
ExperimentalGUISettings settings;
try {
settings = ExperimentalGUISettings.read(plugin.getConfigFile().getData());
} catch (IllegalArgumentException invalid) {
sender.sendMessage("\u00a7cInvalid experimental GUI settings: " + invalid.getMessage());
return;
}
for (ExperimentalGUIType type : ExperimentalGUIType.values()) {
String reason = unsupported(type);
sender.sendMessage(type.configurationKey() + ": " + (settings.enabled().contains(type) ? "enabled" : "disabled")
+ (reason == null ? ", supported" : ", unavailable: " + reason));
}
}
public void status(CommandSender sender) {
sender.sendMessage("Experimental sessions: " + sessions.snapshot().size() + "; pending snapshots: " + loading.get()
+ "; temporary NPCs: " + npc.entityCount() + "; temporary displays: " + displays.entityCount());
for (var session : sessions.snapshot()) sender.sendMessage(session.type + " " + session.player + " " + session.state());
}
@EventHandler public void quit(PlayerQuitEvent event) { sessions.close(event.getPlayer().getUniqueId()); }
@EventHandler public void world(PlayerChangedWorldEvent event) { sessions.close(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void teleport(PlayerTeleportEvent event) { sessions.close(event.getPlayer().getUniqueId()); }
@EventHandler public void death(PlayerDeathEvent event) { sessions.close(event.getEntity().getUniqueId()); }
}
@@ -0,0 +1,38 @@
package com.bencodez.votingplugin.experimental;
import java.util.EnumSet;
import java.util.Set;
import org.bukkit.configuration.ConfigurationSection;
import com.bencodez.votingplugin.hologram.HologramVoteSettings;
record ExperimentalGUISettings(Set<ExperimentalGUIType> enabled, int maxSessions, int timeoutSeconds,
int sitesPerPage, double distance) {
ExperimentalGUISettings {
enabled = Set.copyOf(enabled);
if (maxSessions < 1 || maxSessions > 64 || timeoutSeconds < 1 || timeoutSeconds > 60
|| sitesPerPage < 1 || sitesPerPage > 5 || !Double.isFinite(distance)
|| distance < 1.5 || distance > 3)
throw new IllegalArgumentException("MaxSessions 1..64, TimeoutSeconds 1..60, SitesPerPage 1..5, Distance 1.5..3");
}
static ExperimentalGUISettings read(ConfigurationSection config) {
String path = "Experimental.VoteGUIs.";
boolean global = config.getBoolean(path + "Enabled", false);
// Preserve the earlier explicit hologram opt-in, without enabling any other style.
boolean legacy = config.getBoolean("Experimental.HologramVoteGUI.Enabled", false);
Set<ExperimentalGUIType> enabled = EnumSet.noneOf(ExperimentalGUIType.class);
for (ExperimentalGUIType type : ExperimentalGUIType.values()) {
if ((global || type == ExperimentalGUIType.HOLOGRAM && legacy)
&& config.getBoolean(path + type.configurationKey() + ".Enabled", true)) enabled.add(type);
}
return new ExperimentalGUISettings(enabled, config.getInt(path + "MaxActiveSessions", 32),
config.getInt(path + "TimeoutSeconds", 60), config.getInt(path + "SitesPerPage", 5),
config.getDouble(path + "Distance", 2.5));
}
HologramVoteSettings hologram(ConfigurationSection config) {
HologramVoteSettings original = HologramVoteSettings.read(config);
return new HologramVoteSettings(enabled.contains(ExperimentalGUIType.HOLOGRAM),
original.distance(), original.timeoutSeconds(), original.sitesPerPage());
}
}
@@ -0,0 +1,24 @@
package com.bencodez.votingplugin.experimental;
/** Stable configuration and command identities; styles never silently substitute for each other. */
public enum ExperimentalGUIType {
HOLOGRAM("Hologram", "testhologram"),
ANIMATED_INVENTORY("AnimatedInventory", "testinventorygui"),
NPC("NPC", "testnpcgui"),
STREAK_TRACK("StreakTrack", "teststreakgui"),
RADIAL("Radial", "testradialgui"),
NATIVE_DIALOG("NativeDialog", "testdialoggui"),
REWARD_SHOWCASE("RewardShowcase", "testshowcasegui"),
VOTING_TERMINAL("VotingTerminal", "testterminalgui");
private final String configurationKey;
private final String command;
ExperimentalGUIType(String configurationKey, String command) {
this.configurationKey = configurationKey;
this.command = command;
}
public String configurationKey() { return configurationKey; }
public String command() { return command; }
}
@@ -0,0 +1,22 @@
package com.bencodez.votingplugin.experimental;
import java.util.Objects;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
/** Identity is the holder AND its exact inventory, never a displayed title. */
final class ExperimentalInventory implements InventoryHolder {
final ExperimentalSessions.Session session;
private Inventory inventory;
ExperimentalInventory(ExperimentalSessions.Session session) {
this.session = Objects.requireNonNull(session);
}
void bind(Inventory inventory) {
if (this.inventory != null) throw new IllegalStateException("Inventory already bound");
this.inventory = Objects.requireNonNull(inventory);
}
@Override public Inventory getInventory() { return inventory; }
}
@@ -0,0 +1,55 @@
package com.bencodez.votingplugin.experimental;
import java.util.function.BiConsumer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.inventory.Inventory;
/** Protects only this experimental holder's exact view, including bottom-inventory transfers. */
final class ExperimentalInventoryListener implements Listener {
private final ExperimentalSessions sessions;
private final BiConsumer<ExperimentalSessions.Session, Integer> click;
ExperimentalInventoryListener(ExperimentalSessions sessions,
BiConsumer<ExperimentalSessions.Session, Integer> click) {
this.sessions = sessions;
this.click = click;
}
private static ExperimentalInventory identify(Inventory top) {
if (top.getHolder() instanceof ExperimentalInventory holder && holder.getInventory() == top) return holder;
return null;
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void click(InventoryClickEvent event) {
Inventory top = event.getView().getTopInventory();
ExperimentalInventory holder = identify(top);
if (holder == null) return;
boolean previouslyCancelled = event.isCancelled();
event.setCancelled(true);
if (previouslyCancelled || !sessions.current(holder.session)
|| !holder.session.player.equals(event.getWhoClicked().getUniqueId())) return;
// Transfers, number-key swaps, double clicks and offhand swaps never activate buttons.
if (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT) return;
int slot = event.getRawSlot();
if (slot >= 0 && slot < top.getSize()) click.accept(holder.session, slot);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void drag(InventoryDragEvent event) {
if (identify(event.getView().getTopInventory()) != null) event.setCancelled(true);
}
@EventHandler
public void close(InventoryCloseEvent event) {
ExperimentalInventory holder = identify(event.getView().getTopInventory());
if (holder != null && holder.session.player.equals(event.getPlayer().getUniqueId()))
sessions.close(holder.session);
}
}
@@ -0,0 +1,326 @@
package com.bencodez.votingplugin.experimental;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.logging.Level;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityCombustEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityInteractEvent;
import org.bukkit.event.entity.EntityTargetEvent;
import org.bukkit.event.entity.EntityTeleportEvent;
import org.bukkit.event.player.PlayerInteractAtEntityEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.vehicle.VehicleEnterEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.inventory.EquipmentSlot;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.hologram.HologramMenuScheduler;
/** One temporary native Villager per session; no Citizens, fake players, storage or world scans. */
final class ExperimentalNPC implements Listener {
private static final int MAX_NPCS = 64;
private final VotingPluginMain plugin;
private final HologramMenuScheduler scheduler;
private final ExperimentalSessions sessions;
private final Map<UUID, NPC> resources = new ConcurrentHashMap<>();
private final Map<UUID, NPC> entities = new ConcurrentHashMap<>();
private static final class NPC {
final ExperimentalSessions.Session session;
final Player player;
final Location anchor;
final String name;
final Runnable selection;
final AtomicBoolean selected = new AtomicBoolean();
// Mutated under this record's monitor. Entity access itself belongs to the anchor region.
boolean closed;
boolean cleanupQueued;
Villager entity;
UUID entityId;
boolean privateVisibility;
NPC(ExperimentalSessions.Session session, Player player, Location anchor, String name, Runnable selection) {
this.session = session;
this.player = player;
this.anchor = anchor.clone();
this.name = name;
this.selection = selection;
}
}
ExperimentalNPC(VotingPluginMain plugin, HologramMenuScheduler scheduler, ExperimentalSessions sessions) {
this.plugin = Objects.requireNonNull(plugin);
this.scheduler = Objects.requireNonNull(scheduler);
this.sessions = Objects.requireNonNull(sessions);
}
static boolean supported() {
try {
Entity.class.getMethod("setPersistent", boolean.class);
Entity.class.getMethod("setGravity", boolean.class);
Entity.class.getMethod("setInvulnerable", boolean.class);
LivingEntity.class.getMethod("setAI", boolean.class);
LivingEntity.class.getMethod("setCollidable", boolean.class);
World.class.getMethod("spawn", Location.class, Class.class, Consumer.class);
return true;
} catch (ReflectiveOperationException | LinkageError unavailable) {
return false;
}
}
void open(ExperimentalSessions.Session session, Player player, Location anchor, String name, Runnable selection) {
Objects.requireNonNull(session);
Objects.requireNonNull(player);
Objects.requireNonNull(selection);
if (!sessions.current(session)) return;
if (anchor == null || anchor.getWorld() == null || !Double.isFinite(anchor.getX())
|| !Double.isFinite(anchor.getY()) || !Double.isFinite(anchor.getZ())
|| !Float.isFinite(anchor.getYaw()) || !Float.isFinite(anchor.getPitch())
|| name == null || name.length() > 128 || !supported()) {
sessions.close(session);
return;
}
NPC npc = new NPC(session, player, anchor, name, selection);
synchronized (resources) {
// Repeated snapshots/refreshes do not create another NPC for the same session.
if (resources.containsKey(session.id)) return;
if (resources.size() >= MAX_NPCS) { sessions.close(session); return; }
resources.put(session.id, npc);
}
try {
// Own even a not-yet-spawned resource before either scheduler may accept work.
session.own(() -> retire(npc), this::report);
scheduler.player(player, () -> {
try {
if (!validPlayer(npc, player)) { sessions.close(session); return; }
scheduler.region(npc.anchor, () -> spawn(npc));
} catch (RuntimeException | LinkageError failure) { fail(npc, failure); }
}, () -> sessions.close(session));
} catch (RuntimeException | LinkageError failure) { fail(npc, failure); }
}
int entityCount() { return entities.size(); }
/** Cleanup commands/reload can retry rejected removals after the session owner has retired. */
void retryCleanup() {
for (NPC npc : resources.values()) {
boolean closed;
synchronized (npc) { closed = npc.closed; }
if (closed) retire(npc);
}
}
private boolean current(NPC npc) {
return !npc.closed && plugin.isEnabled() && sessions.current(npc.session);
}
// This method is called only on the player's owner scheduler, never from the spawn region.
private boolean validPlayer(NPC npc, Player player) {
synchronized (npc) {
return current(npc) && npc.session.player.equals(player.getUniqueId()) && player.isOnline()
&& (player.hasPermission("VotingPlugin.Admin")
|| player.hasPermission("VotingPlugin.Commands.AdminVote." + npc.session.type.command()))
&& player.getWorld().equals(npc.anchor.getWorld())
&& player.getEyeLocation().distanceSquared(npc.anchor) <= 64;
}
}
private boolean ownsArea(NPC npc) {
if (!scheduler.owns(npc.anchor)) return false;
// The Villager's bounds must not overlap a neighbouring region owned by another tick thread.
for (double dx : new double[] { -.5, .5 }) for (double dz : new double[] { -.5, .5 })
if (!scheduler.owns(npc.anchor.clone().add(dx, 0, dz))) return false;
return true;
}
private boolean loaded(NPC npc) {
World world = npc.anchor.getWorld();
if (Bukkit.getWorld(world.getUID()) != world) return false;
for (double dx : new double[] { -.5, .5 }) for (double dz : new double[] { -.5, .5 }) {
Location edge = npc.anchor.clone().add(dx, 0, dz);
if (!world.isChunkLoaded(edge.getBlockX() >> 4, edge.getBlockZ() >> 4)) return false;
}
return true;
}
private void spawn(NPC npc) {
try {
synchronized (npc) {
if (!current(npc)) { sessions.close(npc.session); return; }
if (npc.entity != null) return;
if (!ownsArea(npc) || !loaded(npc)) {
throw new IllegalStateException("NPC area is unavailable or belongs to another region");
}
Villager spawned = npc.anchor.getWorld().spawn(npc.anchor, Villager.class, villager -> {
npc.entity = villager;
npc.entityId = villager.getUniqueId();
entities.put(npc.entityId, npc);
// No fallback to a persistent entity on old implementations.
villager.setPersistent(false);
if (!current(npc)) throw new IllegalStateException("NPC session retired during spawn");
villager.setAI(false);
villager.setGravity(false);
villager.setInvulnerable(true);
villager.setSilent(true);
villager.setCollidable(false);
villager.setCanPickupItems(false);
villager.setCustomName(npc.name);
villager.setCustomNameVisible(true);
villager.setRemoveWhenFarAway(false);
try {
Entity.class.getMethod("setVisibleByDefault", boolean.class);
Player.class.getMethod("showEntity", org.bukkit.plugin.Plugin.class, Entity.class);
villager.setVisibleByDefault(false);
npc.privateVisibility = true;
} catch (NoSuchMethodException olderVisibilityAPI) {
// Older Bukkit versions display a public Villager, still owner-interaction-only.
} catch (NoSuchMethodError | AbstractMethodError olderImplementation) {
// Implementations predating the visibility API retain public visibility.
}
});
if (spawned != npc.entity || !spawned.isValid() || !current(npc)) {
throw new IllegalStateException("NPC spawn was cancelled or its session retired");
}
}
scheduler.player(npc.player, () -> {
try {
synchronized (npc) {
if (!validPlayer(npc, npc.player)) { sessions.close(npc.session); return; }
if (npc.privateVisibility) {
// Showing touches tracking: the player's thread must own the stationary NPC too.
if (!ownsArea(npc)) { sessions.close(npc.session); return; }
npc.player.showEntity(plugin, npc.entity);
}
npc.session.activate();
}
} catch (RuntimeException | LinkageError failure) { fail(npc, failure); }
}, () -> sessions.close(npc.session));
} catch (RuntimeException | LinkageError failure) { fail(npc, failure); }
}
private void retire(NPC npc) {
synchronized (npc) {
npc.closed = true;
if (npc.entity == null) { resources.remove(npc.session.id, npc); return; }
if (npc.cleanupQueued) return;
npc.cleanupQueued = true;
}
try {
// Inline removal, when already owned, also covers a partial-spawn exception before publication.
if (ownsArea(npc)) removeOwned(npc);
else scheduler.region(npc.anchor, () -> removeOwned(npc));
} catch (RuntimeException | LinkageError failure) {
// Final Folia shutdown can reject region work. Nonpersistent entities are discarded at unload.
// Retain exact identity and the capacity slot until removal/unload; live rejection must not
// permit unbounded orphan spawns. A later owned event may retry the cleanup safely.
synchronized (npc) { npc.cleanupQueued = false; }
report(failure);
}
}
private void removeOwned(NPC npc) {
try {
synchronized (npc) {
if (npc.entity == null) return;
if (!ownsArea(npc)) throw new IllegalStateException("NPC removal requires its fixed region");
World world = npc.anchor.getWorld();
// An unloaded world already discarded the nonpersistent NPC; never reload it for cleanup.
if (Bukkit.getWorld(world.getUID()) == world) npc.entity.remove();
}
forget(npc);
} catch (RuntimeException | LinkageError failure) {
synchronized (npc) { npc.cleanupQueued = false; }
report(failure);
}
}
private void forget(NPC npc) {
synchronized (npc) {
if (npc.entityId != null) entities.remove(npc.entityId, npc);
npc.entity = null;
resources.remove(npc.session.id, npc);
}
}
private void fail(NPC npc, Throwable failure) {
sessions.close(npc.session);
report(failure);
}
private void report(Throwable failure) {
try { plugin.getLogger().log(Level.WARNING, "Experimental NPC failed", failure); }
catch (RuntimeException ignored) { /* Reporting must never prevent another session's cleanup. */ }
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void interact(PlayerInteractEntityEvent event) {
NPC npc = entities.get(event.getRightClicked().getUniqueId());
if (npc == null) return;
boolean previouslyCancelled = event.isCancelled();
event.setCancelled(true); // Blocks trading for everyone, including unrelated viewers of old-API NPCs.
synchronized (npc) {
if (npc.closed) { retire(npc); return; }
}
Player player = event.getPlayer();
if (previouslyCancelled || event.getHand() != EquipmentSlot.HAND
|| !npc.session.player.equals(player.getUniqueId()) || !npc.selected.compareAndSet(false, true)) return;
try {
scheduler.player(player, () -> {
try {
if (!validPlayer(npc, player)) { sessions.close(npc.session); return; }
npc.selection.run();
} catch (RuntimeException | LinkageError failure) { fail(npc, failure); }
}, () -> sessions.close(npc.session));
} catch (RuntimeException | LinkageError failure) { fail(npc, failure); }
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void interactAt(PlayerInteractAtEntityEvent event) { interact(event); }
private boolean owned(Entity entity) {
NPC npc = entities.get(entity.getUniqueId());
if (npc == null) return false;
synchronized (npc) {
if (npc.closed) retire(npc);
}
return true;
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void damage(EntityDamageEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void teleport(EntityTeleportEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void target(EntityTargetEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void interactBlock(EntityInteractEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void combust(EntityCombustEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void vehicle(VehicleEnterEvent event) { if (owned(event.getEntered())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void unload(WorldUnloadEvent event) {
for (NPC npc : resources.values()) if (npc.anchor.getWorld() == event.getWorld()) {
sessions.close(npc.session);
// An accepted world unload discards nonpersistent NPCs even if its region has stopped ticking.
forget(npc);
}
}
}
@@ -0,0 +1,137 @@
package com.bencodez.votingplugin.experimental;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.LongSupplier;
/**
* Bounded ownership of temporary menus, independent of production inventory registrations.
* Cleanup actions dispatch to their platform owner; this class never accesses Bukkit state.
*/
final class ExperimentalSessions {
enum State { CREATING, ACTIVE, CLOSED }
static final class Session {
final UUID id = UUID.randomUUID();
final UUID player;
final ExperimentalGUIType type;
final long createdAtNanos;
final long lifetimeNanos;
private State state = State.CREATING;
private final List<Runnable> cleanup = new ArrayList<>();
private Session(UUID player, ExperimentalGUIType type, long now, long lifetimeNanos) {
this.player = player;
this.type = type;
this.createdAtNanos = now;
this.lifetimeNanos = lifetimeNanos;
}
synchronized State state() { return state; }
synchronized boolean activate() {
if (state != State.CREATING) return false;
state = State.ACTIVE;
return true;
}
boolean expired(long now) { return now - createdAtNanos >= lifetimeNanos; }
// Registration races with shutdown: late resources must still be retired exactly once.
void own(Runnable release, Consumer<RuntimeException> errors) {
Objects.requireNonNull(release);
boolean full;
synchronized (this) {
full = state != State.CLOSED && cleanup.size() >= 128;
if (state != State.CLOSED) {
if (!full) {
cleanup.add(release);
return;
}
}
}
release(release, errors);
if (full) throw new IllegalStateException("Experimental resource limit reached");
}
private void close(Consumer<RuntimeException> errors) {
List<Runnable> releases;
synchronized (this) {
if (state == State.CLOSED) return;
state = State.CLOSED;
releases = List.copyOf(cleanup);
cleanup.clear();
}
for (Runnable action : releases) release(action, errors);
}
private static void release(Runnable action, Consumer<RuntimeException> errors) {
try { action.run(); }
catch (RuntimeException failure) {
// A reporting failure must not prevent cleanup of the remaining resources.
try { errors.accept(failure); } catch (RuntimeException ignored) { }
}
}
}
private final Map<UUID, Session> sessions = new HashMap<>();
private final LongSupplier clock;
private final Consumer<RuntimeException> errors;
private boolean stopped;
ExperimentalSessions(LongSupplier clock, Consumer<RuntimeException> errors) {
this.clock = Objects.requireNonNull(clock);
this.errors = Objects.requireNonNull(errors);
}
Session open(UUID player, ExperimentalGUIType type, int limit, int timeoutSeconds) {
Objects.requireNonNull(player);
Objects.requireNonNull(type);
if (limit < 1 || limit > 64 || timeoutSeconds < 1 || timeoutSeconds > 60)
throw new IllegalArgumentException("Session limit must be 1..64 and timeout 1..60 seconds");
Session previous;
Session next;
synchronized (this) {
if (stopped || (!sessions.containsKey(player) && sessions.size() >= limit)) return null;
next = new Session(player, type, clock.getAsLong(), timeoutSeconds * 1_000_000_000L);
previous = sessions.put(player, next);
}
if (previous != null) previous.close(errors);
return next;
}
synchronized boolean current(Session session) {
return !stopped && sessions.get(session.player) == session
&& session.state() != State.CLOSED && !session.expired(clock.getAsLong());
}
void close(Session session) {
synchronized (this) { sessions.remove(session.player, session); }
session.close(errors);
}
void close(UUID player) {
Session session;
synchronized (this) { session = sessions.remove(player); }
if (session != null) session.close(errors);
}
synchronized List<Session> snapshot() { return List.copyOf(sessions.values()); }
void expire() {
for (Session session : snapshot()) if (session.expired(clock.getAsLong())) close(session);
}
void clear(boolean shutdown) {
List<Session> retired;
synchronized (this) {
if (shutdown) stopped = true;
retired = List.copyOf(sessions.values());
sessions.clear();
}
for (Session session : retired) session.close(errors);
}
}
@@ -0,0 +1,127 @@
package com.bencodez.votingplugin.experimental;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.plugin.Plugin;
import com.bencodez.votingplugin.VotingPluginMain;
import io.github.projectunified.unidialog.core.payload.DialogPayload;
/** Lazy host adapter over already bundled UniDialog; owns its own callbacks and event hook. */
final class NativeExperimentalDialog implements ExperimentalDialogs.Backend {
private static final String NAMESPACE = "votingplugin_experimental";
private record Platform(String manager, Class<?> event, boolean paper, Method close) {}
private final Object manager;
private final Platform platform;
private static Platform probe() throws ReflectiveOperationException {
ClassLoader loader = NativeExperimentalDialog.class.getClassLoader();
try {
Class<?> dialog = Class.forName("io.papermc.paper.dialog.Dialog", false, loader);
Class<?> event = Class.forName("io.papermc.paper.event.player.PlayerCustomClickEvent", false, loader);
event.getMethod("getIdentifier");
event.getMethod("getCommonConnection");
Class<?> dialogLike = Class.forName("net.kyori.adventure.dialog.DialogLike", false, loader);
Player.class.getMethod("showDialog", dialogLike);
Method close = Player.class.getMethod("closeDialog");
Class.forName("io.papermc.paper.registry.data.dialog.ActionButton", false, loader);
if (!dialogLike.isAssignableFrom(dialog)) throw new NoSuchMethodException("Incompatible Paper dialog API");
return new Platform("io.github.projectunified.unidialog.paper.PaperDialogManager", event, true, close);
} catch (ClassNotFoundException | NoSuchMethodException noPaper) {
Class<?> dialog = Class.forName("net.md_5.bungee.api.dialog.Dialog", false, loader);
Class<?> event = Class.forName("org.bukkit.event.player.PlayerCustomClickEvent", false, loader);
event.getMethod("getId");
event.getMethod("getPlayer");
Player.class.getMethod("showDialog", dialog);
return new Platform("io.github.projectunified.unidialog.spigot.SpigotDialogManager", event, false,
Player.class.getMethod("clearDialog"));
}
}
static boolean supported() {
try { probe(); return true; }
catch (ReflectiveOperationException | LinkageError unavailable) { return false; }
}
@SuppressWarnings("unchecked")
NativeExperimentalDialog(VotingPluginMain plugin) {
try {
platform = probe();
Class<?> type = Class.forName(platform.manager(), true, getClass().getClassLoader());
manager = type.getConstructor(Plugin.class, String.class).newInstance(plugin, NAMESPACE);
Method dispatch = type.getMethod("onCustomClick", platform.event());
// Do not register the library listener: all its HashMap accesses use this private lock.
plugin.getServer().getPluginManager().registerEvent((Class<? extends Event>) platform.event(),
new Listener() {}, EventPriority.NORMAL, (listener, event) -> {
if (!platform.event().isInstance(event)) return;
synchronized (manager) {
try { dispatch.invoke(manager, event); }
catch (ReflectiveOperationException failure) {
plugin.getLogger().warning("Experimental dialog callback failed: " + failure.getClass().getSimpleName());
}
}
}, plugin, false);
} catch (ReflectiveOperationException | LinkageError failure) {
throw new IllegalStateException("Native dialog API unavailable", failure);
}
}
@Override public Runnable show(Player player, String title, String body, List<ExperimentalDialogs.Button> buttons,
BiConsumer<UUID, String> action) {
List<String> ids = new ArrayList<>();
synchronized (manager) {
try {
Object dialog = call(manager, "createMultiActionDialog", new Class<?>[0]);
call(dialog, "title", new Class<?>[] {String.class}, title);
call(dialog, "canCloseWithEscape", new Class<?>[] {boolean.class}, true);
call(dialog, "pause", new Class<?>[] {boolean.class}, false);
call(dialog, "afterAction", new Class<?>[] {io.github.projectunified.unidialog.core.dialog.Dialog.AfterAction.class},
io.github.projectunified.unidialog.core.dialog.Dialog.AfterAction.CLOSE);
call(dialog, "columns", new Class<?>[] {int.class}, 1);
call(dialog, "body", new Class<?>[] {Consumer.class}, (Consumer<Object>) builder -> {
Object text = call(builder, "text", new Class<?>[0]);
call(text, "text", new Class<?>[] {String.class}, body);
});
for (var button : buttons) {
String id = UUID.randomUUID().toString();
if (button.action() != null) {
Consumer<DialogPayload> callback = payload -> action.accept(payload.owner(), button.action());
call(manager, "registerCustomAction", new Class<?>[] {String.class, String.class, Consumer.class},
NAMESPACE, id, callback);
ids.add(id);
}
call(dialog, "action", new Class<?>[] {Consumer.class}, (Consumer<Object>) builder -> {
call(builder, "label", new Class<?>[] {String.class}, button.label());
call(builder, "tooltip", new Class<?>[] {String.class}, button.tooltip());
if (button.url() != null) call(builder, "openUrl", new Class<?>[] {String.class}, button.url());
else call(builder, "dynamicCustom", new Class<?>[] {String.class, String.class}, NAMESPACE, id);
});
}
Object opener = call(dialog, "opener", new Class<?>[0]);
Class<?> audience = platform.paper()
? Class.forName("net.kyori.adventure.audience.Audience", false, getClass().getClassLoader()) : Player.class;
call(opener, "open", new Class<?>[] {audience}, player);
} catch (RuntimeException | LinkageError | ClassNotFoundException failure) {
release(ids);
throw new IllegalStateException("Native dialog could not open", failure);
}
}
List<String> owned = List.copyOf(ids);
return () -> { synchronized (manager) { release(owned); } };
}
private void release(List<String> ids) {
for (String id : ids) call(manager, "unregisterCustomAction", new Class<?>[] {String.class, String.class}, NAMESPACE, id);
}
@Override public void close(Player player) {
try { platform.close().invoke(player); }
catch (ReflectiveOperationException failure) { throw new IllegalStateException("Native dialog could not close", failure); }
}
private static Object call(Object target, String name, Class<?>[] types, Object... arguments) {
try { return target.getClass().getMethod(name, types).invoke(target, arguments); }
catch (ReflectiveOperationException failure) { throw new IllegalStateException("Native dialog method unavailable: " + name, failure); }
}
}
@@ -0,0 +1,81 @@
package com.bencodez.votingplugin.hologram;
import java.lang.reflect.Method;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
/** Uses entity ownership for players and fixed region ownership for stationary menu entities. */
public class HologramMenuScheduler {
private final VotingPluginMain plugin;
private final Method regionExecute;
private final Object regionScheduler;
private final Method ownsLocation;
public HologramMenuScheduler(VotingPluginMain plugin) {
this.plugin = plugin;
Method execute = null;
Method owns = null;
Object scheduler = null;
try {
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
scheduler = Bukkit.class.getMethod("getRegionScheduler").invoke(null);
execute = Class.forName("io.papermc.paper.threadedregions.scheduler.RegionScheduler")
.getMethod("execute", Plugin.class, org.bukkit.World.class, int.class, int.class, Runnable.class);
owns = Bukkit.class.getMethod("isOwnedByCurrentRegion", Location.class);
} catch (ClassNotFoundException notFolia) {
// Spigot/Paper have one world-owner thread. No Folia API is linked on these servers.
} catch (ReflectiveOperationException incompatibleFolia) {
throw new IllegalStateException("Folia region scheduler unavailable", incompatibleFolia);
}
regionExecute = execute;
regionScheduler = scheduler;
ownsLocation = owns;
}
public void player(Player player, Runnable action, Runnable retired) {
// Bukkit disables the plugin before onDisable. Already-owned cleanup must not
// enqueue a task that will be rejected after the inventory listener is removed.
if (regionExecute == null && Bukkit.isPrimaryThread()) action.run();
else BukkitCompletionScheduler.run(plugin, player, action, retired, retired);
}
public void region(Location anchor, Runnable action) {
if (regionExecute == null) {
if (Bukkit.isPrimaryThread()) action.run();
else plugin.getBukkitScheduler().runTask(plugin, action, anchor);
return;
}
try {
// Raw execute survives subsequent task cancellation during PluginDisableEvent.
// Every managed entity is stationary and removed from this same fixed region.
regionExecute.invoke(regionScheduler, plugin, anchor.getWorld(), anchor.getBlockX() >> 4,
anchor.getBlockZ() >> 4, action);
} catch (ReflectiveOperationException failed) {
throw new IllegalStateException("Cannot schedule hologram region cleanup", failed);
}
}
/** Capability selected once without linking Folia classes on other servers. */
public boolean regionized() { return regionExecute != null; }
public boolean owns(Location location) {
if (ownsLocation == null) return Bukkit.isPrimaryThread();
try {
return Boolean.TRUE.equals(ownsLocation.invoke(null, location));
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Cannot verify hologram region ownership", failure);
}
}
public Runnable watchPlayer(Player player, Runnable action, Runnable retired) {
var task = plugin.getBukkitScheduler().getFoliaLib().getImpl()
.runAtEntityTimer(player, action, retired, 20L, 20L);
return task::cancel;
}
}
@@ -0,0 +1,333 @@
package com.bencodez.votingplugin.hologram;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.LongSupplier;
import java.util.function.BooleanSupplier;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
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.EntityDamageEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerInteractAtEntityEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.server.PluginDisableEvent;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.votesites.VoteSite;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.TextComponent;
/** Experimental, read-only menu. User storage is read solely on the existing persistence worker. */
public final class HologramVoteMenu implements Listener {
static final int MAX_MENUS = 64;
static final int MAX_SITES = 200;
private final VotingPluginMain plugin;
private final HologramMenuScheduler scheduler;
private final LongSupplier clock;
private final ConcurrentHashMap<UUID, Session> sessions = new ConcurrentHashMap<>();
private final ConcurrentHashMap<UUID, Button> buttons = new ConcurrentHashMap<>();
private volatile boolean stopped;
private final AtomicInteger pendingLoads = new AtomicInteger();
enum Action { SITE, PREVIOUS, NEXT, CLOSE }
record Button(Session session, long version, Action action, int siteIndex) { }
static final class Session {
final UUID owner;
final Player player;
final Location anchor;
final HologramVoteSettings settings;
final long expiresAt;
final AtomicBoolean closed = new AtomicBoolean();
final AtomicLong lastClick = new AtomicLong();
// Entity list and page/version are accessed only in the stationary anchor region.
final List<Entity> entities = new ArrayList<>();
volatile List<HologramVoteModel.Site> sites = List.of();
volatile Runnable cancelWatch = () -> { };
int page;
long version;
BooleanSupplier externalGuard = () -> true;
Runnable onClosed = () -> {};
Session(Player player, Location anchor, HologramVoteSettings settings) {
this(player, anchor, settings, System.nanoTime());
}
Session(Player player, Location anchor, HologramVoteSettings settings, long now) {
this.owner = player.getUniqueId();
this.player = player;
this.anchor = anchor.clone();
this.settings = settings;
this.expiresAt = now + settings.timeoutSeconds() * 1_000_000_000L;
}
boolean expired(long now) { return now - expiresAt >= 0; }
}
public HologramVoteMenu(VotingPluginMain plugin) {
this(plugin, new HologramMenuScheduler(plugin));
}
HologramVoteMenu(VotingPluginMain plugin, HologramMenuScheduler scheduler) {
this(plugin, scheduler, System::nanoTime);
}
HologramVoteMenu(VotingPluginMain plugin, HologramMenuScheduler scheduler, LongSupplier clock) {
this.plugin = plugin;
this.scheduler = scheduler;
this.clock = clock;
}
public static boolean supported() {
try {
Class.forName("org.bukkit.entity.TextDisplay");
Class.forName("org.bukkit.entity.Interaction");
Entity.class.getMethod("setVisibleByDefault", boolean.class);
return true;
} catch (ReflectiveOperationException unavailable) {
return false;
}
}
public void open(Player player) {
open(player, null, () -> true, () -> {});
}
/** Shared experimental lifecycle adapter; legacy public opening retains its original settings. */
public void open(Player player, HologramVoteSettings override, BooleanSupplier guard, Runnable onClosed) {
scheduler.player(player, () -> {
if (!guard.getAsBoolean()) { onClosed.run(); return; }
if (openOwned(player, override, guard, onClosed) == null) onClosed.run();
}, onClosed);
}
private Session openOwned(Player player, HologramVoteSettings override, BooleanSupplier guard, Runnable onClosed) {
if (stopped || !plugin.isEnabled() || !player.isOnline()) return null;
if (!player.hasPermission("VotingPlugin.Admin")
&& !player.hasPermission("VotingPlugin.Commands.AdminVote.TestHologram")) {
player.sendMessage("\u00a7cYou do not have permission to use this experimental menu.");
return null;
}
HologramVoteSettings settings;
try {
settings = override == null ? HologramVoteSettings.read(plugin.getConfigFile().getData()) : override;
} catch (IllegalArgumentException invalid) {
player.sendMessage("\u00a7cInvalid Experimental.HologramVoteGUI settings: " + invalid.getMessage());
return null;
}
if (!settings.enabled()) {
player.sendMessage("\u00a7eEnable Experimental.HologramVoteGUI.Enabled to test this menu.");
return null;
}
if (!supported()) {
player.sendMessage("\u00a7cThis experimental menu requires Minecraft 1.19.4+ entity/visibility APIs.");
return null;
}
close(player.getUniqueId());
Location eyes = player.getEyeLocation();
Location anchor = eyes.clone().add(eyes.getDirection().multiply(settings.distance()));
anchor.setYaw(eyes.getYaw() + 180);
anchor.setPitch(0);
Session session = new Session(player, anchor, settings, clock.getAsLong());
session.externalGuard = guard;
session.onClosed = onClosed;
synchronized (sessions) {
if (stopped) return null;
if (sessions.size() >= MAX_MENUS) {
player.sendMessage("\u00a7eToo many experimental menus are open. Please retry shortly.");
return null;
}
sessions.put(session.owner, session);
}
List<VoteSite> visible = plugin.getVoteSiteManager().getVoteSitesEnabled().stream()
.filter(site -> !site.isHidden() && (site.getPermissionToView().isEmpty()
|| player.hasPermission(site.getPermissionToView())))
.limit(MAX_SITES).toList();
if (pendingLoads.incrementAndGet() > MAX_MENUS) {
pendingLoads.decrementAndGet();
close(session);
player.sendMessage("\u00a7eVoting data is busy; please retry shortly.");
return session;
}
boolean submitted = false;
try {
session.cancelWatch = scheduler.watchPlayer(player, () -> watch(session), () -> close(session));
if (session.closed.get()) session.cancelWatch.run();
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
try {
var user = plugin.getUser(session.owner);
List<HologramVoteModel.Site> sites = new ArrayList<>();
for (VoteSite site : visible) {
if (!current(session)) return;
sites.add(new HologramVoteModel.Site(site.getKey(), site.getDisplayName(),
site.getVoteURL(false), user.canVoteSite(site), user.voteNextDurationTime(site)));
}
if (!current(session)) return;
session.sites = List.copyOf(sites);
scheduler.region(session.anchor, () -> render(session));
} catch (Exception failure) {
fail(session, "Voting-site data could not be loaded; check the server console.", failure);
} finally {
pendingLoads.decrementAndGet();
}
});
submitted = true;
} catch (RuntimeException rejected) {
if (!submitted) pendingLoads.decrementAndGet();
fail(session, "The experimental menu could not be scheduled.", rejected);
}
return session;
}
private boolean current(Session session) {
return !stopped && !session.closed.get() && !session.expired(clock.getAsLong())
&& sessions.get(session.owner) == session && session.externalGuard.getAsBoolean();
}
private void watch(Session session) {
if (!current(session) || !session.player.isOnline()
|| !session.anchor.getWorld().equals(session.player.getWorld())
|| session.anchor.distanceSquared(session.player.getEyeLocation()) > 64) close(session);
}
private void render(Session session) {
if (!current(session)) { close(session); return; }
removeEntities(session);
long version = ++session.version;
try {
NativeHologramRenderer.render(session, scheduler, (entity, action, index) -> {
session.entities.add(entity);
if (action != null) buttons.put(entity.getUniqueId(), new Button(session, version, action, index));
});
if (!current(session)) { removeEntities(session); return; }
List<Entity> entities = List.copyOf(session.entities);
scheduler.player(session.player, () -> {
if (!current(session)) return;
// Showing an entity can touch its tracker. Verify the fixed menu area is also
// owned by the player's current region before calling the player visibility API.
for (int dx : new int[] { -3, 3 }) for (int dz : new int[] { -3, 3 }) {
if (!scheduler.owns(session.anchor.clone().add(dx, 0, dz))) { close(session); return; }
}
if (!session.player.getWorld().equals(session.anchor.getWorld())
|| session.player.getEyeLocation().distanceSquared(session.anchor) > 64) { close(session); return; }
entities.forEach(entity -> session.player.showEntity(plugin, entity));
}, () -> close(session));
} catch (RuntimeException failure) {
removeEntities(session);
fail(session, "The hologram could not be created here. Move to an open area and retry.", failure);
}
}
private void fail(Session session, String message, Exception failure) {
plugin.getLogger().warning("Experimental hologram failed: " + failure.getClass().getSimpleName());
close(session);
if (!stopped && plugin.isEnabled()) scheduler.player(session.player,
() -> session.player.sendMessage("\u00a7c" + message), () -> { });
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void interact(PlayerInteractEntityEvent event) {
Button button = buttons.get(event.getRightClicked().getUniqueId());
if (button == null) return;
event.setCancelled(true);
click(event.getPlayer(), button);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void interactAt(PlayerInteractAtEntityEvent event) { interact(event); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void damage(EntityDamageEvent event) {
if (buttons.containsKey(event.getEntity().getUniqueId())) event.setCancelled(true);
}
private void click(Player player, Button button) {
Session session = button.session();
if (!session.owner.equals(player.getUniqueId()) || !current(session)) return;
scheduler.player(player, () -> {
if (!current(session) || !player.hasPermission("VotingPlugin.Admin")
&& !player.hasPermission("VotingPlugin.Commands.AdminVote.TestHologram")) return;
if (!player.getWorld().equals(session.anchor.getWorld())
|| player.getEyeLocation().distanceSquared(session.anchor) > 64) { close(session); return; }
long now = clock.getAsLong();
long previous = session.lastClick.get();
if (now - previous < 200_000_000L || !session.lastClick.compareAndSet(previous, now)) return;
scheduler.region(session.anchor, () -> {
if (!current(session) || session.version != button.version()) return;
if (button.action() == Action.CLOSE) { close(session); return; }
if (button.action() == Action.NEXT) session.page++;
if (button.action() == Action.PREVIOUS) session.page--;
if (button.action() == Action.SITE) {
HologramVoteModel.Site site = session.sites.get(button.siteIndex());
var url = HologramVoteModel.votingUrl(site.url());
scheduler.player(player, () -> {
if (!current(session)) return;
if (url.isEmpty()) {
player.sendMessage("\u00a7cThis voting site has no valid HTTP/HTTPS VoteURL.");
return;
}
TextComponent link = new TextComponent("\u00a7aOpen voting page: " + site.title());
link.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url.get()));
player.spigot().sendMessage(link);
}, () -> close(session));
} else {
session.page = Math.max(0, Math.min(session.page,
HologramVoteModel.pages(session.sites.size(), session.settings.sitesPerPage()) - 1));
render(session);
}
});
}, () -> close(session));
}
public void close(UUID owner) {
Session session = sessions.get(owner);
if (session != null) close(session);
}
/** A late cleanup can retire only the hologram associated with this exact lifecycle guard. */
public void close(UUID owner, BooleanSupplier guard) {
Session session = sessions.get(owner);
if (session != null && session.externalGuard == guard) close(session);
}
private void close(Session session) {
if (!session.closed.compareAndSet(false, true)) return;
sessions.remove(session.owner, session);
buttons.entrySet().removeIf(entry -> entry.getValue().session() == session);
try { session.cancelWatch.run(); }
catch (RuntimeException cancellationFailure) {
plugin.getLogger().warning("Hologram watch cancellation failed; continuing entity cleanup.");
}
try {
scheduler.region(session.anchor, () -> removeEntities(session));
} catch (RuntimeException rejected) {
// During final Folia shutdown region ticks may already be stopped. These entities are
// non-persistent; world teardown discards them rather than serializing orphan menus.
plugin.getLogger().warning("Hologram cleanup could not be scheduled during shutdown.");
}
session.onClosed.run();
}
private void removeEntities(Session session) {
for (Entity entity : session.entities) {
buttons.remove(entity.getUniqueId());
entity.remove();
}
session.entities.clear();
}
public void clear() { List.copyOf(sessions.values()).forEach(this::close); }
public void shutdown() { synchronized (sessions) { stopped = true; } clear(); }
@EventHandler public void quit(PlayerQuitEvent event) { close(event.getPlayer().getUniqueId()); }
@EventHandler public void changedWorld(PlayerChangedWorldEvent event) { close(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.LOWEST)
public void disabling(PluginDisableEvent event) { if (event.getPlugin() == plugin) shutdown(); }
}
@@ -0,0 +1,57 @@
package com.bencodez.votingplugin.hologram;
import java.net.URI;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/** Immutable presentation data; never executes votes, commands or rewards. */
public final class HologramVoteModel {
private HologramVoteModel() { }
public record Site(String key, String name, String url, boolean canVote, long remainingSeconds) {
public String title() {
String title = name == null || name.isBlank() ? key : name;
title = title.replaceAll("[\\r\\n\\p{Cntrl}]", " ");
if (title.length() > 22) title = title.substring(0, 21) + "\u2026";
return title;
}
public String label() {
String status = canVote ? "Vote Now" : remainingSeconds > 0
? duration(remainingSeconds) + " Remaining" : "Cooldown active";
return (canVote ? "\u00a7a" : "\u00a7c") + "\u25cf " + title() + " \u00a77- " + status;
}
}
public static List<Site> page(List<Site> sites, int page, int perPage) {
if (perPage < 1 || perPage > 5) throw new IllegalArgumentException("SitesPerPage must be 1..5");
int pages = pages(sites.size(), perPage);
int start = Math.min(Math.max(page, 0), pages - 1) * perPage;
return List.copyOf(sites.subList(start, Math.min(start + perPage, sites.size())));
}
public static int pages(int sites, int perPage) {
if (perPage < 1 || perPage > 5) throw new IllegalArgumentException("SitesPerPage must be 1..5");
return Math.max(1, (sites + perPage - 1) / perPage);
}
public static Optional<String> votingUrl(String value) {
if (value == null || value.isBlank() || value.length() > 2048) return Optional.empty();
try {
URI uri = new URI(value.trim());
String scheme = uri.getScheme();
if (scheme == null || !List.of("http", "https").contains(scheme.toLowerCase(Locale.ROOT))
|| uri.getHost() == null || uri.getHost().isBlank() || uri.getRawUserInfo() != null
|| uri.getPort() == 0 || uri.getPort() < -1 || uri.getPort() > 65535) return Optional.empty();
return Optional.of(uri.toASCIIString());
} catch (Exception invalid) {
return Optional.empty();
}
}
static String duration(long seconds) {
long hours = seconds / 3600;
long minutes = (seconds % 3600) / 60;
return hours > 0 ? hours + "h " + minutes + "m" : minutes > 0 ? minutes + "m" : seconds + "s";
}
}
@@ -0,0 +1,18 @@
package com.bencodez.votingplugin.hologram;
import org.bukkit.configuration.ConfigurationSection;
public record HologramVoteSettings(boolean enabled, double distance, int timeoutSeconds, int sitesPerPage) {
public HologramVoteSettings {
if (!Double.isFinite(distance) || distance < 1.5 || distance > 3
|| timeoutSeconds < 1 || timeoutSeconds > 60 || sitesPerPage < 1 || sitesPerPage > 5) {
throw new IllegalArgumentException("Distance must be 1.5..3, TimeoutSeconds 1..60, SitesPerPage 1..5");
}
}
public static HologramVoteSettings read(ConfigurationSection config) {
String path = "Experimental.HologramVoteGUI.";
return new HologramVoteSettings(config.getBoolean(path + "Enabled", false),
config.getDouble(path + "Distance", 2.5), config.getInt(path + "TimeoutSeconds", 60),
config.getInt(path + "SitesPerPage", 5));
}
}
@@ -0,0 +1,80 @@
package com.bencodez.votingplugin.hologram;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.entity.Display;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Interaction;
import org.bukkit.entity.TextDisplay;
import org.bukkit.util.Transformation;
import org.bukkit.util.Vector;
import org.joml.AxisAngle4f;
import org.joml.Vector3f;
/** Loaded only after the 1.19.4+ API/visibility probe succeeds. No NMS or external hologram plugin. */
final class NativeHologramRenderer {
interface Spawned { void accept(Entity entity, HologramVoteMenu.Action action, int siteIndex); }
private record Row(Location location, String text, HologramVoteMenu.Action action, int index, float width) { }
static void render(HologramVoteMenu.Session session, HologramMenuScheduler scheduler, Spawned spawned) {
List<Row> rows = new ArrayList<>();
Location anchor = session.anchor;
rows.add(new Row(offset(anchor, 0, 1.18), "\u00a76\u2726 VOTING REWARDS \u2726", null, -1, 0));
List<HologramVoteModel.Site> page = HologramVoteModel.page(session.sites, session.page,
session.settings.sitesPerPage());
for (int i = 0; i < page.size(); i++) {
rows.add(new Row(offset(anchor, 0, .7 - i * .38), page.get(i).label(),
HologramVoteMenu.Action.SITE, session.page * session.settings.sitesPerPage() + i, 2.6f));
}
if (page.isEmpty()) rows.add(new Row(offset(anchor, 0, .7), "\u00a77No visible enabled voting sites", null, -1, 0));
double footer = .58 - Math.max(1, page.size()) * .38;
int pages = HologramVoteModel.pages(session.sites.size(), session.settings.sitesPerPage());
rows.add(new Row(offset(anchor, -.95, footer), (session.page > 0 ? "\u00a7e" : "\u00a78") + "[Previous]",
session.page > 0 ? HologramVoteMenu.Action.PREVIOUS : null, -1, .75f));
rows.add(new Row(offset(anchor, 0, footer), (session.page < pages - 1 ? "\u00a7e" : "\u00a78") + "[Next]",
session.page < pages - 1 ? HologramVoteMenu.Action.NEXT : null, -1, .75f));
rows.add(new Row(offset(anchor, .95, footer), "\u00a7c[Close]", HologramVoteMenu.Action.CLOSE, -1, .75f));
rows.add(new Row(offset(anchor, 0, footer - .35), "\u00a78Page " + (session.page + 1) + "/" + pages
+ " - Right-click an entry", null, -1, 0));
// Check all spawn chunks first. Never touch a neighbouring Folia region from this callback.
for (Row row : rows) if (!scheduler.owns(row.location()))
throw new IllegalStateException("Menu spans an unowned region");
for (Row row : rows) {
TextDisplay display = anchor.getWorld().spawn(row.location(), TextDisplay.class, text -> {
configure(text);
text.setText(row.text());
text.setBillboard(Display.Billboard.FIXED);
text.setLineWidth(450);
text.setShadowed(true);
text.setSeeThrough(false);
text.setBackgroundColor(Color.fromARGB(185, 12, 16, 25));
text.setAlignment(TextDisplay.TextAlignment.CENTER);
text.setTransformation(new Transformation(new Vector3f(), new AxisAngle4f(),
new Vector3f(.5f), new AxisAngle4f()));
});
spawned.accept(display, null, -1);
if (row.action() != null) {
Interaction interaction = anchor.getWorld().spawn(row.location().clone().subtract(0, .06, 0),
Interaction.class, hit -> {
configure(hit);
hit.setInteractionWidth(row.width());
hit.setInteractionHeight(.29f);
hit.setResponsive(false);
});
spawned.accept(interaction, row.action(), row.index());
}
}
}
private static void configure(Entity entity) {
entity.setPersistent(false);
entity.setGravity(false);
entity.setInvulnerable(true);
entity.setVisibleByDefault(false);
}
private static Location offset(Location anchor, double sideways, double up) {
double yaw = Math.toRadians(anchor.getYaw());
return anchor.clone().add(new Vector(Math.cos(yaw) * sideways, up, Math.sin(yaw) * sideways));
}
}
@@ -635,6 +635,14 @@ public class VoteStreakHandler {
return readState(user, definition).streakCount;
}
/** Read-only stored presentation; call on the user-storage worker, never a player/region thread. */
public record StoredPreview(int amount, int bestAmount, boolean awardRecorded) {}
public StoredPreview getStoredPreview(VotingPluginUser user, VoteStreakDefinition definition) {
StreakState state = readState(user, definition);
return new StoredPreview(state.streakCount, state.bestStreakCount, state.hasRewarded(definition));
}
/**
* Gets the highest amount reached for a configured vote streak or progress
* group. Milestone IDs belonging to a progress group return that group's shared
@@ -1253,3 +1253,45 @@ DisableUpdateChecking: false
# Disable checking permissions on tab complete
# Could increase performance slightly, but probably not
DisableAdvancedTab: false
# Experimental native display menu; /vote continues to use its existing GUI.
# Test with /av testhologram (admin only), Minecraft 1.19.4+.
Experimental:
# Independent experimental admin test menus; production /vote menus are unchanged.
VoteGUIs:
Enabled: false
MaxActiveSessions: 32 # Hard bounds: 1..64
TimeoutSeconds: 60 # Hard bounds: 1..60
SitesPerPage: 5 # Hard bounds: 1..5
Distance: 2.5 # Hard bounds: 1.5..3 blocks
# An explicitly enabled legacy HologramVoteGUI still works when the global
# switch is off; Hologram.Enabled below can disable that legacy opt-in too.
Hologram:
Enabled: true
AnimatedInventory:
Enabled: true
NPC:
Enabled: true
# Temporary native villager; clicking opens this experimental site's menu.
DisplayName: 'Voting Guide'
StreakTrack:
Enabled: true
Radial:
Enabled: true
Radius: 1.25 # 0.9..1.8 blocks; native crosshair targeting, no free cursor.
NativeDialog:
Enabled: true
RewardShowcase:
Enabled: true
VotingTerminal:
Enabled: true
# Reserved: this prototype creates only temporary, nonpersistent terminals.
AllowPersistentTerminals: false
HologramVoteGUI:
Enabled: false
# Valid range 1.5..3 blocks from the player's eyes.
Distance: 2.5
# Valid range 1..60 seconds; menus also close when more than 8 blocks away.
TimeoutSeconds: 60
# Valid range 1..5 enabled, visible sites per page.
SitesPerPage: 5
@@ -195,3 +195,56 @@ permissions:
VotingPlugin.Login.RemindVotes.All:
description: Remind if player can vote on all votesites, rather than any
default: false
VotingPlugin.Commands.AdminVote.TestHologram:
description: Allows testing the experimental holographic vote menu
default: op
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.Commands.AdminVote.testinventorygui:
description: Allows the corresponding experimental GUI test command
default: op
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.Commands.AdminVote.testnpcgui:
description: Allows the corresponding experimental GUI test command
default: op
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.Commands.AdminVote.teststreakgui:
description: Allows the corresponding experimental GUI test command
default: op
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.Commands.AdminVote.testradialgui:
description: Allows the corresponding experimental GUI test command
default: op
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.Commands.AdminVote.testdialoggui:
description: Allows the corresponding experimental GUI test command
default: op
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.Commands.AdminVote.testshowcasegui:
description: Allows the corresponding experimental GUI test command
default: op
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.Commands.AdminVote.testterminalgui:
description: Allows the corresponding experimental GUI test command
default: op
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.Commands.AdminVote.TestGUI:
description: Allows the corresponding experimental GUI test command
default: op
children:
VotingPlugin.Commands.AdminVote: true
@@ -0,0 +1,64 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.bukkit.Bukkit;
import org.bukkit.Server;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.permissions.Permission;
import org.bukkit.permissions.PermissionDefault;
import org.bukkit.permissions.PermissibleBase;
import org.bukkit.permissions.ServerOperator;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginManager;
import org.junit.jupiter.api.Test;
class ExperimentalCommandPermissionTest {
@Test void dedicatedPermissionsPassBaseGateWithoutGrantingOtherAdminCommands() throws Exception {
PluginDescriptionFile descriptor;
try (var reader = new InputStreamReader(getClass().getResourceAsStream("/plugin.yml"), StandardCharsets.UTF_8)) {
descriptor = new PluginDescriptionFile(reader);
}
Map<String, Permission> permissions = new HashMap<>();
for (Permission permission : descriptor.getPermissions()) {
permissions.put(permission.getName().toLowerCase(java.util.Locale.ROOT), permission);
}
java.util.List<Permission> experimentalPermissions = new java.util.ArrayList<>();
for (var type : ExperimentalGUIType.values()) {
String name = "VotingPlugin.Commands.AdminVote."
+ (type == ExperimentalGUIType.HOLOGRAM ? "TestHologram" : type.command());
Permission permission = permissions.get(name.toLowerCase(java.util.Locale.ROOT));
assertNotNull(permission, name);
assertEquals(Boolean.TRUE, permission.getChildren().get("VotingPlugin.Commands.AdminVote"));
assertEquals(PermissionDefault.OP, permission.getDefault());
experimentalPermissions.add(permission);
}
Server server = mock(Server.class);
PluginManager manager = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(manager);
when(manager.getPermission(anyString())).thenAnswer(inv ->
permissions.get(inv.getArgument(0, String.class).toLowerCase(java.util.Locale.ROOT)));
when(manager.getDefaultPermissions(anyBoolean())).thenReturn(Set.of());
Plugin plugin = mock(Plugin.class);
when(plugin.isEnabled()).thenReturn(true);
try (var bukkit = mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getPluginManager).thenReturn(manager);
for (Permission permission : experimentalPermissions) {
PermissibleBase nonOp = new PermissibleBase(mock(ServerOperator.class));
nonOp.addAttachment(plugin, permission.getName(), true);
assertTrue(nonOp.hasPermission("VotingPlugin.Commands.AdminVote"));
assertTrue(nonOp.hasPermission(permission.getName()));
assertFalse(nonOp.hasPermission("VotingPlugin.Admin"));
assertFalse(nonOp.hasPermission("VotingPlugin.Commands.AdminVote.Reload"));
}
}
}
}
@@ -0,0 +1,193 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import com.bencodez.advancedcore.api.command.AdvancedCoreTabCompleteHandler;
import com.bencodez.advancedcore.api.command.CommandHandler;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.commands.CommandLoader;
/**
* Exercises the experimental command registrations through the same command
* handler objects and tab-completion implementation used by /av.
*/
class ExperimentalCommandRegistrationTest {
private static final String ADMIN = "VotingPlugin.Admin";
@Test
void registersEveryExperimentalStyleAndManagementActionWithAdminPermission() throws Exception {
VotingPluginMain plugin = commandLoaderFixture();
invokeAdminRegistration(plugin);
List<CommandHandler> handlers = plugin.getAdminVoteCommand();
for (ExperimentalGUIType type : ExperimentalGUIType.values()) {
CommandHandler handler = find(handlers, type.command());
assertNotNull(handler, type.command());
assertEquals(type.command(), handler.getArgs()[0].toLowerCase(),
"the dedicated command identity must stay stable");
assertTrue(handler.getPerm().contains(ADMIN), type.command());
}
for (String action : Arrays.asList("list", "close", "status", "cleanup")) {
CommandHandler handler = find(handlers, "TestGUI", action);
assertNotNull(handler, "testgui " + action);
assertTrue(handler.getPerm().contains(ADMIN));
}
for (String action : Arrays.asList("create", "remove", "list", "inspect")) {
CommandHandler handler = find(handlers, "testterminalgui", action);
assertNotNull(handler, "testterminalgui " + action);
assertTrue(handler.getPerm().contains(ADMIN));
}
}
@Test
void frameworkPermissionChecksAllowAdminAndRejectOrdinaryPlayers() throws Exception {
VotingPluginMain plugin = commandLoaderFixture();
invokeAdminRegistration(plugin);
CommandHandler handler = find(plugin.getAdminVoteCommand(), "testdialoggui");
assertNotNull(handler);
Player admin = mock(Player.class);
when(admin.hasPermission(ADMIN)).thenReturn(true);
Player ordinary = mock(Player.class);
when(ordinary.hasPermission(ADMIN)).thenReturn(false);
assertTrue(handler.hasPerm(admin));
assertFalse(handler.hasPerm(ordinary));
}
@Test
void registeredHandlersRouteToTheExperimentalManager() throws Exception {
VotingPluginMain plugin = commandLoaderFixture();
ExperimentalGUIManager manager = mock(ExperimentalGUIManager.class);
when(plugin.getExperimentalGUIManager()).thenReturn(manager);
invokeAdminRegistration(plugin);
Player player = mock(Player.class);
CommandSender console = mock(CommandSender.class);
find(plugin.getAdminVoteCommand(), "testdialoggui").execute(player, new String[] { "testdialoggui" });
verify(manager).open(player, ExperimentalGUIType.NATIVE_DIALOG);
find(plugin.getAdminVoteCommand(), "TestGUI", "status").execute(console,
new String[] { "TestGUI", "status" });
verify(manager).control(console, "status");
find(plugin.getAdminVoteCommand(), "testterminalgui", "inspect").execute(console,
new String[] { "testterminalgui", "inspect" });
verify(manager).terminalControl(console, "inspect");
}
@Test
void tabCompletionComesFromRegisteredHandlersAndPreservesExistingAdminCommands() throws Exception {
VotingPluginMain plugin = commandLoaderFixture();
invokeAdminRegistration(plugin);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission(ADMIN)).thenReturn(true);
ArrayList<CommandHandler> handlers = new ArrayList<>(plugin.getAdminVoteCommand());
List<String> roots = AdvancedCoreTabCompleteHandler.getInstance()
.getTabCompleteOptions(handlers, sender, new String[] { "test" }, 0);
assertTrue(roots.stream().anyMatch(value -> value.equalsIgnoreCase("testhologram")));
assertTrue(roots.stream().anyMatch(value -> value.equalsIgnoreCase("testdialoggui")));
assertTrue(roots.stream().anyMatch(value -> value.equalsIgnoreCase("testgui")));
assertTrue(roots.stream().anyMatch(value -> value.equalsIgnoreCase("testterminalgui")));
assertTrue(roots.stream().anyMatch(value -> value.equalsIgnoreCase("vote")),
"the existing /av Vote command must remain available");
assertNotNull(find(handlers, "GUI"), "existing admin GUI command");
assertNotNull(find(handlers, "Vote"), "existing admin vote command");
List<String> controls = AdvancedCoreTabCompleteHandler.getInstance()
.getTabCompleteOptions(handlers, sender, new String[] { "testgui", "" }, 1);
assertEquals(new HashSet<>(Arrays.asList("list", "close", "status", "cleanup")), new HashSet<>(controls));
}
@Test
void existingAdminSiteAndEditorHandlersKeepTheirProductionTargetsAndPermissions() throws Exception {
VotingPluginMain plugin = commandLoaderFixture();
invokeAdminRegistration(plugin);
Player player = mock(Player.class);
var site = mock(com.bencodez.votingplugin.votesites.VoteSite.class);
when(plugin.getVoteSiteManager().getVoteSite("Example", false)).thenReturn(site);
var list = find(plugin.getAdminVoteCommand(), "Sites");
var editor = find(plugin.getAdminVoteCommand(), "Sites", "(sitename)");
assertEquals("VotingPlugin.Commands.AdminVote.Sites|" + ADMIN, list.getPerm());
assertEquals("VotingPlugin.Commands.AdminVote.Sites.Site|" + ADMIN, editor.getPerm());
try (var guis = org.mockito.Mockito.mockConstruction(com.bencodez.votingplugin.commands.gui.AdminGUI.class)) {
list.execute(player, new String[]{"Sites"});
editor.execute(player, new String[]{"Sites", "Example"});
assertEquals(2, guis.constructed().size());
verify(guis.constructed().get(0)).openAdminGUIVoteSites(player);
verify(guis.constructed().get(1)).openAdminGUIVoteSiteSite(player, site);
org.mockito.Mockito.verify(plugin, org.mockito.Mockito.never()).getExperimentalGUIManager();
}
}
@Test
void existingAdminGuiStillUsesAdvancedCoreMenuAndItsPermission() throws Exception {
VotingPluginMain plugin = commandLoaderFixture();
invokeAdminRegistration(plugin);
var handler = find(plugin.getAdminVoteCommand(), "GUI");
assertEquals("VotingPlugin.Commands.AdminVote.GUI|" + ADMIN, handler.getPerm());
Player player = mock(Player.class);
try (var statics = org.mockito.Mockito.mockStatic(com.bencodez.advancedcore.command.gui.AdminGUI.class)) {
var gui = mock(com.bencodez.advancedcore.command.gui.AdminGUI.class);
statics.when(com.bencodez.advancedcore.command.gui.AdminGUI::getInstance).thenReturn(gui);
handler.execute(player, new String[]{"GUI"});
verify(gui).openGUI(player);
org.mockito.Mockito.verify(plugin, org.mockito.Mockito.never()).getExperimentalGUIManager();
}
}
private static VotingPluginMain commandLoaderFixture() {
VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
when(plugin.getOptions().isMultiplePermissionChecks()).thenReturn(true);
AtomicReference<ArrayList<CommandHandler>> handlers = new AtomicReference<>();
doAnswer(invocation -> {
handlers.set(invocation.getArgument(0));
return null;
}).when(plugin).setAdminVoteCommand(org.mockito.ArgumentMatchers.any(ArrayList.class));
when(plugin.getAdminVoteCommand()).thenAnswer(invocation -> handlers.get());
return plugin;
}
private static void invokeAdminRegistration(VotingPluginMain plugin) throws Exception {
Method method = CommandLoader.class.getDeclaredMethod("loadAdminVoteCommand");
method.setAccessible(true);
// AdvancedCore's shared singleton is initialized by plugin startup. This
// fixture isolates generic AC registrations while exercising real VP handlers.
try (var coreStatic = org.mockito.Mockito.mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) {
var core = mock(com.bencodez.advancedcore.command.CommandLoader.class);
coreStatic.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(core);
when(core.getBasicAdminCommands("VotingPlugin")).thenReturn(new ArrayList<>());
method.invoke(new CommandLoader(plugin));
}
}
private static CommandHandler find(List<CommandHandler> handlers, String... expected) {
return handlers.stream().filter(handler -> sameArgs(handler.getArgs(), expected)
|| (expected.length == 1 && handler.isCommand(expected[0]))).findFirst().orElse(null);
}
private static boolean sameArgs(String[] actual, String[] expected) {
if (actual.length != expected.length) return false;
for (int index = 0; index < actual.length; index++) {
if (!actual[index].equalsIgnoreCase(expected[index])) return false;
}
return true;
}
}
@@ -0,0 +1,88 @@
package com.bencodez.votingplugin.experimental;
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.Queue;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiConsumer;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.hologram.HologramMenuScheduler;
class ExperimentalDialogsTest {
static final class Backend implements ExperimentalDialogs.Backend {
BiConsumer<UUID,String> callback;
int shows, releases, closes;
@Override public Runnable show(Player player, String title, String body, List<ExperimentalDialogs.Button> buttons,
BiConsumer<UUID,String> callback) { this.callback = callback; shows++; return () -> releases++; }
@Override public void close(Player player) { closes++; }
}
static final class Fixture {
final UUID id = UUID.randomUUID();
final AtomicLong clock = new AtomicLong();
final ExperimentalSessions sessions = new ExperimentalSessions(clock::get, failure -> fail(failure));
final VotingPluginMain plugin = mock(VotingPluginMain.class);
final Player player = mock(Player.class);
final HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
final Queue<Runnable> owner = new ArrayDeque<>();
final Backend backend = new Backend();
final ExperimentalDialogs dialogs = new ExperimentalDialogs(plugin, scheduler, sessions, backend);
final AtomicInteger actions = new AtomicInteger();
ExperimentalSessions.Session session = sessions.open(id, ExperimentalGUIType.NATIVE_DIALOG, 64, 60);
Fixture() {
when(plugin.isEnabled()).thenReturn(true);
when(player.isOnline()).thenReturn(true);
when(player.hasPermission(anyString())).thenReturn(true);
doAnswer(inv -> { owner.add(inv.getArgument(1)); return null; }).when(scheduler).player(any(), any(), any());
}
void render() { dialogs.render(session, player, "Voting", "Streak", List.of(
new ExperimentalDialogs.Button("Vote", "Available", "https://example.test", null),
new ExperimentalDialogs.Button("Next", "Navigation", null, "next")), action -> actions.incrementAndGet()); }
void drain() { while (!owner.isEmpty()) owner.remove().run(); }
}
@Test void unrelatedOwnerIsIgnoredAndCorrectCallbackRunsOnceOnPlayerScheduler() {
Fixture f = new Fixture(); f.render();
f.backend.callback.accept(UUID.randomUUID(), "next");
assertTrue(f.owner.isEmpty());
f.backend.callback.accept(f.id, "next"); f.backend.callback.accept(f.id, "next");
assertEquals(0, f.actions.get()); f.drain(); assertEquals(1, f.actions.get());
}
@Test void replacementFrameAndSessionFenceQueuedCallbacksWithoutClearingUnownedDialogs() {
Fixture f = new Fixture(); f.render();
var old = f.backend.callback; old.accept(f.id, "next");
f.render(); assertEquals(1, f.backend.releases);
f.drain(); assertEquals(0, f.actions.get()); old.accept(f.id, "next"); assertTrue(f.owner.isEmpty());
f.session = f.sessions.open(f.id, ExperimentalGUIType.NATIVE_DIALOG, 64, 60);
f.render(); f.drain(); assertEquals(0, f.backend.closes);
f.sessions.close(f.session); f.drain(); assertEquals(0, f.backend.closes);
assertEquals(3, f.backend.releases);
}
@Test void revocationTimeoutReloadAndRepeatedCleanupCannotExecuteActions() {
Fixture f = new Fixture(); f.render();
f.backend.callback.accept(f.id, "next");
when(f.player.hasPermission(anyString())).thenReturn(false);
f.drain(); assertEquals(0, f.actions.get());
when(f.player.hasPermission(anyString())).thenReturn(true);
f.clock.set(60_000_000_000L); f.backend.callback.accept(f.id, "next"); assertTrue(f.owner.isEmpty());
f.sessions.clear(false); f.sessions.clear(false); f.drain();
assertEquals(1, f.backend.releases); assertEquals(0, f.backend.closes);
f.session = f.sessions.open(f.id, ExperimentalGUIType.NATIVE_DIALOG, 64, 60); f.render();
f.sessions.clear(true); f.drain(); assertEquals(2, f.backend.releases);
}
@Test void urlAndPresentationBoundsFailClosed() {
assertThrows(IllegalArgumentException.class, () -> new ExperimentalDialogs.Button("x", "x", "javascript:alert(1)", null));
assertThrows(IllegalArgumentException.class, () -> new ExperimentalDialogs.Button("x", "x", null, null));
assertThrows(IllegalArgumentException.class, () -> new ExperimentalDialogs.Button("x", "x", "https://example.test", "next"));
Fixture f = new Fixture();
var button = new ExperimentalDialogs.Button("x", "x", null, "next");
assertThrows(IllegalArgumentException.class, () -> f.dialogs.render(f.session, f.player, "x", "x",
java.util.Collections.nCopies(9, button), action -> fail()));
assertEquals(0, f.backend.shows);
}
}
@@ -0,0 +1,256 @@
package com.bencodez.votingplugin.experimental;
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.Queue;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import java.util.function.Consumer;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Interaction;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.hologram.HologramMenuScheduler;
class ExperimentalDisplaysTest {
private static final class Fixture implements AutoCloseable {
final VotingPluginMain plugin = mock(VotingPluginMain.class);
final HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
final ExperimentalSessions sessions = new ExperimentalSessions(System::nanoTime, failure -> fail(failure));
final ExperimentalDisplays displays = new ExperimentalDisplays(plugin, scheduler, sessions);
final UUID owner = UUID.randomUUID();
final UUID worldId = UUID.randomUUID();
final World world = mock(World.class);
final Player player = mock(Player.class);
final TextDisplay text = mock(TextDisplay.class);
final Interaction interaction = mock(Interaction.class);
final Location anchor = new Location(world, 8, 64, 8);
final Queue<Runnable> players = new ArrayDeque<>(), regions = new ArrayDeque<>();
final AtomicInteger spawns = new AtomicInteger(), selections = new AtomicInteger();
final AtomicReference<ExperimentalDisplays.Target> selected = new AtomicReference<>();
final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
boolean playerOwner, regionOwner;
boolean owns = true;
ExperimentalSessions.Session session;
Fixture() {
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(player.getUniqueId()).thenReturn(owner);
when(player.isOnline()).thenAnswer(i -> { assertTrue(playerOwner); return true; });
when(player.hasPermission(anyString())).thenAnswer(i -> { assertTrue(playerOwner); return true; });
when(player.getWorld()).thenAnswer(i -> { assertTrue(playerOwner); return world; });
when(player.getEyeLocation()).thenAnswer(i -> { assertTrue(playerOwner); return anchor; });
when(world.getUID()).thenReturn(worldId);
when(world.isChunkLoaded(anyInt(), anyInt())).thenAnswer(i -> { assertTrue(regionOwner || playerOwner); return true; });
bukkit.when(() -> Bukkit.getWorld(worldId)).thenReturn(world);
when(scheduler.owns(any())).thenAnswer(i -> owns && (regionOwner || playerOwner));
doAnswer(i -> { players.add(i.getArgument(1)); return null; }).when(scheduler).player(any(), any(), any());
doAnswer(i -> { regions.add(i.getArgument(1)); return null; }).when(scheduler).region(any(), any());
when(text.getUniqueId()).thenReturn(UUID.randomUUID());
when(interaction.getUniqueId()).thenReturn(UUID.randomUUID());
when(text.isValid()).thenReturn(true);
when(interaction.isValid()).thenReturn(true);
doAnswer(i -> { assertTrue(regionOwner || playerOwner); return null; }).when(text).remove();
doAnswer(i -> { assertTrue(regionOwner || playerOwner); return null; }).when(interaction).remove();
doAnswer(i -> {
Class<?> type = i.getArgument(1);
Consumer<Entity> init = i.getArgument(2);
Entity entity = type == TextDisplay.class ? text : interaction;
spawns.incrementAndGet(); init.accept(entity); return entity;
}).when(world).spawn(any(Location.class), any(Class.class), any());
session = sessions.open(owner, ExperimentalGUIType.RADIAL, 64, 60);
}
void open(List<ExperimentalDisplays.Target> targets) {
displays.render(session, player, anchor, targets, target -> { assertTrue(playerOwner); selected.set(target); selections.incrementAndGet(); });
}
void playerNext() { playerOwner = true; try { players.remove().run(); } finally { playerOwner = false; } }
void regionNext() { regionOwner = true; try { regions.remove().run(); } finally { regionOwner = false; } }
void spawnFrame() { open(List.of(new ExperimentalDisplays.Target("site-a", "Site A", null, 0, 0, ExperimentalDisplays.Action.SITE))); playerNext(); regionNext(); while (!players.isEmpty()) playerNext(); }
PlayerInteractEntityEvent click(Entity entity) {
PlayerInteractEntityEvent event = mock(PlayerInteractEntityEvent.class);
when(event.getPlayer()).thenReturn(player); when(event.getRightClicked()).thenReturn(entity); when(event.getHand()).thenReturn(EquipmentSlot.HAND); return event;
}
@Override public void close() { bukkit.close(); }
}
@Test void rendersBoundedOwnedFrameAndDispatchesStableTargetOnOwnerScheduler() {
try (Fixture f = new Fixture()) {
f.spawnFrame();
assertEquals(2, f.spawns.get());
assertEquals(2, f.displays.entityCount());
PlayerInteractEntityEvent click = f.click(f.interaction);
f.displays.interact(click);
verify(click).setCancelled(true);
assertEquals(0, f.selections.get());
f.playerNext();
assertEquals("site-a", f.selected.get().id());
}
}
@Test void fullTenTargetFrameHasThirtyEntitiesAndCleanupRemovesEveryOwnedEntity() {
try (Fixture f = new Fixture()) {
f.bukkit.when(Bukkit::getItemFactory).thenReturn(mock(org.bukkit.inventory.ItemFactory.class));
java.util.List<Entity> owned = new java.util.ArrayList<>();
doAnswer(inv -> {
assertTrue(f.regionOwner);
Class<? extends Entity> type = inv.getArgument(1);
Entity entity = mock(type);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
when(entity.isValid()).thenReturn(true);
Consumer<Entity> initializer = inv.getArgument(2);
owned.add(entity); initializer.accept(entity); return entity;
}).when(f.world).spawn(any(Location.class), any(Class.class), any());
java.util.List<ExperimentalDisplays.Target> targets = new java.util.ArrayList<>();
for (int i = 0; i < 10; i++) targets.add(new ExperimentalDisplays.Target("target" + i, "Site " + i,
new org.bukkit.inventory.ItemStack(org.bukkit.Material.CHEST), 0, 0, ExperimentalDisplays.Action.INFO));
f.open(targets); f.playerNext(); f.regionNext(); while (!f.players.isEmpty()) f.playerNext();
assertEquals(30, owned.size());
assertEquals(30, f.displays.entityCount());
f.displays.close(f.session); f.regionNext();
assertEquals(0, f.displays.entityCount());
for (Entity entity : owned) verify(entity).remove();
}
}
@Test void oversizedFrameIsRejectedBeforeSchedulingOrSpawning() {
try (Fixture f = new Fixture()) {
var target = new ExperimentalDisplays.Target("x", "x", null, 0, 0, ExperimentalDisplays.Action.INFO);
f.open(java.util.Collections.nCopies(11, target));
assertTrue(f.players.isEmpty());
assertTrue(f.regions.isEmpty());
assertFalse(f.sessions.current(f.session));
assertEquals(0, f.spawns.get());
}
}
@Test void showcaseRotationUsesRegionOwnerAndRetiresOnOwnershipLoss() {
try (Fixture f = new Fixture()) {
f.bukkit.when(Bukkit::getItemFactory).thenReturn(mock(org.bukkit.inventory.ItemFactory.class));
var item = mock(org.bukkit.entity.ItemDisplay.class);
when(item.getUniqueId()).thenReturn(UUID.randomUUID()); when(item.isValid()).thenReturn(true);
doAnswer(inv -> { assertTrue(f.regionOwner); return null; }).when(item).setRotation(anyFloat(), anyFloat());
doAnswer(inv -> {
Class<?> type = inv.getArgument(1);
Entity entity = type == TextDisplay.class ? f.text : type == org.bukkit.entity.ItemDisplay.class ? item : f.interaction;
Consumer<Entity> initializer = inv.getArgument(2); initializer.accept(entity); return entity;
}).when(f.world).spawn(any(Location.class), any(Class.class), any());
f.session = f.sessions.open(f.owner, ExperimentalGUIType.REWARD_SHOWCASE, 64, 60);
f.open(List.of(new ExperimentalDisplays.Target("reward", "Reward", new org.bukkit.inventory.ItemStack(org.bukkit.Material.CHEST), 0, 0, ExperimentalDisplays.Action.INFO)));
f.playerNext(); f.regionNext(); while (!f.players.isEmpty()) f.playerNext();
f.displays.rotateShowcase(f.session, 1);
verify(item, never()).setRotation(anyFloat(), anyFloat());
f.regionNext(); verify(item).setRotation(15f, 0f);
f.owns = false;
f.displays.rotateShowcase(f.session, 2); f.regionNext();
assertFalse(f.sessions.current(f.session));
while (!f.regions.isEmpty()) f.regionNext(); // Retained cleanup fails safe while unowned.
f.owns = true; f.displays.retryCleanup(); f.regionNext();
assertEquals(0, f.displays.entityCount());
f.displays.rotateShowcase(f.session, 3);
assertTrue(f.regions.isEmpty());
verify(item, times(1)).setRotation(anyFloat(), anyFloat());
}
}
@Test void identicalFrameDoesNotRespawnOrRegisterAnotherCleanup() {
try (Fixture f = new Fixture()) {
List<ExperimentalDisplays.Target> targets = List.of(new ExperimentalDisplays.Target("site-a", "Site A", null, 0, 0, ExperimentalDisplays.Action.SITE));
f.open(targets); f.open(targets);
assertEquals(1, f.players.size());
f.playerNext(); f.regionNext(); while (!f.players.isEmpty()) f.playerNext();
assertEquals(2, f.spawns.get());
}
}
@Test void changedSiteKeyReplacesFrameEvenWhenVisibleLabelAndTargetIdStayTheSame() {
try (Fixture f = new Fixture()) {
f.open(List.of(new ExperimentalDisplays.Target("site-0", "Site", null, 0, 0,
ExperimentalDisplays.Action.SITE, "x".repeat(2047) + "A")));
f.playerNext(); f.regionNext(); while (!f.players.isEmpty()) f.playerNext();
assertEquals(2, f.spawns.get());
f.open(List.of(new ExperimentalDisplays.Target("site-0", "Site", null, 0, 0,
ExperimentalDisplays.Action.SITE, "x".repeat(2047) + "B")));
while (!f.players.isEmpty()) f.playerNext();
while (!f.regions.isEmpty()) f.regionNext();
while (!f.players.isEmpty()) f.playerNext();
assertEquals(4, f.spawns.get());
assertEquals(2, f.displays.entityCount());
}
}
@Test void completeFootprintIsPreflightedBeforeAnySpawn() {
try (Fixture f = new Fixture()) {
f.owns = false;
f.open(List.of(new ExperimentalDisplays.Target("site-a", "Site A", null, 0, 0, ExperimentalDisplays.Action.SITE)));
f.playerNext();
assertEquals(1, f.regions.size());
f.regions.remove().run();
assertEquals(0, f.spawns.get());
}
}
@Test void foreignPlayerAndOffhandCannotDispatchTarget() {
try (Fixture f = new Fixture()) {
f.spawnFrame();
Player stranger = mock(Player.class);
when(stranger.getUniqueId()).thenReturn(UUID.randomUUID());
PlayerInteractEntityEvent foreign = f.click(f.interaction); when(foreign.getPlayer()).thenReturn(stranger); f.displays.interact(foreign);
PlayerInteractEntityEvent offhand = f.click(f.interaction); when(offhand.getHand()).thenReturn(EquipmentSlot.OFF_HAND); f.displays.interact(offhand);
assertEquals(0, f.selections.get());
verify(foreign).setCancelled(true); verify(offhand).setCancelled(true);
}
}
@Test void failedRegionCleanupIsRetainedAndRetryable() {
try (Fixture f = new Fixture()) {
f.spawnFrame();
f.owns = false;
doAnswer(i -> { if (!f.owns) throw new IllegalStateException("region rejected"); f.regions.add(i.getArgument(1)); return null; })
.when(f.scheduler).region(any(), any());
f.displays.close(f.session);
assertEquals(2, f.displays.entityCount());
f.owns = true;
f.displays.retryCleanup();
f.regionNext();
assertEquals(0, f.displays.entityCount());
}
}
@Test void partialSpawnIsOwnedBeforeSetterFailureAndCanBeRemoved() {
try (Fixture f = new Fixture()) {
doAnswer(i -> { f.owns = false; throw new IllegalStateException("text configuration failed"); })
.when(f.text).setText(anyString());
f.open(List.of(new ExperimentalDisplays.Target("site-a", "Site A", null, 0, 0, ExperimentalDisplays.Action.SITE)));
f.playerNext();
f.regionNext();
assertEquals(1, f.displays.entityCount());
f.owns = true;
f.regionNext();
assertEquals(0, f.displays.entityCount());
}
}
@Test void targetBoundsAndCapabilityProbeAreSafe() {
assertThrows(IllegalArgumentException.class, () -> new ExperimentalDisplays.Target("x", "x", null, 9, 0, ExperimentalDisplays.Action.SITE));
assertTrue(ExperimentalDisplays.supported());
}
}
@@ -0,0 +1,625 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemFactory;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.hologram.HologramMenuScheduler;
import com.bencodez.votingplugin.user.VotingPluginUser;
import com.bencodez.votingplugin.votesites.VoteSite;
import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.api.chat.ClickEvent;
class ExperimentalGUIManagerTest {
@Test void invalidSettingsListReportsValidationToConsoleWithoutListingOrCreatingResources() {
try (Fixture f = new Fixture()) {
f.config.set("Experimental.VoteGUIs.MaxActiveSessions", 0);
var console = mock(org.bukkit.command.ConsoleCommandSender.class);
when(console.hasPermission("VotingPlugin.Commands.AdminVote.TestGUI")).thenReturn(true);
assertDoesNotThrow(() -> f.manager.control(console, "list"));
verify(console, times(1)).sendMessage(contains("Invalid experimental GUI settings: MaxSessions 1..64"));
assertTrue(f.owner.isEmpty());
assertTrue(f.storage.isEmpty());
assertTrue(f.created.isEmpty());
}
}
@Test void invalidSettingsListReportsValidationOnPlayerOwnerWithoutCreatingResources() {
try (Fixture f = new Fixture()) {
f.config.set("Experimental.VoteGUIs.SitesPerPage", 6);
f.manager.control(f.player, "list");
verify(f.player, never()).sendMessage(anyString());
assertDoesNotThrow(f::owner);
verify(f.player, times(1)).sendMessage(contains("Invalid experimental GUI settings:"));
assertTrue(f.storage.isEmpty());
assertTrue(f.created.isEmpty());
}
}
@Test void validSettingsListStillReportsEveryStyle() {
try (Fixture f = new Fixture()) {
var console = mock(org.bukkit.command.ConsoleCommandSender.class);
when(console.hasPermission("VotingPlugin.Commands.AdminVote.TestGUI")).thenReturn(true);
f.manager.control(console, "list");
verify(console, times(ExperimentalGUIType.values().length)).sendMessage(anyString());
verify(console, never()).sendMessage(contains("Invalid experimental GUI settings:"));
assertTrue(f.storage.isEmpty());
}
}
static final class Fixture implements AutoCloseable {
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
final HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
final Player player = mock(Player.class);
final UUID id = UUID.randomUUID();
final VotingPluginUser user = mock(VotingPluginUser.class);
final YamlConfiguration config = new YamlConfiguration();
final Queue<Runnable> owner = new ArrayDeque<>();
final Queue<Runnable> storage = new ArrayDeque<>();
final List<Runnable> watches = new ArrayList<>();
final List<Inventory> created = new ArrayList<>();
final AtomicReference<Inventory> top = new AtomicReference<>(mock(Inventory.class));
final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
final ExperimentalGUIManager manager;
final Player.Spigot chat = mock(Player.Spigot.class);
Fixture() {
config.set("Experimental.VoteGUIs.Enabled", true);
when(plugin.getConfigFile().getData()).thenReturn(config);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(plugin.getUser(id)).thenReturn(user);
when(player.getUniqueId()).thenReturn(id);
when(player.isOnline()).thenReturn(true);
when(player.hasPermission(anyString())).thenReturn(true);
World world = mock(World.class);
when(player.getWorld()).thenReturn(world);
when(player.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0));
InventoryView view = mock(InventoryView.class);
when(view.getTopInventory()).thenAnswer(inv -> top.get());
when(player.getOpenInventory()).thenReturn(view);
when(player.spigot()).thenReturn(chat);
doAnswer(inv -> { top.set(inv.getArgument(0)); return view; }).when(player).openInventory(any(Inventory.class));
doAnswer(inv -> { top.set(mock(Inventory.class)); return null; }).when(player).closeInventory();
doAnswer(inv -> { owner.add(inv.getArgument(1)); return null; }).when(scheduler).player(any(), any(), any());
when(scheduler.watchPlayer(any(), any(), any())).thenAnswer(inv -> {
watches.add(inv.getArgument(1));
return (Runnable) () -> {};
});
var timer = plugin.getUserManager().getDataManager().getTimer();
doAnswer(inv -> { storage.add(inv.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class));
when(plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>());
bukkit.when(Bukkit::getItemFactory).thenReturn(mock(ItemFactory.class));
bukkit.when(() -> Bukkit.createInventory(any(InventoryHolder.class), eq(54), anyString())).thenAnswer(inv -> {
Inventory inventory = mock(Inventory.class);
InventoryHolder holder = inv.getArgument(0);
when(inventory.getHolder()).thenReturn(holder);
when(inventory.getSize()).thenReturn(54);
created.add(inventory);
return inventory;
});
manager = new ExperimentalGUIManager(plugin, scheduler);
}
void owner() { while (!owner.isEmpty()) owner.remove().run(); }
void open() { manager.open(player, ExperimentalGUIType.ANIMATED_INVENTORY); owner(); }
void snapshot() { storage.remove().run(); owner(); }
VoteSite site(String key, String url) {
VoteSite site = mock(VoteSite.class, RETURNS_DEEP_STUBS);
when(site.getKey()).thenReturn(key);
when(site.getDisplayName()).thenReturn(key);
when(site.getPermissionToView()).thenReturn("");
when(site.getVoteURL(false)).thenReturn(url);
when(user.canVoteSite(site)).thenReturn(true);
return site;
}
void click(int slot) {
InventoryClickEvent event = mock(InventoryClickEvent.class);
InventoryView view = player.getOpenInventory();
when(event.getView()).thenReturn(view);
when(event.getWhoClicked()).thenReturn(player);
when(event.getClick()).thenReturn(ClickType.LEFT);
when(event.getRawSlot()).thenReturn(slot);
((ExperimentalInventoryListener) manager.inventoryListener()).click(event);
}
@Override public void close() { manager.shutdown(); owner(); bukkit.close(); }
}
@Test void foliaTranslationRetiresNativeMenuWithoutTeleportEventAndKeepsProductionInventory() {
try (Fixture f = new Fixture()) {
when(f.scheduler.regionized()).thenReturn(true);
World world = f.player.getWorld();
when(f.player.getLocation()).thenReturn(new Location(world, 8, 64, 8));
Inventory production = f.top.get();
f.manager.open(f.player, ExperimentalGUIType.HOLOGRAM); f.owner();
assertEquals(1, f.watches.size());
when(f.player.getLocation()).thenReturn(new Location(world, 8, 64, 7));
f.watches.getFirst().run(); f.owner();
verify(f.plugin.getHologramVoteMenu()).close(eq(f.id), any());
assertSame(production, f.top.get());
verify(f.player, never()).closeInventory();
}
}
@Test void foliaRotationDoesNotCloseMenuAndOldGuardCannotRetireReplacement() {
try (Fixture f = new Fixture()) {
when(f.scheduler.regionized()).thenReturn(true);
World world = f.player.getWorld();
when(f.player.getLocation()).thenReturn(new Location(world, 8, 64, 8));
f.manager.open(f.player, ExperimentalGUIType.HOLOGRAM); f.owner();
when(f.player.getLocation()).thenReturn(new Location(world, 8, 64, 8, 90, 45));
f.watches.getFirst().run(); f.owner();
verify(f.plugin.getHologramVoteMenu(), never()).close(eq(f.id), any());
f.manager.open(f.player, ExperimentalGUIType.HOLOGRAM); f.owner();
clearInvocations(f.plugin.getHologramVoteMenu());
when(f.player.getLocation()).thenReturn(new Location(world, 8, 64, 7));
f.watches.getFirst().run(); f.owner();
verify(f.plugin.getHologramVoteMenu(), never()).close(eq(f.id), any());
f.watches.get(1).run(); f.owner();
verify(f.plugin.getHologramVoteMenu()).close(eq(f.id), any());
}
}
@Test void foliaInventoryStylesFailBeforeAdmissionAndNeverTouchProductionInventory() {
try (Fixture f = new Fixture()) {
when(f.scheduler.regionized()).thenReturn(true);
Inventory production = f.top.get();
for (var type : List.of(ExperimentalGUIType.ANIMATED_INVENTORY,
ExperimentalGUIType.STREAK_TRACK, ExperimentalGUIType.NPC)) {
f.manager.open(f.player, type); f.owner();
}
assertSame(production, f.top.get());
assertTrue(f.created.isEmpty());
assertTrue(f.storage.isEmpty());
assertTrue(f.watches.isEmpty());
verify(f.player, never()).openInventory(any(Inventory.class));
verify(f.player, never()).closeInventory();
verify(f.plugin, never()).getUser(any(UUID.class));
}
}
@Test void foliaHologramGetsTheSameOwnerScheduledTranslationGuard() {
try (Fixture f = new Fixture()) {
when(f.scheduler.regionized()).thenReturn(true);
World world = f.player.getWorld();
when(f.player.getLocation()).thenReturn(new Location(world, 8, 64, 8));
f.manager.open(f.player, ExperimentalGUIType.HOLOGRAM); f.owner();
assertEquals(1, f.watches.size());
when(f.player.getLocation()).thenReturn(new Location(world, 8, 64, 7));
f.watches.getFirst().run(); f.owner();
verify(f.plugin.getHologramVoteMenu()).close(eq(f.id), any());
verify(f.player, never()).closeInventory();
}
}
@Test void creationUsesWorkerSnapshotAndOnlyOwnerSchedulerOpensInventory() {
try (Fixture f = new Fixture()) {
f.open();
assertEquals(1, f.storage.size());
verify(f.plugin, never()).getUser(f.id);
verify(f.player, never()).openInventory(any(Inventory.class));
f.storage.remove().run();
verify(f.plugin).getUser(f.id);
verify(f.player, never()).openInventory(any(Inventory.class));
f.owner();
assertEquals(1, f.created.size());
assertTrue(f.top.get().getHolder() instanceof ExperimentalInventory);
verify(f.user).getDayVoteStreak();
verify(f.user).getWeekVoteStreak();
verify(f.user).getMonthVoteStreak();
verify(f.user).getTotal(com.bencodez.votingplugin.topvoter.TopVoter.AllTime);
verifyNoMoreInteractions(f.user);
}
}
@Test void replacingBeforeSnapshotDoesNotOpenStaleViewOrCloseSuccessor() {
try (Fixture f = new Fixture()) {
f.open();
f.open();
assertEquals(2, f.storage.size());
f.snapshot();
assertTrue(f.created.isEmpty());
f.snapshot();
assertEquals(1, f.created.size());
f.watches.getFirst().run();
f.owner();
assertSame(f.created.getFirst(), f.top.get());
}
}
@Test void correctPageAndLatestConfiguredUrlAreSelectedWithoutRewards() {
try (Fixture f = new Fixture()) {
List<VoteSite> sites = new ArrayList<>();
for (int i = 0; i < 6; i++) sites.add(f.site("site" + i, "https://example.test/" + i));
when(f.plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>(sites));
f.open(); f.snapshot();
f.click(53); f.owner();
when(sites.get(5).getVoteURL(false)).thenReturn("https://example.test/changed");
f.click(20); f.owner();
var captor = org.mockito.ArgumentCaptor.forClass(BaseComponent.class);
verify(f.chat).sendMessage(captor.capture());
assertEquals(ClickEvent.Action.OPEN_URL, captor.getValue().getClickEvent().getAction());
assertEquals("https://example.test/changed", captor.getValue().getClickEvent().getValue());
verify(f.user, times(6)).canVoteSite(any());
verify(f.user, times(6)).voteNextDurationTime(any());
verify(f.user).getDayVoteStreak();
verify(f.user).getWeekVoteStreak();
verify(f.user).getMonthVoteStreak();
verify(f.user).getTotal(com.bencodez.votingplugin.topvoter.TopVoter.AllTime);
verifyNoMoreInteractions(f.user);
}
}
@Test void invalidUrlAndPermissionChangesCannotSendLink() {
try (Fixture f = new Fixture()) {
VoteSite site = f.site("bad", "javascript:alert(1)");
when(f.plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>(List.of(site)));
f.open(); f.snapshot();
f.click(20); f.owner();
verify(f.chat, never()).sendMessage(any(BaseComponent.class));
verify(f.player).sendMessage(contains("no valid HTTP/HTTPS"));
when(site.getPermissionToView()).thenReturn("test.visible");
when(f.player.hasPermission("test.visible")).thenReturn(false);
when(site.getVoteURL(false)).thenReturn("https://example.test");
f.click(20); f.owner();
verify(f.chat, never()).sendMessage(any(BaseComponent.class));
}
}
@Test void disabledFeatureAndDeniedPermissionLeaveProductionInventoryUntouched() {
try (Fixture f = new Fixture()) {
Inventory production = f.top.get();
f.config.set("Experimental.VoteGUIs.Enabled", false);
f.open();
assertTrue(f.storage.isEmpty());
assertSame(production, f.top.get());
f.config.set("Experimental.VoteGUIs.Enabled", true);
when(f.player.hasPermission(anyString())).thenReturn(false);
f.open();
assertTrue(f.storage.isEmpty());
assertSame(production, f.top.get());
verify(f.player, never()).closeInventory();
}
}
@Test void allEightDisabledCommandsHaveNoEffectOnExistingProductionInventoryOrUserData() {
try (Fixture f = new Fixture()) {
Inventory production = f.top.get();
f.config.set("Experimental.VoteGUIs.Enabled", false);
f.config.set("Experimental.HologramVoteGUI.Enabled", false);
for (ExperimentalGUIType type : ExperimentalGUIType.values()) {
f.manager.open(f.player, type); f.owner();
assertSame(production, f.top.get(), type.name());
}
verify(f.plugin, never()).getUser(any(UUID.class));
verify(f.player, never()).openInventory(any(Inventory.class));
verify(f.player, never()).closeInventory();
assertTrue(f.storage.isEmpty());
}
}
@Test void invalidHologramConfigurationDoesNotPreventInventoryOpening() {
try (Fixture f = new Fixture()) {
f.config.set("Experimental.HologramVoteGUI.Distance", 999);
f.open(); f.snapshot();
assertEquals(1, f.created.size());
assertSame(f.created.getFirst(), f.top.get());
}
}
@Test void reloadRetiresPendingSnapshotAndAnimationCannotReopenIt() {
try (Fixture f = new Fixture()) {
f.open();
f.manager.clear(); f.owner();
f.snapshot();
f.watches.getFirst().run(); f.owner();
assertTrue(f.created.isEmpty());
f.open(); f.snapshot();
f.manager.clear(); f.owner();
verify(f.player).closeInventory();
}
}
@Test void longValidSiteKeysRemainDistinctAndSelectCorrectUrlsInBothDisplayStyles() {
for (ExperimentalGUIType type : List.of(ExperimentalGUIType.RADIAL, ExperimentalGUIType.REWARD_SHOWCASE)) {
AtomicReference<List<ExperimentalDisplays.Target>> targets = new AtomicReference<>();
AtomicReference<java.util.function.Consumer<ExperimentalDisplays.Target>> selection = new AtomicReference<>();
try (var renderers = mockConstruction(ExperimentalDisplays.class, (renderer, context) -> {
doAnswer(inv -> { targets.set(inv.getArgument(3)); selection.set(inv.getArgument(4)); return null; })
.when(renderer).render(any(), any(), any(), anyList(), any());
}); Fixture f = new Fixture()) {
String firstKey = "x".repeat(2047) + "A";
String secondKey = "x".repeat(2047) + "B";
when(f.plugin.getSpecialRewardsConfig().getData()).thenReturn(new YamlConfiguration());
var sites = new ArrayList<>(List.of(f.site(firstKey, "https://example.test/first"),
f.site(secondKey, "https://example.test/second")));
when(f.plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(sites);
Inventory production = f.top.get();
f.manager.open(f.player, type); f.owner(); f.snapshot();
var siteTargets = targets.get().stream().filter(t -> t.action() == ExperimentalDisplays.Action.SITE).toList();
assertEquals(2, siteTargets.size(), type.name());
assertTrue(siteTargets.stream().allMatch(t -> t.id().length() <= 128));
assertNotEquals(siteTargets.get(0).id(), siteTargets.get(1).id());
assertEquals(firstKey, siteTargets.get(0).siteKey());
assertEquals(secondKey, siteTargets.get(1).siteKey());
selection.get().accept(siteTargets.get(1));
var captor = org.mockito.ArgumentCaptor.forClass(BaseComponent.class);
verify(f.chat).sendMessage(captor.capture());
assertEquals("https://example.test/second", captor.getValue().getClickEvent().getValue());
assertSame(production, f.top.get());
verify(f.player, never()).openInventory(any(Inventory.class));
}
}
}
@Test void radialUsesStableSiteKeysAndNativeTargetsWithoutOpeningInventory() {
AtomicReference<List<ExperimentalDisplays.Target>> targets = new AtomicReference<>();
AtomicReference<java.util.function.Consumer<ExperimentalDisplays.Target>> selection = new AtomicReference<>();
try (var renderers = mockConstruction(ExperimentalDisplays.class, (renderer, context) -> {
doAnswer(inv -> { targets.set(inv.getArgument(3)); selection.set(inv.getArgument(4)); return null; })
.when(renderer).render(any(), any(), any(), anyList(), any());
}); Fixture f = new Fixture()) {
List<VoteSite> sites = new ArrayList<>();
for (int i = 0; i < 6; i++) sites.add(f.site("site" + i, "https://example.test/" + i));
when(f.plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>(sites));
Inventory production = f.top.get();
f.manager.open(f.player, ExperimentalGUIType.RADIAL); f.owner(); f.snapshot();
assertSame(production, f.top.get());
assertEquals(9, targets.get().size());
assertEquals(5, targets.get().stream().filter(t -> t.action() == ExperimentalDisplays.Action.SITE).count());
var next = targets.get().stream().filter(t -> t.action() == ExperimentalDisplays.Action.NEXT).findFirst().orElseThrow();
selection.get().accept(next);
var site = targets.get().stream().filter(t -> t.action() == ExperimentalDisplays.Action.SITE).findFirst().orElseThrow();
assertEquals("site5", site.siteKey());
assertEquals("site-0", site.id());
when(sites.get(5).getVoteURL(false)).thenReturn("https://example.test/new");
selection.get().accept(site);
var captor = org.mockito.ArgumentCaptor.forClass(BaseComponent.class);
verify(f.chat).sendMessage(captor.capture());
assertEquals("https://example.test/new", captor.getValue().getClickEvent().getValue());
verify(f.player, never()).openInventory(any(Inventory.class));
}
}
@Test void terminalSelectsOnlyAnEnabledStyleAndRemovalNeverClosesProductionInventory() {
AtomicReference<List<ExperimentalDisplays.Target>> targets = new AtomicReference<>();
AtomicReference<java.util.function.Consumer<ExperimentalDisplays.Target>> selection = new AtomicReference<>();
try (var renderers = mockConstruction(ExperimentalDisplays.class, (renderer, context) -> {
doAnswer(inv -> { targets.set(inv.getArgument(3)); selection.set(inv.getArgument(4)); return null; })
.when(renderer).render(any(), any(), any(), anyList(), any());
}); Fixture f = new Fixture()) {
Inventory production = f.top.get();
f.manager.terminalControl(f.player, "create"); f.owner(); f.snapshot();
assertEquals(9, targets.get().size());
assertSame(production, f.top.get());
var staleSelection = selection.get();
var inventory = targets.get().stream().filter(t -> t.id().equals("ANIMATED_INVENTORY")).findFirst().orElseThrow();
f.config.set("Experimental.VoteGUIs.AnimatedInventory.Enabled", false);
selection.get().accept(inventory); f.owner();
assertTrue(f.storage.isEmpty());
assertSame(production, f.top.get());
f.manager.terminalControl(f.player, "inspect"); f.owner();
verify(f.player).sendMessage(contains("private, nonpersistent"));
f.manager.terminalControl(f.player, "remove"); f.owner();
staleSelection.accept(inventory); f.owner();
assertTrue(f.storage.isEmpty());
verify(f.player, never()).closeInventory();
f.manager.terminalControl(f.player, "create"); f.owner(); f.snapshot();
f.config.set("Experimental.VoteGUIs.AnimatedInventory.Enabled", true);
selection.get().accept(inventory); f.owner(); f.snapshot();
assertTrue(f.top.get().getHolder() instanceof ExperimentalInventory);
}
}
@Test void terminalManagementCannotRemoveAnotherExperimentalStyle() {
try (Fixture f = new Fixture()) {
f.open(); f.snapshot();
Inventory active = f.top.get();
f.manager.terminalControl(f.player, "remove"); f.owner();
assertSame(active, f.top.get());
verify(f.player).sendMessage(contains("No active temporary"));
verify(f.player, never()).closeInventory();
}
}
@Test void showcaseNavigatesActualRewardsAndSitePagesWithoutExecutingRewards() {
AtomicReference<List<ExperimentalDisplays.Target>> targets = new AtomicReference<>();
AtomicReference<java.util.function.Consumer<ExperimentalDisplays.Target>> selection = new AtomicReference<>();
try (var renderers = mockConstruction(ExperimentalDisplays.class, (renderer, context) -> {
doAnswer(inv -> { targets.set(inv.getArgument(3)); selection.set(inv.getArgument(4)); return null; })
.when(renderer).render(any(), any(), any(), anyList(), any());
}); Fixture f = new Fixture()) {
var handler = f.plugin.getVoteStreakHandler();
var first = new com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakDefinition(
"DailyThree", com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType.DAILY, true, 3, 1, 0, 0, false);
var second = new com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakDefinition(
"DailyFive", com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType.DAILY, true, 5, 1, 0, 0, false);
when(handler.getDefinitions()).thenReturn(List.of(first, second));
when(handler.getStoredPreview(f.user, first)).thenReturn(
new com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakHandler.StoredPreview(2, 4, false));
when(handler.getStoredPreview(f.user, second)).thenReturn(
new com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakHandler.StoredPreview(4, 4, false));
when(f.plugin.getSpecialRewardsConfig().getData()).thenReturn(new YamlConfiguration());
List<VoteSite> sites = new ArrayList<>();
for (int i = 0; i < 6; i++) sites.add(f.site("site" + i, "https://example.test/" + i));
when(f.plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>(sites));
Inventory production = f.top.get();
f.manager.open(f.player, ExperimentalGUIType.REWARD_SHOWCASE); f.owner(); f.snapshot();
assertSame(production, f.top.get());
assertEquals(10, targets.get().size());
assertTrue(targets.get().getFirst().text().contains("DailyThree"));
assertTrue(targets.get().getFirst().text().contains("2/3"));
selection.get().accept(targets.get().getFirst()); // Preview has no reward action.
verify(f.chat, never()).sendMessage(any(BaseComponent.class));
selection.get().accept(targets.get().stream().filter(t -> t.action() == ExperimentalDisplays.Action.NEXT_REWARD).findFirst().orElseThrow());
assertTrue(targets.get().getFirst().text().contains("DailyFive"));
selection.get().accept(targets.get().stream().filter(t -> t.action() == ExperimentalDisplays.Action.SITE_PAGE).findFirst().orElseThrow());
var site = targets.get().stream().filter(t -> t.action() == ExperimentalDisplays.Action.SITE).findFirst().orElseThrow();
assertEquals("site5", site.siteKey());
assertEquals("site-0", site.id());
selection.get().accept(site);
verify(f.chat).sendMessage(any(BaseComponent.class));
f.watches.getFirst().run(); f.owner();
verify(renderers.constructed().getFirst()).rotateShowcase(any(), eq(1L));
verify(handler).getDefinitions();
verify(handler).getStoredPreview(f.user, first);
verify(handler).getStoredPreview(f.user, second);
verifyNoMoreInteractions(handler);
verify(f.player, never()).openInventory(any(Inventory.class));
}
}
@Test void emptyShowcaseRemainsAnIndependentCloseablePreview() {
AtomicReference<List<ExperimentalDisplays.Target>> targets = new AtomicReference<>();
AtomicReference<java.util.function.Consumer<ExperimentalDisplays.Target>> selection = new AtomicReference<>();
try (var renderers = mockConstruction(ExperimentalDisplays.class, (renderer, context) -> {
doAnswer(inv -> { targets.set(inv.getArgument(3)); selection.set(inv.getArgument(4)); return null; })
.when(renderer).render(any(), any(), any(), anyList(), any());
}); Fixture f = new Fixture()) {
f.manager.open(f.player, ExperimentalGUIType.REWARD_SHOWCASE); f.owner(); f.snapshot();
assertEquals(5, targets.get().size());
assertTrue(targets.get().getFirst().text().contains("No enabled streak rewards"));
selection.get().accept(targets.get().stream().filter(t -> t.action() == ExperimentalDisplays.Action.CLOSE).findFirst().orElseThrow());
int renders = org.mockito.Mockito.mockingDetails(renderers.constructed().getFirst()).getInvocations().size();
f.watches.getFirst().run(); f.owner();
assertEquals(renders, org.mockito.Mockito.mockingDetails(renderers.constructed().getFirst()).getInvocations().size());
verify(f.chat, never()).sendMessage(any(BaseComponent.class));
}
}
@Test void dialogUsesValidatedNativeUrlButtonsAndItsOwnNavigation() {
AtomicReference<List<ExperimentalDialogs.Button>> buttons = new AtomicReference<>();
AtomicReference<java.util.function.Consumer<String>> selection = new AtomicReference<>();
try (var renderers = mockConstruction(ExperimentalDialogs.class, (renderer, context) -> {
doAnswer(inv -> { buttons.set(inv.getArgument(4)); selection.set(inv.getArgument(5)); return null; })
.when(renderer).render(any(), any(), anyString(), anyString(), anyList(), any());
}); Fixture f = new Fixture()) {
List<VoteSite> sites = new ArrayList<>();
for (int i = 0; i < 6; i++) sites.add(f.site("site" + i, i == 5 ? "invalid" : "https://example.test/" + i));
when(f.plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>(sites));
f.manager.open(f.player, ExperimentalGUIType.NATIVE_DIALOG); f.owner(); f.snapshot();
assertEquals(8, buttons.get().size());
assertEquals("https://example.test/0", buttons.get().getFirst().url());
selection.get().accept("next");
assertEquals(4, buttons.get().size());
assertNull(buttons.get().getFirst().url());
assertEquals("invalid", buttons.get().getFirst().action());
verify(f.player, never()).openInventory(any(Inventory.class));
verify(f.chat, never()).sendMessage(any(BaseComponent.class));
}
}
@Test void dialogKeepsLongInvalidAndMissingUrlSitesNavigableWithoutEmbeddingKeysInActions() {
AtomicReference<List<ExperimentalDialogs.Button>> buttons = new AtomicReference<>();
AtomicReference<java.util.function.Consumer<String>> selection = new AtomicReference<>();
try (var renderers = mockConstruction(ExperimentalDialogs.class, (renderer, context) -> {
doAnswer(inv -> { buttons.set(inv.getArgument(4)); selection.set(inv.getArgument(5)); return null; })
.when(renderer).render(any(), any(), anyString(), anyString(), anyList(), any());
}); Fixture f = new Fixture()) {
String prefix = "Z".repeat(2047);
List<VoteSite> sites = new ArrayList<>();
sites.add(f.site(prefix + "A", "VoteURL"));
sites.add(f.site(prefix + "B", null));
for (int index = 2; index < 6; index++) sites.add(f.site("site" + index, "https://example.test/" + index));
when(f.plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>(sites));
Inventory production = f.top.get();
f.manager.open(f.player, ExperimentalGUIType.NATIVE_DIALOG); f.owner(); f.snapshot();
assertNotNull(buttons.get());
assertEquals(8, buttons.get().size());
for (int index = 0; index < 2; index++) {
assertNull(buttons.get().get(index).url());
assertEquals("invalid", buttons.get().get(index).action());
assertEquals("No valid HTTP/HTTPS URL", buttons.get().get(index).tooltip());
}
assertEquals("https://example.test/2", buttons.get().get(2).url());
selection.get().accept(buttons.get().getFirst().action());
verify(f.player).sendMessage(contains("no valid HTTP/HTTPS voting URL"));
selection.get().accept("next");
assertEquals(4, buttons.get().size());
assertEquals("https://example.test/5", buttons.get().getFirst().url());
selection.get().accept("previous");
assertEquals(8, buttons.get().size());
selection.get().accept("close");
verify(renderers.constructed().getFirst(), times(3)).render(any(), any(), anyString(), anyString(), anyList(), any());
verify(f.chat, never()).sendMessage(any(BaseComponent.class));
verify(f.player, never()).openInventory(any(Inventory.class));
verify(f.player, never()).closeInventory();
assertSame(production, f.top.get());
}
}
@Test void npcSelectionOpensOnlyItsCurrentExperimentalInventoryAfterSnapshot() {
AtomicReference<Runnable> selection = new AtomicReference<>();
try (var npcs = mockConstruction(ExperimentalNPC.class, (npc, context) -> {
doAnswer(inv -> { selection.set(inv.getArgument(4)); return null; })
.when(npc).open(any(), any(), any(), anyString(), any());
}); Fixture f = new Fixture()) {
Inventory production = f.top.get();
f.manager.open(f.player, ExperimentalGUIType.NPC); f.owner();
assertNull(selection.get());
verify(f.plugin, never()).getUser(f.id);
f.snapshot();
assertSame(production, f.top.get());
assertNotNull(selection.get());
selection.get().run();
assertTrue(f.top.get().getHolder() instanceof ExperimentalInventory);
Runnable oldSelection = selection.get();
f.open(); f.snapshot();
Inventory successor = f.top.get();
oldSelection.run();
assertSame(successor, f.top.get());
f.manager.clear(); f.owner();
verify(npcs.constructed().getFirst()).retryCleanup();
}
}
@Test void streakTrackShowsExistingDefinitionAndOnlySiteShortcutSendsLink() {
try (Fixture f = new Fixture()) {
var definition = new com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakDefinition(
"DailyThree", com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType.DAILY,
true, 3, 1, 0, 0, false);
var handler = f.plugin.getVoteStreakHandler();
when(handler.getDefinitions()).thenReturn(List.of(definition));
when(handler.getStoredPreview(f.user, definition)).thenReturn(
new com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakHandler.StoredPreview(2, 5, false));
when(f.plugin.getSpecialRewardsConfig().getData()).thenReturn(new YamlConfiguration());
VoteSite site = f.site("first", "https://example.test/first");
when(f.plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new ArrayList<>(List.of(site)));
f.manager.open(f.player, ExperimentalGUIType.STREAK_TRACK); f.owner(); f.snapshot();
verify(f.top.get()).setItem(eq(20), any());
f.click(20); f.owner(); // Preview has no claiming action.
verify(f.chat, never()).sendMessage(any(BaseComponent.class));
f.click(29); f.owner();
verify(f.chat).sendMessage(any(BaseComponent.class));
verify(handler).getDefinitions();
verify(handler).getStoredPreview(f.user, definition);
verifyNoMoreInteractions(handler);
}
}
}
@@ -0,0 +1,56 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.*;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.Test;
class ExperimentalGUISettingsTest {
@Test void missingSettingsNeverEnableNewStyles() {
var settings = ExperimentalGUISettings.read(new YamlConfiguration());
assertTrue(settings.enabled().isEmpty());
assertFalse(settings.hologram(new YamlConfiguration()).enabled());
assertEquals(32, settings.maxSessions());
}
@Test void legacyOptInEnablesOnlyHologramAndPreservesItsSettings() {
var config = new YamlConfiguration();
config.set("Experimental.HologramVoteGUI.Enabled", true);
config.set("Experimental.HologramVoteGUI.Distance", 2.9);
config.set("Experimental.HologramVoteGUI.TimeoutSeconds", 25);
var settings = ExperimentalGUISettings.read(config);
assertEquals(java.util.Set.of(ExperimentalGUIType.HOLOGRAM), settings.enabled());
assertEquals(2.9, settings.hologram(config).distance());
assertEquals(25, settings.hologram(config).timeoutSeconds());
config.set("Experimental.VoteGUIs.Hologram.Enabled", false);
assertTrue(ExperimentalGUISettings.read(config).enabled().isEmpty());
}
@Test void individualDisablingNeverDisablesAnotherStyle() {
for (var type : ExperimentalGUIType.values()) {
var config = new YamlConfiguration();
config.set("Experimental.VoteGUIs.Enabled", true);
config.set("Experimental.VoteGUIs." + type.configurationKey() + ".Enabled", false);
var settings = ExperimentalGUISettings.read(config);
assertFalse(settings.enabled().contains(type));
assertEquals(7, settings.enabled().size());
}
}
@Test void invalidPerformanceSettingsFailClosed() {
for (String setting : new String[] {"MaxActiveSessions", "TimeoutSeconds", "SitesPerPage", "Distance"}) {
var config = new YamlConfiguration();
config.set("Experimental.VoteGUIs." + setting, 999);
assertThrows(IllegalArgumentException.class, () -> ExperimentalGUISettings.read(config));
}
}
@Test void malformedHologramSettingsDoNotDisableUnrelatedStyles() {
var config = new YamlConfiguration();
config.set("Experimental.VoteGUIs.Enabled", true);
config.set("Experimental.HologramVoteGUI.Distance", 999);
var settings = ExperimentalGUISettings.read(config);
assertTrue(settings.enabled().contains(ExperimentalGUIType.ANIMATED_INVENTORY));
assertTrue(settings.enabled().contains(ExperimentalGUIType.STREAK_TRACK));
assertThrows(IllegalArgumentException.class, () -> settings.hologram(config));
}
}
@@ -0,0 +1,174 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.entity.HumanEntity;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryView;
import org.junit.jupiter.api.Test;
class ExperimentalInventoryListenerTest {
final UUID owner = UUID.randomUUID();
final ExperimentalSessions sessions = new ExperimentalSessions(() -> 0L, failure -> fail(failure));
final AtomicInteger actions = new AtomicInteger();
final ExperimentalInventoryListener listener = new ExperimentalInventoryListener(sessions,
(session, slot) -> actions.incrementAndGet());
Inventory experimental() {
var session = sessions.open(owner, ExperimentalGUIType.ANIMATED_INVENTORY, 64, 60);
var holder = new ExperimentalInventory(session);
Inventory inventory = mock(Inventory.class);
when(inventory.getHolder()).thenReturn(holder);
when(inventory.getSize()).thenReturn(54);
holder.bind(inventory);
session.activate();
return inventory;
}
InventoryView view(Inventory top) {
InventoryView view = mock(InventoryView.class);
when(view.getTopInventory()).thenReturn(top);
return view;
}
HumanEntity viewer(UUID id) {
HumanEntity viewer = mock(HumanEntity.class);
when(viewer.getUniqueId()).thenReturn(id);
return viewer;
}
InventoryClickEvent click(Inventory top, UUID player, ClickType type, int slot) {
InventoryClickEvent event = mock(InventoryClickEvent.class);
InventoryView view = view(top);
HumanEntity viewer = viewer(player);
when(event.getView()).thenReturn(view);
when(event.getWhoClicked()).thenReturn(viewer);
when(event.getClick()).thenReturn(type);
when(event.getRawSlot()).thenReturn(slot);
return event;
}
@Test void productionInventoriesAreUnaffectedEvenWithTheSameTitle() {
for (String name : new String[] {"Vote", "Vote Sites", "Admin Vote", "Rewards", "Vote Site Editor"}) {
Inventory production = mock(Inventory.class, name);
var event = click(production, owner, ClickType.SHIFT_LEFT, 0);
listener.click(event);
verify(event, never()).setCancelled(anyBoolean());
InventoryDragEvent drag = mock(InventoryDragEvent.class);
InventoryView view = view(production);
when(drag.getView()).thenReturn(view);
listener.drag(drag);
verify(drag, never()).setCancelled(anyBoolean());
InventoryCloseEvent close = mock(InventoryCloseEvent.class);
when(close.getView()).thenReturn(view);
listener.close(close);
}
assertEquals(0, actions.get());
}
@Test void productionCloseEventsDoNotRetireAnActiveExperimentalSessionForTheSamePlayer() {
Inventory owned = experimental();
var session = ((ExperimentalInventory) owned.getHolder()).session;
for (String menu : new String[]{"Vote", "Vote Sites", "Admin Vote", "Rewards", "Vote Site Editor"}) {
InventoryCloseEvent event = mock(InventoryCloseEvent.class);
InventoryView productionView = view(mock(Inventory.class, menu));
HumanEntity productionViewer = viewer(owner);
when(event.getView()).thenReturn(productionView);
when(event.getPlayer()).thenReturn(productionViewer);
listener.close(event);
assertTrue(sessions.current(session), menu);
}
sessions.clear(false);
assertTrue(sessions.snapshot().isEmpty());
}
@Test void concurrentPlayersKeepDistinctInventoriesAndCleanup() {
Inventory first = experimental();
var firstSession = ((ExperimentalInventory) first.getHolder()).session;
UUID secondPlayer = UUID.randomUUID();
var secondSession = sessions.open(secondPlayer, ExperimentalGUIType.STREAK_TRACK, 64, 60);
var secondHolder = new ExperimentalInventory(secondSession);
Inventory second = mock(Inventory.class);
when(second.getHolder()).thenReturn(secondHolder); when(second.getSize()).thenReturn(54);
secondHolder.bind(second); secondSession.activate();
listener.click(click(first, secondPlayer, ClickType.LEFT, 0));
listener.click(click(second, owner, ClickType.LEFT, 0));
assertEquals(0, actions.get());
listener.click(click(first, owner, ClickType.LEFT, 0));
listener.click(click(second, secondPlayer, ClickType.LEFT, 0));
assertEquals(2, actions.get());
InventoryCloseEvent close = mock(InventoryCloseEvent.class);
InventoryView firstView = view(first); HumanEntity firstViewer = viewer(owner);
when(close.getView()).thenReturn(firstView); when(close.getPlayer()).thenReturn(firstViewer);
listener.close(close);
assertFalse(sessions.current(firstSession));
assertTrue(sessions.current(secondSession));
listener.click(click(second, secondPlayer, ClickType.LEFT, 0));
assertEquals(3, actions.get());
}
@Test void exactInventoryIdentityIsRequiredEvenForAnExperimentalHolder() {
Inventory real = experimental();
Inventory impostor = mock(Inventory.class);
var holder = real.getHolder();
when(impostor.getHolder()).thenReturn(holder);
var event = click(impostor, owner, ClickType.LEFT, 0);
listener.click(event);
verify(event, never()).setCancelled(anyBoolean());
assertEquals(0, actions.get());
}
@Test void transfersAndSwapsAreBlockedWithoutActivatingButtons() {
Inventory top = experimental();
for (ClickType type : new ClickType[] {ClickType.SHIFT_LEFT, ClickType.SHIFT_RIGHT,
ClickType.NUMBER_KEY, ClickType.SWAP_OFFHAND, ClickType.DOUBLE_CLICK, ClickType.DROP}) {
var event = click(top, owner, type, 0);
listener.click(event);
verify(event).setCancelled(true);
}
listener.click(click(top, owner, ClickType.LEFT, 55));
assertEquals(0, actions.get());
listener.click(click(top, owner, ClickType.LEFT, 0));
assertEquals(1, actions.get());
}
@Test void wrongPlayerStaleSessionAndExternalCancellationCannotActivate() {
Inventory top = experimental();
listener.click(click(top, UUID.randomUUID(), ClickType.LEFT, 0));
var cancelled = click(top, owner, ClickType.LEFT, 0);
when(cancelled.isCancelled()).thenReturn(true);
listener.click(cancelled);
var replacement = sessions.open(owner, ExperimentalGUIType.STREAK_TRACK, 64, 60);
listener.click(click(top, owner, ClickType.LEFT, 0));
InventoryCloseEvent stale = mock(InventoryCloseEvent.class);
InventoryView view = view(top);
HumanEntity viewer = viewer(owner);
when(stale.getView()).thenReturn(view);
when(stale.getPlayer()).thenReturn(viewer);
listener.close(stale);
assertTrue(sessions.current(replacement));
assertEquals(0, actions.get());
}
@Test void dragAndCloseOnlyAffectTheOwnedSession() {
Inventory top = experimental();
InventoryView view = view(top);
InventoryDragEvent drag = mock(InventoryDragEvent.class);
when(drag.getView()).thenReturn(view);
listener.drag(drag);
verify(drag).setCancelled(true);
InventoryCloseEvent close = mock(InventoryCloseEvent.class);
HumanEntity viewer = viewer(owner);
when(close.getView()).thenReturn(view);
when(close.getPlayer()).thenReturn(viewer);
listener.close(close);
assertTrue(sessions.snapshot().isEmpty());
}
}
@@ -0,0 +1,463 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityTeleportEvent;
import org.bukkit.event.player.PlayerInteractAtEntityEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.hologram.HologramMenuScheduler;
class ExperimentalNPCTest {
private static final class Fixture implements AutoCloseable {
final VotingPluginMain plugin = mock(VotingPluginMain.class);
final HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
final AtomicLong clock = new AtomicLong();
final ExperimentalSessions sessions = new ExperimentalSessions(clock::get, failure -> fail(failure));
final ExperimentalNPC npc = new ExperimentalNPC(plugin, scheduler, sessions);
final UUID owner = UUID.randomUUID();
final UUID worldId = UUID.randomUUID();
final World world = mock(World.class);
final Player player = mock(Player.class);
final Villager villager = mock(Villager.class);
final Location anchor = new Location(world, 8, 64, 8);
final Queue<Runnable> players = new ArrayDeque<>();
final Queue<Runnable> regions = new ArrayDeque<>();
final AtomicInteger selections = new AtomicInteger();
final AtomicInteger spawns = new AtomicInteger();
final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
ExperimentalSessions.Session session;
boolean inPlayer;
boolean inRegion;
boolean owns = true;
Fixture() {
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(world.getUID()).thenAnswer(inv -> { assertTrue(inRegion || inPlayer && owns); return worldId; });
bukkit.when(() -> Bukkit.getWorld(worldId)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenAnswer(inv -> { assertTrue(inRegion); return true; });
when(player.getUniqueId()).thenReturn(owner);
when(player.isOnline()).thenAnswer(inv -> { assertTrue(inPlayer); return true; });
when(player.hasPermission(anyString())).thenAnswer(inv -> { assertTrue(inPlayer); return true; });
when(player.getWorld()).thenAnswer(inv -> { assertTrue(inPlayer); return world; });
when(player.getEyeLocation()).thenAnswer(inv -> { assertTrue(inPlayer); return anchor; });
when(villager.getUniqueId()).thenReturn(UUID.randomUUID());
when(villager.isValid()).thenAnswer(inv -> { assertTrue(inRegion); return true; });
doAnswer(inv -> { assertTrue(inRegion || inPlayer && owns); return null; }).when(villager).remove();
doAnswer(inv -> {
assertTrue(inPlayer);
assertTrue(owns);
return null;
}).when(player).showEntity(plugin, villager);
doAnswer(inv -> { players.add(inv.getArgument(1)); return null; })
.when(scheduler).player(any(), any(), any());
doAnswer(inv -> { regions.add(inv.getArgument(1)); return null; })
.when(scheduler).region(any(), any());
when(scheduler.owns(any())).thenAnswer(inv -> owns && (inPlayer || inRegion));
doAnswer(inv -> {
assertTrue(inRegion);
assertTrue(owns);
spawns.incrementAndGet();
inv.getArgument(2, Consumer.class).accept(villager);
return villager;
}).when(world).spawn(any(Location.class), eq(Villager.class), any());
session = sessions.open(owner, ExperimentalGUIType.NPC, 64, 60);
}
void open() { npc.open(session, player, anchor, "Voting guide", () -> {
assertTrue(inPlayer);
selections.incrementAndGet();
}); }
void playerNext() {
inPlayer = true;
try { players.remove().run(); } finally { inPlayer = false; }
}
void regionNext() {
inRegion = true;
try { regions.remove().run(); } finally { inRegion = false; }
}
void spawned() { open(); playerNext(); regionNext(); playerNext(); }
PlayerInteractEntityEvent click(Player who, Villager target) {
PlayerInteractEntityEvent event = mock(PlayerInteractEntityEvent.class);
when(event.getPlayer()).thenReturn(who);
when(event.getRightClicked()).thenReturn(target);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
return event;
}
@Override public void close() { bukkit.close(); }
}
@Test void spawnAndVisibilityRunOnTheirOwnersAndRefreshCannotDuplicate() {
try (Fixture f = new Fixture()) {
assertTrue(ExperimentalNPC.supported());
f.open();
f.open();
assertEquals(1, f.players.size());
assertEquals(0, f.spawns.get());
f.playerNext();
assertEquals(0, f.spawns.get());
f.regionNext();
assertEquals(1, f.npc.entityCount());
verify(f.player, never()).showEntity(any(), any());
f.playerNext();
assertEquals(ExperimentalSessions.State.ACTIVE, f.session.state());
verify(f.villager).setPersistent(false);
verify(f.villager).setAI(false);
verify(f.villager).setGravity(false);
verify(f.villager).setInvulnerable(true);
verify(f.villager).setSilent(true);
verify(f.villager).setCollidable(false);
verify(f.villager).setCanPickupItems(false);
verify(f.villager).setCustomName("Voting guide");
verify(f.villager).setVisibleByDefault(false);
verify(f.player).showEntity(f.plugin, f.villager);
f.open();
assertTrue(f.players.isEmpty());
assertEquals(1, f.spawns.get());
}
}
@Test void onlyExactOwnedEntityAndOwningPlayersMainHandCanSelectOnce() {
try (Fixture f = new Fixture()) {
f.spawned();
Villager unrelated = mock(Villager.class);
when(unrelated.getUniqueId()).thenReturn(UUID.randomUUID());
var normal = f.click(f.player, unrelated);
f.npc.interact(normal);
verify(normal, never()).setCancelled(anyBoolean());
Player stranger = mock(Player.class);
when(stranger.getUniqueId()).thenReturn(UUID.randomUUID());
var wrongPlayer = f.click(stranger, f.villager);
f.npc.interact(wrongPlayer);
verify(wrongPlayer).setCancelled(true);
var offhand = f.click(f.player, f.villager);
when(offhand.getHand()).thenReturn(EquipmentSlot.OFF_HAND);
f.npc.interact(offhand);
var cancelled = f.click(f.player, f.villager);
when(cancelled.isCancelled()).thenReturn(true);
f.npc.interact(cancelled);
assertTrue(f.players.isEmpty());
var owner = f.click(f.player, f.villager);
f.npc.interact(owner);
verify(owner).setCancelled(true);
PlayerInteractAtEntityEvent duplicate = mock(PlayerInteractAtEntityEvent.class);
when(duplicate.getPlayer()).thenReturn(f.player);
when(duplicate.getRightClicked()).thenReturn(f.villager);
when(duplicate.getHand()).thenReturn(EquipmentSlot.HAND);
f.npc.interactAt(duplicate);
assertEquals(1, f.players.size());
assertEquals(0, f.selections.get());
f.playerNext();
assertEquals(1, f.selections.get());
}
}
@Test void protectionDoesNotInterceptUnrelatedVillagers() {
try (Fixture f = new Fixture()) {
f.spawned();
Villager unrelated = mock(Villager.class);
when(unrelated.getUniqueId()).thenReturn(UUID.randomUUID());
for (Villager entity : new Villager[] { f.villager, unrelated }) {
EntityDamageEvent damage = mock(EntityDamageEvent.class);
when(damage.getEntity()).thenReturn(entity);
EntityTeleportEvent teleport = mock(EntityTeleportEvent.class);
when(teleport.getEntity()).thenReturn(entity);
f.npc.damage(damage);
f.npc.teleport(teleport);
if (entity == f.villager) {
verify(damage).setCancelled(true);
verify(teleport).setCancelled(true);
} else {
verify(damage, never()).setCancelled(anyBoolean());
verify(teleport, never()).setCancelled(anyBoolean());
}
}
}
}
@Test void closeBeforePlayerAdmissionAndBeforeRegionSpawnFencesLateCallbacks() {
try (Fixture f = new Fixture()) {
f.open();
f.sessions.close(f.session);
f.playerNext();
assertTrue(f.regions.isEmpty());
f.session = f.sessions.open(f.owner, ExperimentalGUIType.NPC, 64, 60);
f.open();
f.playerNext();
f.sessions.close(f.session);
f.regionNext();
assertEquals(0, f.spawns.get());
assertEquals(0, f.npc.entityCount());
}
}
@Test void replacementTimeoutAndShutdownInvalidatePendingSpawns() {
try (Fixture f = new Fixture()) {
f.open();
f.playerNext();
var replacement = f.sessions.open(f.owner, ExperimentalGUIType.NPC, 64, 60);
f.regionNext();
assertTrue(f.sessions.current(replacement));
f.session = replacement;
f.open();
f.playerNext();
f.clock.set(60_000_000_000L);
f.regionNext();
assertEquals(ExperimentalSessions.State.CLOSED, replacement.state());
f.session = f.sessions.open(f.owner, ExperimentalGUIType.NPC, 64, 60);
f.open();
f.playerNext();
f.sessions.clear(true);
f.regionNext();
assertEquals(0, f.spawns.get());
}
}
@Test void cleanupIsIdempotentAndUsesFixedRegionEvenAfterClose() {
try (Fixture f = new Fixture()) {
f.spawned();
f.sessions.close(f.session);
f.sessions.close(f.session);
assertEquals(1, f.regions.size());
verify(f.villager, never()).remove();
f.regionNext();
verify(f.villager).remove();
assertEquals(0, f.npc.entityCount());
assertTrue(f.sessions.snapshot().isEmpty());
}
}
@Test void pendingSelectionAndVisibilityCannotActAfterClose() {
try (Fixture f = new Fixture()) {
f.open();
f.playerNext();
f.regionNext();
f.sessions.close(f.session);
f.playerNext();
verify(f.player, never()).showEntity(any(), any());
f.regionNext();
f.session = f.sessions.open(f.owner, ExperimentalGUIType.NPC, 64, 60);
f.spawned();
f.npc.interact(f.click(f.player, f.villager));
f.sessions.close(f.session);
f.playerNext();
assertEquals(0, f.selections.get());
f.regionNext();
}
}
@Test void selectionRevalidatesWorldDistanceAndPermissionOnPlayersScheduler() {
try (Fixture f = new Fixture()) {
f.spawned();
f.npc.interact(f.click(f.player, f.villager));
doReturn(new Location(f.world, 100, 64, 100)).when(f.player).getEyeLocation();
f.playerNext();
assertEquals(0, f.selections.get());
assertFalse(f.sessions.current(f.session));
assertEquals(0, f.npc.entityCount());
doReturn(f.anchor).when(f.player).getEyeLocation();
f.session = f.sessions.open(f.owner, ExperimentalGUIType.NPC, 64, 60);
f.spawned();
f.npc.interact(f.click(f.player, f.villager));
doReturn(false).when(f.player).hasPermission(anyString());
f.playerNext();
assertEquals(0, f.selections.get());
assertEquals(0, f.npc.entityCount());
}
}
@Test void unownedAndUnloadedAreasNeverSpawnOrLoadChunks() {
try (Fixture f = new Fixture()) {
f.open();
f.playerNext();
f.owns = false;
f.regionNext();
assertEquals(0, f.spawns.get());
verify(f.world, never()).isChunkLoaded(anyInt(), anyInt());
f.owns = true;
f.session = f.sessions.open(f.owner, ExperimentalGUIType.NPC, 64, 60);
doReturn(false).when(f.world).isChunkLoaded(anyInt(), anyInt());
f.open();
f.playerNext();
f.regionNext();
assertEquals(0, f.spawns.get());
verify(f.world, never()).getChunkAt(anyInt(), anyInt());
verify(f.world, never()).loadChunk(anyInt(), anyInt());
assertTrue(f.sessions.snapshot().isEmpty());
}
}
@Test void neighboringRegionBoundaryAndUnloadedWorldAreRejectedBeforeSpawn() {
try (Fixture f = new Fixture()) {
doAnswer(inv -> {
Location location = inv.getArgument(0);
return (f.inPlayer || f.inRegion) && location.getX() <= 8;
}).when(f.scheduler).owns(any());
f.open();
f.playerNext();
f.regionNext();
assertEquals(0, f.spawns.get());
doAnswer(inv -> f.inPlayer || f.inRegion).when(f.scheduler).owns(any());
f.bukkit.when(() -> Bukkit.getWorld(f.worldId)).thenReturn(null);
f.session = f.sessions.open(f.owner, ExperimentalGUIType.NPC, 64, 60);
f.open();
f.playerNext();
f.regionNext();
assertEquals(0, f.spawns.get());
verify(f.world, never()).isChunkLoaded(anyInt(), anyInt());
}
}
@Test void playerThreadCannotShowAcrossAnUnownedRegionBoundary() {
try (Fixture f = new Fixture()) {
f.open();
f.playerNext();
f.regionNext();
f.owns = false;
f.playerNext();
verify(f.player, never()).showEntity(any(), any());
assertFalse(f.sessions.current(f.session));
f.owns = true;
f.regionNext();
verify(f.villager).remove();
}
}
@Test void partialSpawnFailureRemovesCapturedEntityOnOwningRegion() {
try (Fixture f = new Fixture()) {
doThrow(new IllegalStateException("configuration failed")).when(f.villager).setAI(false);
f.open();
f.playerNext();
f.regionNext();
verify(f.villager).setPersistent(false);
verify(f.villager).remove();
assertEquals(0, f.npc.entityCount());
assertTrue(f.sessions.snapshot().isEmpty());
assertTrue(f.players.isEmpty());
}
}
@Test void cancelledSpawnAndRejectedSchedulingDoNotLeaveSessionsOrEntities() {
try (Fixture f = new Fixture()) {
doReturn(false).when(f.villager).isValid();
f.open();
f.playerNext();
f.regionNext();
verify(f.villager).remove();
assertEquals(0, f.npc.entityCount());
f.session = f.sessions.open(f.owner, ExperimentalGUIType.NPC, 64, 60);
doThrow(new IllegalStateException("region rejected")).when(f.scheduler).region(any(), any());
f.open();
f.playerNext();
assertEquals(1, f.spawns.get());
assertTrue(f.sessions.snapshot().isEmpty());
f.session = f.sessions.open(f.owner, ExperimentalGUIType.NPC, 64, 60);
doThrow(new IllegalStateException("player rejected")).when(f.scheduler).player(any(), any(), any());
f.open();
assertTrue(f.sessions.snapshot().isEmpty());
}
}
@Test void rejectedCleanupRetainsExactOwnershipUntilOwnedRetryOrWorldUnload() {
try (Fixture f = new Fixture()) {
f.spawned();
doThrow(new IllegalStateException("region stopped")).when(f.scheduler).region(any(), any());
f.sessions.close(f.session);
assertEquals(1, f.npc.entityCount());
assertTrue(f.sessions.snapshot().isEmpty());
EntityDamageEvent damage = mock(EntityDamageEvent.class);
when(damage.getEntity()).thenReturn(f.villager);
f.inRegion = true;
try { f.npc.damage(damage); } finally { f.inRegion = false; }
verify(damage).setCancelled(true);
verify(f.villager).remove();
assertEquals(0, f.npc.entityCount());
}
try (Fixture f = new Fixture()) {
f.spawned();
doThrow(new IllegalStateException("region stopped")).when(f.scheduler).region(any(), any());
f.sessions.close(f.session);
WorldUnloadEvent unload = mock(WorldUnloadEvent.class);
when(unload.getWorld()).thenReturn(f.world);
f.npc.unload(unload);
assertEquals(0, f.npc.entityCount());
}
}
@Test void requiredPersistenceImplementationFailureNeverFallsBackToSavedVillager() {
try (Fixture f = new Fixture()) {
doThrow(new AbstractMethodError("old entity implementation")).when(f.villager).setPersistent(false);
f.open();
f.playerNext();
f.regionNext();
verify(f.villager).remove();
assertEquals(0, f.npc.entityCount());
assertTrue(f.sessions.snapshot().isEmpty());
}
}
@Test void cleanupCommandCanRetryRetiredRecordsWithoutTouchingActiveNPCs() {
try (Fixture f = new Fixture()) {
f.spawned();
f.npc.retryCleanup();
assertTrue(f.regions.isEmpty());
doThrow(new IllegalStateException("region stopped")).when(f.scheduler).region(any(), any());
f.sessions.close(f.session);
assertEquals(1, f.npc.entityCount());
doAnswer(inv -> { f.regions.add(inv.getArgument(1)); return null; })
.when(f.scheduler).region(any(), any());
f.npc.retryCleanup();
f.npc.retryCleanup();
assertEquals(1, f.regions.size());
f.regionNext();
assertEquals(0, f.npc.entityCount());
verify(f.villager).remove();
}
}
@Test void playerWorldChangeAndRetirementCallbackInvalidateSelections() {
try (Fixture f = new Fixture()) {
f.spawned();
f.npc.interact(f.click(f.player, f.villager));
doReturn(mock(World.class)).when(f.player).getWorld();
f.playerNext();
assertEquals(0, f.selections.get());
assertEquals(0, f.npc.entityCount());
}
try (Fixture f = new Fixture()) {
doAnswer(inv -> { inv.getArgument(2, Runnable.class).run(); return null; })
.when(f.scheduler).player(any(), any(), any());
f.open();
assertTrue(f.sessions.snapshot().isEmpty());
assertEquals(0, f.spawns.get());
assertEquals(0, f.npc.entityCount());
}
}
}
@@ -0,0 +1,34 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.List;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.VotingPluginMain;
class ExperimentalNativeDialogTest {
@Test void nativeSpigotBuilderOpensUrlAndPrivateCallbackButtonsWithoutProductionService() {
assertTrue(NativeExperimentalDialog.supported());
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
Player player = mock(Player.class);
var backend = new NativeExperimentalDialog(plugin);
Runnable release = backend.show(player, "Voting", "Real streak preview", List.of(
new ExperimentalDialogs.Button("Site", "Available", "https://example.test/vote", null),
new ExperimentalDialogs.Button("Next", "Next sites", null, "next")), (owner, action) -> fail());
var captured = org.mockito.ArgumentCaptor.forClass(net.md_5.bungee.api.dialog.Dialog.class);
verify(player).showDialog(captured.capture());
var dialog = assertInstanceOf(net.md_5.bungee.api.dialog.MultiActionDialog.class, captured.getValue());
assertEquals(2, dialog.actions().size());
assertEquals(net.md_5.bungee.api.dialog.DialogBase.AfterAction.CLOSE, dialog.getBase().afterAction());
assertEquals(Boolean.TRUE, dialog.getBase().canCloseWithEscape());
assertEquals(Boolean.FALSE, dialog.getBase().pause());
var url = assertInstanceOf(net.md_5.bungee.api.dialog.action.StaticAction.class, dialog.actions().getFirst().action());
assertEquals(net.md_5.bungee.api.chat.ClickEvent.Action.OPEN_URL, url.clickEvent().getAction());
assertEquals("https://example.test/vote", url.clickEvent().getValue());
verify(plugin, never()).getDialogService();
release.run(); release.run();
backend.close(player); verify(player).clearDialog();
}
}
@@ -0,0 +1,128 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import org.mockito.MockedConstruction;
import com.bencodez.advancedcore.AdvancedCoreConfigOptions;
import com.bencodez.advancedcore.api.command.CommandHandler;
import com.bencodez.simpleapi.debug.DebugLevel;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.commands.CommandLoader;
import com.bencodez.votingplugin.commands.gui.player.VoteGUI;
import com.bencodez.votingplugin.commands.gui.player.VoteURL;
import com.bencodez.votingplugin.config.Config;
import com.bencodez.votingplugin.user.UserManager;
/** Regression coverage for the production /vote entry handlers. */
class ExperimentalProductionVoteCommandTest {
@Test
void emptyVoteEntryUsesTheConfiguredProductionGuiAndNeverExperimentalManager() throws Exception {
VotingPluginMain plugin = voteLoaderFixture(true);
invokeVoteRegistration(plugin);
Player player = mock(Player.class);
CommandHandler entry = find(plugin.getVoteCommand());
try (MockedConstruction<VoteGUI> constructed = mockConstruction(VoteGUI.class);
MockedConstruction<VoteURL> urls = mockConstruction(VoteURL.class)) {
entry.execute(player, new String[0]);
assertNotNull(constructed.constructed());
assertEquals(1, constructed.constructed().size());
verify(constructed.constructed().get(0)).open();
assertEquals(0, urls.constructed().size());
verify(plugin, never()).getExperimentalGUIManager();
}
}
@Test
void emptyVoteEntryUsesProductionUrlWhenMainGuiIsDisabled() throws Exception {
VotingPluginMain plugin = voteLoaderFixture(false);
invokeVoteRegistration(plugin);
CommandSender console = mock(CommandSender.class);
CommandHandler entry = find(plugin.getVoteCommand());
try (MockedConstruction<VoteGUI> constructed = mockConstruction(VoteGUI.class);
MockedConstruction<VoteURL> urls = mockConstruction(VoteURL.class)) {
entry.execute(console, new String[0]);
assertEquals(0, constructed.constructed().size());
assertEquals(1, urls.constructed().size());
verify(urls.constructed().get(0)).open();
verify(plugin, never()).getExperimentalGUIManager();
}
}
@Test
void explicitVoteGuiHandlerStillOpensProductionGui() throws Exception {
VotingPluginMain plugin = voteLoaderFixture(true);
invokeVoteRegistration(plugin);
Player player = mock(Player.class);
CommandHandler gui = find(plugin.getVoteCommand(), "GUI");
try (MockedConstruction<VoteGUI> constructed = mockConstruction(VoteGUI.class)) {
gui.execute(player, new String[] { "GUI" });
verify(plugin, never()).getExperimentalGUIManager();
assertEquals(1, constructed.constructed().size());
verify(constructed.constructed().get(0)).open();
}
}
private static VotingPluginMain voteLoaderFixture(boolean mainGui) {
VotingPluginMain plugin = mock(VotingPluginMain.class);
Config config = mock(Config.class);
when(config.isUseVoteGUIMainCommand()).thenReturn(mainGui);
when(config.getDisabledCommands()).thenReturn(new ArrayList<>());
when(plugin.getConfigFile()).thenReturn(config);
AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class);
when(options.getDebug()).thenReturn(DebugLevel.NONE);
when(plugin.getOptions()).thenReturn(options);
when(plugin.getGui()).thenReturn(mock(com.bencodez.votingplugin.config.GUI.class));
when(plugin.getVotingPluginUserManager()).thenReturn(mock(UserManager.class));
AtomicReference<ArrayList<CommandHandler>> handlers = new AtomicReference<>();
org.mockito.Mockito.doAnswer(invocation -> {
handlers.set(invocation.getArgument(0));
return null;
}).when(plugin).setVoteCommand(org.mockito.ArgumentMatchers.any(ArrayList.class));
when(plugin.getVoteCommand()).thenAnswer(invocation -> handlers.get());
return plugin;
}
private static void invokeVoteRegistration(VotingPluginMain plugin) throws Exception {
Method method = CommandLoader.class.getDeclaredMethod("loadVoteCommand");
method.setAccessible(true);
// Generic AdvancedCore registrations require its startup singleton. Keep
// that boundary isolated while executing VotingPlugin's real handlers.
try (var coreStatic = org.mockito.Mockito.mockStatic(com.bencodez.advancedcore.command.CommandLoader.class)) {
var core = mock(com.bencodez.advancedcore.command.CommandLoader.class);
coreStatic.when(com.bencodez.advancedcore.command.CommandLoader::getInstance).thenReturn(core);
when(core.getBasicCommands("VotingPlugin")).thenReturn(new ArrayList<>());
method.invoke(new CommandLoader(plugin));
}
}
private static CommandHandler find(List<CommandHandler> handlers, String... args) {
return handlers.stream().filter(handler -> sameArgs(handler.getArgs(), args)).findFirst().orElse(null);
}
private static boolean sameArgs(String[] actual, String[] expected) {
if (actual.length != expected.length) return false;
for (int index = 0; index < actual.length; index++) {
if (!actual[index].equalsIgnoreCase(expected[index])) return false;
}
return true;
}
}
@@ -0,0 +1,109 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
class ExperimentalSessionsTest {
final AtomicLong clock = new AtomicLong();
final AtomicInteger errors = new AtomicInteger();
final ExperimentalSessions sessions = new ExperimentalSessions(clock::get, failure -> errors.incrementAndGet());
final UUID player = UUID.randomUUID();
@Test void replacingEveryStyleInvalidatesOldCallbacksWithoutClosingSuccessor() {
ExperimentalSessions.Session previous = null;
AtomicInteger released = new AtomicInteger();
for (ExperimentalGUIType type : ExperimentalGUIType.values()) {
var next = sessions.open(player, type, 1, 60);
assertNotNull(next);
next.own(released::incrementAndGet, failure -> fail(failure));
assertTrue(next.activate());
assertTrue(sessions.current(next));
if (previous != null) {
assertNotEquals(previous.id, next.id);
assertFalse(sessions.current(previous));
assertFalse(previous.activate());
sessions.close(previous); // A late timeout must never remove the successor.
assertTrue(sessions.current(next));
}
previous = next;
}
sessions.close(player);
sessions.close(player);
assertEquals(8, released.get());
assertTrue(sessions.snapshot().isEmpty());
}
@Test void limitsDoNotPreventReplacementOrCrossPlayerIsolation() {
var first = sessions.open(player, ExperimentalGUIType.NPC, 1, 60);
UUID other = UUID.randomUUID();
assertNull(sessions.open(other, ExperimentalGUIType.RADIAL, 1, 60));
var replacement = sessions.open(player, ExperimentalGUIType.RADIAL, 1, 60);
assertFalse(sessions.current(first));
sessions.close(other);
assertTrue(sessions.current(replacement));
}
@Test void timeoutIncludesExactBoundaryAndHandlesNanoTimeWrap() {
clock.set(Long.MAX_VALUE - 500_000_000L);
var menu = sessions.open(player, ExperimentalGUIType.HOLOGRAM, 64, 1);
clock.addAndGet(999_999_999L);
assertTrue(sessions.current(menu));
clock.incrementAndGet();
assertFalse(sessions.current(menu));
sessions.expire();
assertEquals(ExperimentalSessions.State.CLOSED, menu.state());
assertTrue(sessions.snapshot().isEmpty());
}
@Test void partialInitializationAndLateResourcesAreCleanedOnceDespiteFailures() {
var menu = sessions.open(player, ExperimentalGUIType.REWARD_SHOWCASE, 64, 60);
AtomicInteger released = new AtomicInteger();
menu.own(() -> { throw new IllegalStateException("fixture removal failure"); }, failure -> fail(failure));
menu.own(released::incrementAndGet, failure -> fail(failure));
sessions.close(menu);
menu.own(released::incrementAndGet, failure -> fail(failure));
sessions.close(menu);
assertEquals(1, errors.get());
assertEquals(2, released.get());
assertFalse(menu.activate());
}
@Test void reloadAllowsNewSessionsButShutdownRejectsThem() {
var old = sessions.open(player, ExperimentalGUIType.ANIMATED_INVENTORY, 64, 60);
sessions.clear(false);
assertFalse(sessions.current(old));
var next = sessions.open(player, ExperimentalGUIType.STREAK_TRACK, 64, 60);
assertNotNull(next);
sessions.clear(true);
assertFalse(sessions.current(next));
assertNull(sessions.open(player, ExperimentalGUIType.NATIVE_DIALOG, 64, 60));
}
@Test void repeatedOpenCloseDoesNotRetainResourcesOrSessions() {
AtomicInteger released = new AtomicInteger();
for (int i = 0; i < 1_000; i++) {
var menu = sessions.open(player, ExperimentalGUIType.VOTING_TERMINAL, 64, 60);
menu.own(released::incrementAndGet, failure -> fail(failure));
sessions.close(menu);
assertTrue(sessions.snapshot().isEmpty());
}
assertEquals(1_000, released.get());
}
@Test void resourceAndSettingBoundsAreEnforced() {
assertThrows(IllegalArgumentException.class,
() -> sessions.open(player, ExperimentalGUIType.NPC, 65, 60));
assertThrows(IllegalArgumentException.class,
() -> sessions.open(player, ExperimentalGUIType.NPC, 64, 61));
var menu = sessions.open(player, ExperimentalGUIType.NPC, 64, 60);
for (int i = 0; i < 128; i++) menu.own(() -> {}, failure -> fail(failure));
AtomicInteger rejectedResource = new AtomicInteger();
assertThrows(IllegalStateException.class,
() -> menu.own(rejectedResource::incrementAndGet, failure -> fail(failure)));
assertEquals(1, rejectedResource.get());
}
}
@@ -0,0 +1,46 @@
package com.bencodez.votingplugin.experimental;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakDefinition;
import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakHandler;
import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType;
import com.bencodez.votingplugin.user.VotingPluginUser;
class ExperimentalStreakPreviewTest {
@Test void presentationUsesActualStoredProgressAndAwardIdentityWithoutMutations() {
var plugin = mock(VotingPluginMain.class);
var user = mock(VotingPluginUser.class);
var handler = new VoteStreakHandler(plugin);
var first = new VoteStreakDefinition("ten", VoteStreakType.DAILY, true, 10, 1, 0, 0, false, "shared");
var next = new VoteStreakDefinition("twenty", VoteStreakType.DAILY, true, 20, 1, 0, 0, false, "shared");
when(user.getVoteStreakState("VoteStreakGroup_DAILY_shared"))
.thenReturn("2026-10-09|12|1|true||0|ten|15");
var preview = handler.getStoredPreview(user, first);
assertEquals(12, preview.amount());
assertEquals(15, preview.bestAmount());
assertTrue(preview.awardRecorded());
assertFalse(handler.getStoredPreview(user, next).awardRecorded());
verify(user, times(2)).getVoteStreakState("VoteStreakGroup_DAILY_shared");
verifyNoMoreInteractions(user);
verifyNoInteractions(plugin);
}
@Test void legacyBestFallbackIsReadOnlyAndDoesNotRewriteOldSerializedData() {
var user = mock(VotingPluginUser.class);
var handler = new VoteStreakHandler(mock(VotingPluginMain.class));
var definition = new VoteStreakDefinition("old", VoteStreakType.DAILY, true, 3, 1, 0, 0, false);
when(user.getVoteStreakState("VoteStreak_old")).thenReturn("2026-10-09|2|true||0");
when(user.getBestDayVoteStreak()).thenReturn(9);
var preview = handler.getStoredPreview(user, definition);
assertEquals(2, preview.amount());
assertEquals(9, preview.bestAmount());
assertFalse(preview.awardRecorded());
verify(user).getVoteStreakState("VoteStreak_old");
verify(user).getBestDayVoteStreak();
verifyNoMoreInteractions(user);
}
}
@@ -0,0 +1,40 @@
package com.bencodez.votingplugin.hologram;
import static org.mockito.Mockito.*;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
class HologramMenuSchedulerTest {
@Test void disabledPluginCleanupRunsInlineOnBukkitOwner() {
try (var bukkit = mockStatic(Bukkit.class); var completion = mockStatic(BukkitCompletionScheduler.class)) {
var plugin = mock(VotingPluginMain.class);
var player = mock(Player.class);
var close = mock(Runnable.class);
var retired = mock(Runnable.class);
when(plugin.isEnabled()).thenReturn(false);
bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
new HologramMenuScheduler(plugin).player(player, close, retired);
verify(close).run();
verifyNoInteractions(retired);
completion.verifyNoInteractions();
}
}
@Test void workerCallbackStillUsesPlayerSchedulerInsteadOfInlineEntityAccess() {
try (var bukkit = mockStatic(Bukkit.class); var completion = mockStatic(BukkitCompletionScheduler.class)) {
var plugin = mock(VotingPluginMain.class);
var player = mock(Player.class);
var close = mock(Runnable.class);
var retired = mock(Runnable.class);
bukkit.when(Bukkit::isPrimaryThread).thenReturn(false);
new HologramMenuScheduler(plugin).player(player, close, retired);
verifyNoInteractions(close);
completion.verify(() -> BukkitCompletionScheduler.run(plugin, player, close, retired, retired));
}
}
}
@@ -0,0 +1,119 @@
package com.bencodez.votingplugin.hologram;
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.Queue;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import net.md_5.bungee.api.chat.TextComponent;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.votesites.VoteSite;
class HologramVoteMenuClickTest {
@Test void ownerClicksPageAndIntruderIsIgnoredWhileExpiredSessionCloses() {
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
YamlConfiguration config = new YamlConfiguration();
config.set("Experimental.HologramVoteGUI.Enabled", true);
config.set("Experimental.HologramVoteGUI.SitesPerPage", 5);
when(plugin.getConfigFile().getData()).thenReturn(config);
when(plugin.isEnabled()).thenReturn(true);
Queue<Runnable> reads = new ArrayDeque<>();
var timer = plugin.getUserManager().getDataManager().getTimer();
doAnswer(inv -> { reads.add(inv.getArgument(0)); return null; })
.when(timer).execute(any(Runnable.class));
java.util.ArrayList<VoteSite> voteSites = new java.util.ArrayList<>();
for (int i = 0; i < 7; i++) {
VoteSite site = mock(VoteSite.class);
when(site.isHidden()).thenReturn(false);
when(site.getPermissionToView()).thenReturn("");
when(site.getKey()).thenReturn("site" + i);
when(site.getDisplayName()).thenReturn("Site " + i);
when(site.getVoteURL(false)).thenReturn("https://example.test/" + i);
voteSites.add(site);
}
when(plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(voteSites);
when(plugin.getUser(any())).thenReturn(mock(com.bencodez.votingplugin.user.VotingPluginUser.class));
HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).player(any(), any(), any());
doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).region(any(), any());
AtomicReference<Runnable> watch = new AtomicReference<>();
when(scheduler.watchPlayer(any(), any(), any())).thenAnswer(inv -> { watch.set(inv.getArgument(1)); return (Runnable) () -> { }; });
when(scheduler.owns(any())).thenReturn(true);
AtomicLong now = new AtomicLong(1_000_000_000L);
HologramVoteMenu menu = new HologramVoteMenu(plugin, scheduler, now::get);
Player owner = mock(Player.class), intruder = mock(Player.class);
when(owner.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(owner.isOnline()).thenReturn(true);
when(owner.hasPermission(anyString())).thenReturn(true);
when(intruder.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
World world = mock(World.class);
Location eye = new Location(world, 0, 64, 0);
when(owner.getEyeLocation()).thenReturn(eye);
when(owner.getWorld()).thenReturn(world);
Entity siteEntity = mock(Entity.class), nextEntity = mock(Entity.class);
when(siteEntity.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(nextEntity.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
AtomicReference<HologramVoteMenu.Session> rendered = new AtomicReference<>();
AtomicReference<TextComponent> sent = new AtomicReference<>();
Player.Spigot spigot = mock(Player.Spigot.class);
when(owner.spigot()).thenReturn(spigot);
doAnswer(inv -> { sent.set((TextComponent) inv.getArgument(0)); return null; }).when(spigot).sendMessage(any(TextComponent.class));
try (MockedStatic<NativeHologramRenderer> renderer = mockStatic(NativeHologramRenderer.class)) {
renderer.when(() -> NativeHologramRenderer.render(any(), any(), any())).thenAnswer(inv -> {
HologramVoteMenu.Session session = inv.getArgument(0);
rendered.set(session);
NativeHologramRenderer.Spawned callback = inv.getArgument(2);
callback.accept(siteEntity, HologramVoteMenu.Action.SITE, session.page * 5);
callback.accept(nextEntity, HologramVoteMenu.Action.NEXT, -1);
return null;
});
menu.open(owner);
reads.remove().run();
assertSame(owner.getUniqueId(), rendered.get().owner);
PlayerInteractEntityEvent intruderClick = mock(PlayerInteractEntityEvent.class);
when(intruderClick.getRightClicked()).thenReturn(siteEntity);
when(intruderClick.getPlayer()).thenReturn(intruder);
menu.interact(intruderClick);
verify(intruderClick).setCancelled(true);
verifyNoInteractions(spigot);
PlayerInteractEntityEvent siteClick = mock(PlayerInteractEntityEvent.class);
when(siteClick.getRightClicked()).thenReturn(siteEntity);
when(siteClick.getPlayer()).thenReturn(owner);
menu.interact(siteClick);
now.addAndGet(250_000_000L);
menu.interact(siteClick);
verify(siteClick, atLeastOnce()).setCancelled(true);
assertNotNull(sent.get());
assertEquals("https://example.test/0", sent.get().getClickEvent().getValue());
PlayerInteractEntityEvent nextClick = mock(PlayerInteractEntityEvent.class);
when(nextClick.getRightClicked()).thenReturn(nextEntity);
when(nextClick.getPlayer()).thenReturn(owner);
now.addAndGet(250_000_000L);
menu.interact(nextClick);
assertEquals(1, rendered.get().page);
now.addAndGet(250_000_000L);
menu.interact(siteClick);
assertEquals("https://example.test/5", sent.get().getClickEvent().getValue());
now.addAndGet(61_000_000_000L);
watch.get().run();
assertTrue(rendered.get().closed.get());
}
}
}
@@ -0,0 +1,87 @@
package com.bencodez.votingplugin.hologram;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.VotingPluginMain;
class HologramVoteMenuLifecycleTest {
@SuppressWarnings("unchecked")
private static Map<UUID, HologramVoteMenu.Session> sessions(HologramVoteMenu menu) throws Exception {
Field field = HologramVoteMenu.class.getDeclaredField("sessions");
field.setAccessible(true);
return (Map<UUID, HologramVoteMenu.Session>) field.get(menu);
}
@Test void quitAndWorldChangeOwnOnlyTheMatchingPlayerSession() throws Exception {
VotingPluginMain plugin = mock(VotingPluginMain.class);
HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
doAnswer(invocation -> { invocation.getArgument(1, Runnable.class).run(); return null; })
.when(scheduler).region(any(), any());
HologramVoteMenu menu = new HologramVoteMenu(plugin, scheduler);
Player first = mock(Player.class), second = mock(Player.class);
UUID firstId = UUID.randomUUID(), secondId = UUID.randomUUID();
when(first.getUniqueId()).thenReturn(firstId);
when(second.getUniqueId()).thenReturn(secondId);
HologramVoteSettings settings = new HologramVoteSettings(true, 2.5, 60, 5);
HologramVoteMenu.Session firstSession = new HologramVoteMenu.Session(first,
new Location(mock(World.class), 0, 64, 0), settings);
HologramVoteMenu.Session secondSession = new HologramVoteMenu.Session(second,
new Location(mock(World.class), 0, 64, 0), settings);
Entity firstEntity = mock(Entity.class), secondEntity = mock(Entity.class);
when(firstEntity.getUniqueId()).thenReturn(UUID.randomUUID());
when(secondEntity.getUniqueId()).thenReturn(UUID.randomUUID());
firstSession.entities.add(firstEntity);
secondSession.entities.add(secondEntity);
sessions(menu).put(firstId, firstSession);
sessions(menu).put(secondId, secondSession);
PlayerQuitEvent quit = mock(PlayerQuitEvent.class);
when(quit.getPlayer()).thenReturn(first);
menu.quit(quit);
assertTrue(firstSession.closed.get());
assertFalse(secondSession.closed.get());
verify(firstEntity).remove();
verify(secondEntity, never()).remove();
PlayerChangedWorldEvent changed = mock(PlayerChangedWorldEvent.class);
when(changed.getPlayer()).thenReturn(second);
menu.changedWorld(changed);
assertTrue(secondSession.closed.get());
verify(secondEntity).remove();
}
@Test void clearIsIdempotentAndShutdownPreventsLaterOwnership() throws Exception {
VotingPluginMain plugin = mock(VotingPluginMain.class);
HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
doAnswer(invocation -> { invocation.getArgument(1, Runnable.class).run(); return null; })
.when(scheduler).region(any(), any());
HologramVoteMenu menu = new HologramVoteMenu(plugin, scheduler);
Player player = mock(Player.class);
UUID id = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(id);
HologramVoteMenu.Session session = new HologramVoteMenu.Session(player,
new Location(mock(World.class), 0, 64, 0), new HologramVoteSettings(true, 2.5, 60, 5));
Entity entity = mock(Entity.class);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
session.entities.add(entity);
sessions(menu).put(id, session);
menu.clear();
menu.clear();
verify(entity, times(1)).remove();
assertTrue(session.closed.get());
menu.shutdown();
assertTrue(sessions(menu).isEmpty());
}
}
@@ -0,0 +1,96 @@
package com.bencodez.votingplugin.hologram;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import com.bencodez.votingplugin.VotingPluginMain;
class HologramVoteMenuNegativeTest {
@Test void disabledConfigurationStopsBeforeStorageOrRendering() {
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
YamlConfiguration config = new YamlConfiguration();
when(plugin.getConfigFile().getData()).thenReturn(config);
when(plugin.isEnabled()).thenReturn(true);
Player player = mock(Player.class);
when(player.isOnline()).thenReturn(true);
when(player.hasPermission(anyString())).thenReturn(true);
HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).player(any(), any(), any());
HologramVoteMenu menu = new HologramVoteMenu(plugin, scheduler);
menu.open(player);
verify(player).sendMessage(contains("Enable Experimental.HologramVoteGUI.Enabled"));
verify(plugin.getUserManager().getDataManager().getTimer(), never()).execute(any());
}
@Test void unsupportedPlatformStopsWithoutQueueingWork() {
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
YamlConfiguration config = new YamlConfiguration();
config.set("Experimental.HologramVoteGUI.Enabled", true);
when(plugin.getConfigFile().getData()).thenReturn(config);
when(plugin.isEnabled()).thenReturn(true);
Player player = mock(Player.class);
when(player.isOnline()).thenReturn(true);
when(player.hasPermission(anyString())).thenReturn(true);
HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).player(any(), any(), any());
HologramVoteMenu menu = new HologramVoteMenu(plugin, scheduler);
try (MockedStatic<HologramVoteMenu> api = mockStatic(HologramVoteMenu.class, CALLS_REAL_METHODS)) {
api.when(HologramVoteMenu::supported).thenReturn(false);
menu.open(player);
}
verify(player).sendMessage(contains("requires Minecraft 1.19.4"));
verify(plugin.getUserManager().getDataManager().getTimer(), never()).execute(any());
}
@Test void invalidUrlClickReportsErrorWithoutOpeningExternalLink() {
VotingPluginMain plugin = mock(VotingPluginMain.class);
HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).player(any(), any(), any());
doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).region(any(), any());
when(scheduler.owns(any())).thenReturn(true);
Player player = mock(Player.class);
UUID owner = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(owner);
when(player.hasPermission(anyString())).thenReturn(true);
World world = mock(World.class);
Location anchor = new Location(world, 0, 64, 0);
when(player.getWorld()).thenReturn(world);
when(player.getEyeLocation()).thenReturn(anchor);
HologramVoteMenu menu = new HologramVoteMenu(plugin, scheduler, () -> 1_000_000_000L);
HologramVoteMenu.Session session = new HologramVoteMenu.Session(player, anchor,
new HologramVoteSettings(true, 2.5, 60, 5), 1_000_000_000L);
session.sites = java.util.List.of(new HologramVoteModel.Site("bad", "Bad", "javascript:bad", true, 0));
session.version = 1;
Entity entity = mock(Entity.class);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
FieldAccess.put(menu, "sessions", owner, session);
FieldAccess.put(menu, "buttons", entity.getUniqueId(), new HologramVoteMenu.Button(session, 1, HologramVoteMenu.Action.SITE, 0));
PlayerInteractEntityEvent event = mock(PlayerInteractEntityEvent.class);
when(event.getRightClicked()).thenReturn(entity);
when(event.getPlayer()).thenReturn(player);
menu.interact(event);
verify(player).sendMessage(contains("no valid HTTP/HTTPS"));
verify(player, never()).spigot();
}
private static final class FieldAccess {
@SuppressWarnings("unchecked") static <K, V> void put(Object target, String name, K key, V value) {
try {
var field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
((java.util.concurrent.ConcurrentHashMap<K, V>) field.get(target)).put(key, value);
} catch (ReflectiveOperationException failure) { throw new AssertionError(failure); }
}
}
}
@@ -0,0 +1,74 @@
package com.bencodez.votingplugin.hologram;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.UUID;
import java.util.concurrent.Executor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import com.bencodez.votingplugin.VotingPluginMain;
class HologramVoteMenuOpenTest {
@Test void openQueuesStorageAndStaleReadCannotRenderAfterReplacement() throws Exception {
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
YamlConfiguration config = new YamlConfiguration();
config.set("Experimental.HologramVoteGUI.Enabled", true);
when(plugin.getConfigFile().getData()).thenReturn(config);
when(plugin.isEnabled()).thenReturn(true);
Queue<Runnable> reads = new ArrayDeque<>();
var timer = plugin.getUserManager().getDataManager().getTimer();
doAnswer(inv -> { reads.add(inv.getArgument(0)); return null; })
.when(timer).execute(any(Runnable.class));
HologramMenuScheduler scheduler = mock(HologramMenuScheduler.class);
doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; })
.when(scheduler).player(any(), any(), any());
doAnswer(inv -> { inv.getArgument(1, Runnable.class).run(); return null; })
.when(scheduler).region(any(), any());
when(scheduler.watchPlayer(any(), any(), any())).thenReturn(() -> { });
when(scheduler.owns(any())).thenReturn(true);
HologramVoteMenu menu = new HologramVoteMenu(plugin, scheduler);
Player player = mock(Player.class);
UUID id = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(id);
when(player.isOnline()).thenReturn(true);
when(player.hasPermission(anyString())).thenReturn(true);
World world = mock(World.class);
Location eyes = new Location(world, 0, 64, 0);
when(player.getEyeLocation()).thenReturn(eyes);
when(player.getWorld()).thenReturn(world);
when(plugin.getVoteSiteManager().getVoteSitesEnabled()).thenReturn(new java.util.ArrayList<>());
Entity entity = mock(Entity.class);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
try (MockedStatic<NativeHologramRenderer> renderer = mockStatic(NativeHologramRenderer.class)) {
renderer.when(() -> NativeHologramRenderer.render(any(), any(), any())).thenAnswer(inv -> {
NativeHologramRenderer.Spawned spawned = inv.getArgument(2);
spawned.accept(entity, null, -1);
return null;
});
menu.open(player);
assertEquals(1, reads.size());
menu.open(player); // closes first session before its storage callback runs
assertEquals(2, reads.size());
Runnable stale = reads.remove();
stale.run();
verify(entity, never()).remove();
Runnable current = reads.remove();
current.run();
verify(player).showEntity(plugin, entity);
menu.shutdown();
verify(entity).remove();
}
}
}
@@ -0,0 +1,26 @@
package com.bencodez.votingplugin.hologram;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.Test;
class HologramVoteModelTest {
@Test void urlsAreStrictlyHttpAndHttps() {
assertEquals("https://example.test/vote", HologramVoteModel.votingUrl(" https://example.test/vote ").orElseThrow());
for (String value : new String[] {null, "", "javascript:alert(1)", "ftp://example.test", "https://user@example.test/", "https:///missing-host"})
assertTrue(HologramVoteModel.votingUrl(value).isEmpty(), value);
}
@Test void paginationClampsAndPreservesOrder() {
List<HologramVoteModel.Site> sites = List.of(
new HologramVoteModel.Site("a", "A", "https://a.test", true, 0),
new HologramVoteModel.Site("b", "B", "https://b.test", false, 0),
new HologramVoteModel.Site("c", "C", "https://c.test", false, 61));
assertEquals(2, HologramVoteModel.pages(3, 2));
assertEquals(List.of(sites.get(2)), HologramVoteModel.page(sites, 99, 2));
assertEquals("\u00a7c\u25cf B \u00a77- Cooldown active", sites.get(1).label());
assertTrue(sites.get(2).label().contains("1m Remaining"));
assertThrows(IllegalArgumentException.class, () -> HologramVoteModel.pages(1, 0));
}
}
@@ -0,0 +1,25 @@
package com.bencodez.votingplugin.hologram;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
class HologramVoteSessionTest {
@Test void sessionExpiryIsDeterministicAtBoundaryAndProtectsOwnerSnapshot() {
Player player = mock(Player.class);
UUID owner = UUID.randomUUID();
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(owner);
HologramVoteMenu.Session session = new HologramVoteMenu.Session(player,
new Location(mock(World.class), 1, 2, 3), new HologramVoteSettings(true, 2.5, 1, 5));
assertFalse(session.expired(session.expiresAt - 1));
assertTrue(session.expired(session.expiresAt));
assertEquals(owner, session.owner);
session.sites = java.util.List.of(new HologramVoteModel.Site("site", "Name", null, false, 0));
assertEquals("site", session.sites.get(0).key());
}
}
@@ -0,0 +1,28 @@
package com.bencodez.votingplugin.hologram;
import static org.junit.jupiter.api.Assertions.*;
import org.bukkit.configuration.MemoryConfiguration;
import org.junit.jupiter.api.Test;
class HologramVoteSettingsTest {
@Test void missingConfigurationUsesSafeDisabledDefaults() {
HologramVoteSettings settings = HologramVoteSettings.read(new MemoryConfiguration());
assertFalse(settings.enabled());
assertEquals(2.5, settings.distance());
assertEquals(60, settings.timeoutSeconds());
assertEquals(5, settings.sitesPerPage());
}
@Test void configuredValuesAreReadAndBoundsAreEnforced() {
MemoryConfiguration config = new MemoryConfiguration();
config.set("Experimental.HologramVoteGUI.Enabled", true);
config.set("Experimental.HologramVoteGUI.Distance", 1.5);
config.set("Experimental.HologramVoteGUI.TimeoutSeconds", 1);
config.set("Experimental.HologramVoteGUI.SitesPerPage", 1);
assertEquals(new HologramVoteSettings(true, 1.5, 1, 1), HologramVoteSettings.read(config));
assertThrows(IllegalArgumentException.class, () -> new HologramVoteSettings(true, 1.4, 10, 1));
assertThrows(IllegalArgumentException.class, () -> new HologramVoteSettings(true, 2, 61, 1));
assertThrows(IllegalArgumentException.class, () -> new HologramVoteSettings(true, 2, 10, 6));
}
}
+247
View File
@@ -0,0 +1,247 @@
# Experimental holographic vote menu
This opt-in prototype adds `/av testhologram`. It does not replace `/vote`, execute rewards, record votes, or require
VotingPlugin Control or an external hologram plugin. The command requires `VotingPlugin.Admin` or
`VotingPlugin.Commands.AdminVote.TestHologram` (op by default), and must be executed by a player.
## Try it
On Spigot/Paper 1.19.4+ or a supported Folia server, set this in `Config.yml`, then run `/av reload`:
```yaml
Experimental:
HologramVoteGUI:
Enabled: true
Distance: 2.5
TimeoutSeconds: 60
SitesPerPage: 5
```
Look horizontally into an open area and execute `/av testhologram`. A stationary menu faces your opening direction.
Aim your crosshair at an entry and **right-click** it. VotingPlugin sends a clickable chat link containing that site's
configured HTTP/HTTPS VoteURL. Click the chat link and use Minecraft's normal URL confirmation. The server cannot
force your browser to open. Missing/invalid URLs produce a message instead of a link.
The menu includes enabled, non-hidden sites that you have permission to view, in VotingPlugin's existing order.
It displays a snapshot of your cooldowns from VotingPlugin's existing user APIs. Reopen to refresh the snapshot;
clicking an entry during cooldown only offers its URL and never bypasses vote processing. Previous/Next navigate
pages; Close removes the menu. Empty lists still have a Close button.
Distance accepts 1.5–3 blocks, timeout 1–60 seconds, and page size 1–5. Invalid values refuse to open. Defaults are
2.5/60/5 and the feature is **disabled**. There are at most 64 simultaneous menus and 64 outstanding data reads;
at most 200 visible enabled sites are included. Only the owner receives the entities, and another player's clicks
are rejected even if an entity ID is guessed.
## Lifecycle and compatibility
Reopening replaces your previous menu. Menus close on quit, world change, moving more than eight blocks from the
anchor, expiration, VotingPlugin reload, and plugin disable. Monitoring runs once per second. Entities are non-persistent
and never saved into chunks. Folia player work uses the entity scheduler; stationary entity spawning/removal uses the
anchor region. An opening that crosses an unowned region is refused. During final Folia server shutdown, region ticks may
already have stopped; non-persistent entities are discarded with world teardown rather than saved as orphaned menus.
Data reads run on the existing persistence worker, not the world thread. Late reads and queued callbacks are fenced
against replacement, close, expiration, and shutdown. Native display classes are isolated behind an API probe so older
servers can load VotingPlugin and receive an unsupported-feature message when testing this command.
## Interaction limits and assessment
The inspiration is [Hologram GUI (Mouse Menus)](https://youtu.be/ehlYco0E73w). This implementation uses native
TextDisplay/Interaction entities and ordinary Minecraft entity clicks. It does **not** implement a free mouse cursor,
true cursor hover, or continuous mouse tracking. Interaction hitboxes are axis-aligned and aiming matters; looking
steeply up/down or opening inside blocks can reduce usability. Unicode symbol appearance depends on the client's font.
Keep this experimental until visual gameplay, accessibility, different viewing angles, resource packs, and supported
server versions have broader coverage. The existing inventory GUI remains the normal player interface.
## Experimental style isolation
The experimental manager has its own player/session IDs, inventory holder and entity registry.
It never identifies an inventory by its title. Shift-click, hotbar swaps, drags and other transfers
are blocked only while the exact experimental inventory is open; unrelated menus and entities
are ignored. Opening another experimental style retires the previous session and invalidates
its pending snapshots and selections. A production inventory stays open until an administrator
explicitly requests an experimental inventory screen or clicks the experimental NPC.
`Experimental.VoteGUIs.Enabled` is false by default. Each style has an independent `Enabled`
setting under that section. The earlier explicit `Experimental.HologramVoteGUI.Enabled: true`
continues to enable only the hologram; `Experimental.VoteGUIs.Hologram.Enabled: false` overrides
that legacy opt-in. Other styles require the new global switch. Invalid legacy hologram-specific
settings do not prevent an independently enabled inventory style from opening.
The animated inventory uses configured visible voting sites, five sites per page by default,
read-only cooldown snapshots and optional available-item glints where the native API supports
them. Refresh and navigation never grant votes or rewards. The streak track reads the existing
streak definitions, stored counters and award state; displayed rewards are previews with no
claim action. Arbitrary reward conditions and external actions are not predicted or executed.
The NPC style uses a temporary native villager rather than a fake player or Citizens NPC.
Clicking it opens the dedicated experimental site inventory. It is stationary, nonpersistent,
invulnerable and nontrading; only its owner can activate it. Servers must expose the safe spawn,
AI and persistence APIs. Where private visibility APIs are absent, other players may see the
villager but cannot use it. No player skin customization is provided.
Snapshots run on the existing user-data worker, while inventory/player work uses the player
scheduler and stationary NPC work uses its fixed region. Entity counts and status use tracked
identities, never world scans. `/av testgui cleanup` retires temporary experimental sessions
and retries bounded pending NPC cleanup; it does not touch gameplay entities. A region scheduler
rejection retains the cleanup record and its capacity slot rather than admitting unbounded
replacement entities. Nonpersistent entities are discarded when their world unloads.
## Radial and native dialog prototypes
`/av testradialgui` places up to five configured site icons in a stationary circle, with previous,
next and close targets. Aim the crosshair and right-click an entry. `Radial.Radius` accepts
0.9–1.8 blocks (default 1.25). This is native entity targeting, not free mouse hover or tracking.
The renderer refuses an unloaded or unowned footprint instead of loading chunks or accessing
another Folia region. A full radial page has at most 23 entities: five icon/text/hitbox groups,
one central text/hitbox and three text/hitbox controls. Exact session generations fence stale
clicks and replacement cleanup; failed cleanup retains its bounded capacity slot for retry.
`/av testdialoggui` uses native dialog buttons. Voting-site buttons contain validated HTTP/HTTPS
URL actions handled by the client. Previous, next and close buttons use private experimental
callbacks, with owner and session checks and player-scheduler dispatch. The prototype never
uses the production dialog service's callback registrations or global cleanup. Invalid URLs
are reported instead of becoming executable commands. Closing a dialog or clicking a link
is not proof of a received vote, and never grants rewards.
Minecraft added dialogs in 1.21.6; Paper's developer API starts at 1.21.7
([official dialog documentation](https://docs.papermc.io/paper/dev/dialogs/)). Recent Spigot also
exposes native dialog APIs ([Player API](https://hub.spigotmc.org/javadocs/spigot/org/bukkit/entity/Player.html)).
Support is determined by the exact host classes and methods, not the version string. Unsupported
servers receive an explicit error; the command never falls back to a different GUI. Paper/Folia
and graphical client layout still require runtime acceptance testing; mocked API tests do not
establish gameplay verification.
## Reward showcase and temporary physical terminal
`/av testshowcasegui` creates a stationary floating reward preview with its configured item
(or a safe chest fallback), stored streak progress, award-recorded status and total votes.
Previous/next controls browse existing enabled streak definitions; the voting-sites control
cycles site pages. Preview interaction never claims, grants or repeats a reward. Arbitrary
reward conditions and external reward commands are not evaluated. The preview model turns
15 degrees once per second on its owning region; site icons remain stationary. A full page
has at most 26 entities (six item/text/hitbox groups and four text/hitbox controls).
`/av testterminalgui` or `/av testterminalgui create` creates a temporary private floating
board fixed to the opening location. It changes no blocks. Aim and right-click a listed style
to open that style, subject to its current switch, capability and permission. Disabled or
unsupported selections report their reason and leave the terminal intact. Opening a supported
style replaces the terminal session and removes its board. Its unique ID is the session UUID.
`/av testterminalgui list`, `inspect` and `remove` address only the invoking administrator's
current temporary terminal, never another player's terminal or another GUI type. Inspection
reports the stored world/coordinates and lifecycle without loading chunks or scanning worlds.
The board has 18 text/interaction entities and expires like other temporary sessions.
Persistent terminals are not implemented in this prototype. `AllowPersistentTerminals: false`
remains the default; changing that reserved option does not make temporary test terminals
persistent. There is no persistence/create-persistent command, world reconstruction or block
replacement. A durable public station needs a separate reviewed lifecycle before enabling it.
### Native dialog cleanup limitation
The supported Paper/Spigot APIs can show a dialog and unconditionally clear the player's
current dialog, but cannot inspect its identity. A deferred clear could dismiss a production
dialog opened afterward. Experimental retirement therefore unregisters only its own callbacks
and releases its session; it never calls an unconditional native clear. Close buttons and
Escape dismiss the native screen through the client. On timeout/reload/disable, an old native
screen may remain visible until dismissed or replaced, while its custom callbacks are inert.
Existing validated URL buttons remain ordinary client links, never reward actions. This is a
platform limitation, not a claim of full automatic screen dismissal. No packet/NMS interception
or global production-dialog hook is used.
### Command and capability matrix
| Style | Dedicated command | Required capability |
| --- | --- | --- |
| Hologram | `/av testhologram` | Native TextDisplay/Interaction APIs, 1.19.4+ |
| Animated inventory | `/av testinventorygui` | Plugin-supported Bukkit inventory/item APIs; Paper/Spigot only |
| Vanilla NPC | `/av testnpcgui` | Safe native Villager spawn, AI and nonpersistence APIs; Paper/Spigot only |
| Streak track | `/av teststreakgui` | Plugin-supported Bukkit inventory/item APIs; Paper/Spigot only |
| Radial | `/av testradialgui` | Native display/interaction/transformation APIs, 1.19.4+ |
| Native dialog | `/av testdialoggui` | Paper 1.21.7+ or compatible Spigot dialog API/client |
| Reward showcase | `/av testshowcasegui` | Native display/interaction/transformation APIs, 1.19.4+ |
| Voting terminal | `/av testterminalgui` | Native display/interaction/transformation APIs, 1.19.4+ |
Inventory styles provide the broadest compatibility. Display styles use crosshair targeting,
not free cursor movement. Capability checks are authoritative; no test command silently redirects
to a different renderer. Folia paths use player/entity/region ownership and refuse footprints
crossing an unowned region. Automated Paper/Folia client-protocol checks are recorded below. Visual layouts and manual
gameplay have not been verified; protocol checks do not establish visual usability. The existing Java 21
build requirement and production voting interfaces/accounting remain unchanged.
Use `/av testgui list` to see each independent enabled/supported state, `status` for bounded
tracked session/entity counts, `close` for your current experiment, and `cleanup` to retire all
temporary experiments. The global `Experimental.VoteGUIs.Enabled: false` default remains off.
Enable it explicitly before testing; keep individual style switches false to exclude them.
### Folia teleport lifecycle
Folia's current asynchronous teleport implementation does not publish the ordinary
`PlayerTeleportEvent` on all teleport paths. Experimental sessions therefore also
check the player's position on its entity scheduler once per second on Folia. Moving
from the opening position (including a short teleport) closes the menu; looking around
does not. This deliberately stricter Folia rule applies to all eight experimental
styles, including the legacy hologram test, and never closes an unrelated inventory.
Spigot/Paper retain teleport-event and distance-based cleanup. No packet interception
or internal server fields are used.
### Inventory cleanup and Folia limitation
Paper/Spigot cleanup closes the exact experimental inventory inline when already on the
server thread, including plugin disable. A different production inventory remains untouched.
Worker callbacks continue to use player scheduling.
Folia currently rejects or discards player-scheduler work after a plugin is disabled. A fixed
region scheduler cannot safely follow a player who teleported to a different region. To prevent
preview items becoming transferable after listeners are removed, AnimatedInventory, StreakTrack
and NPC (whose selection opens an inventory) are explicitly unavailable on Folia. Their commands
report this reason before creating a session or touching an inventory. The five native styles
remain independent and available when their individual capabilities exist. This restriction can
be removed when a supported lifecycle API guarantees owner-scheduled inventory retirement.
### Executed server/client checks
The candidate implementation was exercised on real Paper 26.2 build 133 and Folia 26.2 build 7
servers with Java 25, two automated client connections, seven reward-free configured voting
sites, and ViaVersion/ViaBackwards for protocol 26.1. No graphical client, screenshot or manual
gameplay claim is made. Native dialog callback automation needed a test-only length-prefixed
nullable-NBT protocol shim; that does not prove unmodified 26.1 client dialog compatibility.
Paper passed 56 bounded client-protocol checks covering all eight styles: creation, correct
site URL, relevant pagination, invalid URLs, private entity visibility, intruder rejection,
replacement, close, tracked resources, production `/vote gui` after the experiments, and active
display reload cleanup. Folia passed 43 checks for its five supported styles and explicit
resource-free rejection of the three unavailable inventory styles.
A separate run with a three-second timeout and then sixty-second timeout passed 17 checks on
Paper and 14 on Folia: supported styles expired without tracked resources, and a one-block
teleport retired them well before expiration. Both platforms stopped cleanly and had no
class-loading, event-delivery or thread-ownership failure markers in these runs. The test JAR
SHA-256 was `f7d8538b37bd5f7ff89313446ead165ad191b511ee00736552f07bab31eecfac`.
These checks observe tracked registries and client-visible entities, not an exhaustive world
scan. Earned streak/reward progression, graphical spacing/animation/accessibility, failure
during partial spawning, old-server gameplay, and Folia
region-boundary stress remain manual/integration verification limits. Deterministic tests
cover ownership, cancellation, stale callbacks, unrelated inventory/entity events and cleanup
failures, but do not substitute for those gameplay scenarios.
A further automated death/disconnect run passed 17 checks on Paper (all eight styles) and
11 on Folia (five supported native styles). Each active menu was retired after a real player
death, then separately after disconnect/reconnect, with zero tracked sessions, snapshots, NPCs
and displays. The servers exited cleanly with no listed failure markers. This uses the same
validated candidate JAR and remains protocol automation rather than manual visual gameplay.
The site-key follow-up at `daa111a8fd619322bbca034939b44541ad710a1b` was rerun on
both servers with the exact new JAR, SHA-256
`031555380d0fb081891df1b0793d6576babcda4b7fab36c06fe8218d46a8b371`. Paper passed
59 checks and Folia passed 46. These repeat the creation/URL/navigation/ownership/
replacement/close/production-GUI/reload checks and add correct second-site URL
selection in hologram, radial and showcase menus using two valid 2,048-character
site keys with a common prefix. Both servers exited with code zero and no listed
failure markers. The fixtures restored their prior site configuration afterward.
The separate timeout/teleport/death/disconnect evidence above remains tied to its
earlier artifact; these latest runs do not broaden the stated visual or gameplay
verification limits.