mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-12 20:22:04 -05:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4212fc9818 | ||
|
|
4aaeb68b7b | ||
|
|
6b6ee6d28d | ||
|
|
aeb2475007 | ||
|
|
daa111a8fd | ||
|
|
db23fceb70 | ||
|
|
4af61257d0 | ||
|
|
c247e06160 | ||
|
|
e7fb4ff15c |
No files matched your search
@@ -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") {
|
||||
|
||||
+109
@@ -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);
|
||||
}
|
||||
}
|
||||
+230
@@ -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);} }
|
||||
}
|
||||
+735
@@ -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()); }
|
||||
}
|
||||
+38
@@ -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());
|
||||
}
|
||||
}
|
||||
+24
@@ -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; }
|
||||
}
|
||||
+22
@@ -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; }
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
+326
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -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);
|
||||
}
|
||||
}
|
||||
+127
@@ -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); }
|
||||
}
|
||||
}
|
||||
+81
@@ -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";
|
||||
}
|
||||
}
|
||||
+18
@@ -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));
|
||||
}
|
||||
}
|
||||
+80
@@ -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));
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
+64
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -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;
|
||||
}
|
||||
}
|
||||
+88
@@ -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);
|
||||
}
|
||||
}
|
||||
+256
@@ -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());
|
||||
}
|
||||
}
|
||||
+625
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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));
|
||||
}
|
||||
}
|
||||
+174
@@ -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());
|
||||
}
|
||||
}
|
||||
+463
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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();
|
||||
}
|
||||
}
|
||||
+128
@@ -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;
|
||||
}
|
||||
}
|
||||
+109
@@ -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());
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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());
|
||||
}
|
||||
}
|
||||
+96
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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));
|
||||
}
|
||||
}
|
||||
+25
@@ -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());
|
||||
}
|
||||
}
|
||||
+28
@@ -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));
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package com.bencodez.votingplugin.tests.experimental;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.entity.Player;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.experimental.ExperimentalGUIManager;
|
||||
import com.bencodez.votingplugin.hologram.HologramMenuScheduler;
|
||||
import com.bencodez.votingplugin.user.VotingPluginUser;
|
||||
|
||||
class ExperimentalAnimatedInventoryLifecycleTest {
|
||||
@Test void repeatedAnimationReusesOneInventoryAndWatchWithoutReadingStorageAgain() {
|
||||
try (var f = new Fixture()) {
|
||||
f.open();
|
||||
f.snapshot();
|
||||
Inventory inventory = f.top.get();
|
||||
clearInvocations(f.user, inventory, f.player);
|
||||
|
||||
for (int i = 0; i < 120; i++) {
|
||||
f.watches.getFirst().run();
|
||||
f.owner();
|
||||
assertSame(inventory, f.top.get());
|
||||
assertTrue(f.storage.isEmpty());
|
||||
assertTrue(f.owner.isEmpty());
|
||||
}
|
||||
|
||||
assertEquals(1, f.created.size());
|
||||
assertEquals(1, f.watches.size());
|
||||
verify(f.scheduler, times(1)).watchPlayer(eq(f.player), any(), any());
|
||||
verify(inventory, times(120)).clear();
|
||||
var border = ArgumentCaptor.forClass(ItemStack.class);
|
||||
verify(inventory, times(120)).setItem(eq(0), border.capture());
|
||||
assertEquals(List.of(Material.BLUE_STAINED_GLASS_PANE, Material.GRAY_STAINED_GLASS_PANE),
|
||||
border.getAllValues().stream().limit(2).map(ItemStack::getType).toList());
|
||||
assertEquals(2, border.getAllValues().stream().map(ItemStack::getType).distinct().count());
|
||||
verifyNoInteractions(f.user);
|
||||
verify(f.player, never()).openInventory(any(Inventory.class));
|
||||
verify(f.player, never()).closeInventory();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disconnectBeforeSnapshotCannotReadUserOrOpenAnInventoryLater() {
|
||||
try (var f = new Fixture()) {
|
||||
Inventory production = f.top.get();
|
||||
f.open();
|
||||
assertEquals(1, f.storage.size());
|
||||
PlayerQuitEvent quit = mock(PlayerQuitEvent.class);
|
||||
when(quit.getPlayer()).thenReturn(f.player);
|
||||
f.manager.quit(quit);
|
||||
f.owner();
|
||||
f.snapshot();
|
||||
f.watches.getFirst().run();
|
||||
f.owner();
|
||||
|
||||
assertSame(production, f.top.get());
|
||||
assertTrue(f.created.isEmpty());
|
||||
assertTrue(f.storage.isEmpty());
|
||||
verify(f.plugin, never()).getUser(f.id);
|
||||
verifyNoInteractions(f.user);
|
||||
verify(f.player, never()).openInventory(any(Inventory.class));
|
||||
verify(f.player, never()).closeInventory();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void animationRetiresOnceWithoutClosingOrChangingAProductionSuccessor() {
|
||||
try (var f = new Fixture()) {
|
||||
AtomicInteger cancellations = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
f.watches.add(invocation.getArgument(1));
|
||||
return (Runnable) cancellations::incrementAndGet;
|
||||
}).when(f.scheduler).watchPlayer(any(), any(), any());
|
||||
f.open();
|
||||
f.snapshot();
|
||||
Inventory old = f.top.get();
|
||||
Inventory production = mock(Inventory.class);
|
||||
f.top.set(production);
|
||||
clearInvocations(old, f.player, f.user);
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
f.watches.getFirst().run();
|
||||
f.owner();
|
||||
}
|
||||
|
||||
assertEquals(1, cancellations.get());
|
||||
assertSame(production, f.top.get());
|
||||
verifyNoInteractions(production, old, f.user);
|
||||
verify(f.player, never()).closeInventory();
|
||||
verify(f.player, never()).openInventory(any(Inventory.class));
|
||||
assertTrue(f.storage.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void oldAnimationCannotRenderOrCloseTheReplacementExperimentalSession() {
|
||||
try (var f = new Fixture()) {
|
||||
f.open();
|
||||
f.snapshot();
|
||||
Inventory first = f.top.get();
|
||||
Runnable retired = f.watches.getFirst();
|
||||
f.open();
|
||||
f.snapshot();
|
||||
Inventory replacement = f.top.get();
|
||||
assertNotSame(first, replacement);
|
||||
clearInvocations(first, replacement, f.player, f.user);
|
||||
|
||||
retired.run();
|
||||
f.owner();
|
||||
|
||||
assertSame(replacement, f.top.get());
|
||||
verifyNoInteractions(first, replacement, f.user);
|
||||
verify(f.player, never()).closeInventory();
|
||||
verify(f.player, never()).openInventory(any(Inventory.class));
|
||||
assertTrue(f.storage.isEmpty());
|
||||
f.watches.getLast().run();
|
||||
f.owner();
|
||||
verify(replacement).clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse the existing fixture without widening production or legacy test visibility.
|
||||
// Accept its namespace-only move when the integrator relocates the parent tests.
|
||||
private static final class Fixture implements AutoCloseable {
|
||||
private final Object delegate = create();
|
||||
final VotingPluginMain plugin = field("plugin");
|
||||
final HologramMenuScheduler scheduler = field("scheduler");
|
||||
final Player player = field("player");
|
||||
final UUID id = field("id");
|
||||
final VotingPluginUser user = field("user");
|
||||
final Queue<Runnable> owner = field("owner");
|
||||
final Queue<Runnable> storage = field("storage");
|
||||
final List<Runnable> watches = field("watches");
|
||||
final List<Inventory> created = field("created");
|
||||
final AtomicReference<Inventory> top = field("top");
|
||||
final ExperimentalGUIManager manager = field("manager");
|
||||
|
||||
private static Object create() {
|
||||
for (String name : List.of(
|
||||
"com.bencodez.votingplugin.tests.experimental.ExperimentalGUIManagerTest$Fixture",
|
||||
"com.bencodez.votingplugin.experimental.ExperimentalGUIManagerTest$Fixture")) {
|
||||
try {
|
||||
var type = Class.forName(name);
|
||||
var constructor = type.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
return constructor.newInstance();
|
||||
} catch (ClassNotFoundException absent) {
|
||||
// Try only the alternate namespace, never hide a fixture setup failure.
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw accessFailure(failure);
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Existing experimental GUI fixture is unavailable");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T field(String name) {
|
||||
try {
|
||||
var field = delegate.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return (T) field.get(delegate);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw accessFailure(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void call(String name) {
|
||||
try {
|
||||
var method = delegate.getClass().getDeclaredMethod(name);
|
||||
method.setAccessible(true);
|
||||
method.invoke(delegate);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw accessFailure(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static AssertionError accessFailure(ReflectiveOperationException failure) {
|
||||
Throwable cause = failure instanceof InvocationTargetException invocation
|
||||
? invocation.getCause() : failure;
|
||||
if (cause instanceof RuntimeException runtime) throw runtime;
|
||||
if (cause instanceof Error error) throw error;
|
||||
return new AssertionError(cause);
|
||||
}
|
||||
|
||||
void open() { call("open"); }
|
||||
void owner() { call("owner"); }
|
||||
void snapshot() { call("snapshot"); }
|
||||
@Override public void close() { call("close"); }
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user