mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-04 09:02:00 -05:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f6cb104bf | ||
|
|
7010625601 | ||
|
|
8bb8851340 | ||
|
|
1d89eeca4d | ||
|
|
d2d9256cd3 | ||
|
|
41cca3f51f |
No files matched your search
@@ -156,6 +156,49 @@ import lombok.Setter;
|
||||
|
||||
public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
private final Object votingNPCLifecycle = new Object();
|
||||
private final java.util.concurrent.atomic.AtomicLong votingNPCConfiguration = new java.util.concurrent.atomic.AtomicLong();
|
||||
private volatile com.bencodez.votingplugin.npc.VotingNPCManager votingNPCManager;
|
||||
|
||||
public com.bencodez.votingplugin.npc.VotingNPCManager getVotingNPCManager() { return votingNPCManager; }
|
||||
|
||||
/** Optional NPC storage is lazy and asynchronous; configuration only schedules owner work. */
|
||||
private void refreshVotingNPCs() {
|
||||
boolean requested = getConfigFile().isVotingNPCsEnabled();
|
||||
long generation = votingNPCConfiguration.incrementAndGet();
|
||||
if (!requested && votingNPCManager == null) return;
|
||||
getBukkitScheduler().runTask(this, () -> {
|
||||
com.bencodez.votingplugin.npc.VotingNPCManager manager;
|
||||
synchronized (votingNPCLifecycle) {
|
||||
if (!isEnabled() || generation != votingNPCConfiguration.get()) return;
|
||||
if (votingNPCManager == null && requested) {
|
||||
try {
|
||||
var created = new com.bencodez.votingplugin.npc.VotingNPCManager(this);
|
||||
Bukkit.getPluginManager().registerEvents(created, this);
|
||||
votingNPCManager = created;
|
||||
} catch (RuntimeException | LinkageError unsupported) {
|
||||
getLogger().warning("Voting NPCs are unavailable on this server; normal voting is unaffected.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
manager = votingNPCManager;
|
||||
}
|
||||
if (manager != null) manager.configure(requested);
|
||||
});
|
||||
}
|
||||
|
||||
private void closeVotingNPCs() {
|
||||
com.bencodez.votingplugin.npc.VotingNPCManager manager;
|
||||
synchronized (votingNPCLifecycle) {
|
||||
votingNPCConfiguration.incrementAndGet();
|
||||
manager = votingNPCManager;
|
||||
votingNPCManager = null;
|
||||
}
|
||||
if (manager != null) {
|
||||
try { manager.close(); } catch (RuntimeException | LinkageError failure) { debug(failure); }
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static VotingPluginMain plugin;
|
||||
|
||||
@@ -748,6 +791,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
checkVotifier();
|
||||
registerEvents();
|
||||
refreshPlaceholderPlayerPresence();
|
||||
refreshVotingNPCs();
|
||||
|
||||
loadVoteBroadcast();
|
||||
|
||||
@@ -1961,6 +2005,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
@Override
|
||||
public void onUnLoad() {
|
||||
closeVotingNPCs();
|
||||
placeholderPlayerPresence.clear();
|
||||
stopBackendHostedControlLifecycle();
|
||||
stopBackendControlConnectorLifecycle();
|
||||
@@ -2172,6 +2217,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
|
||||
loadDirectlyDefined();
|
||||
|
||||
refreshVotingNPCs();
|
||||
if (reconcileHostedControl) restartBackendControlConnector();
|
||||
|
||||
setUpdate(true);
|
||||
|
||||
@@ -475,6 +475,7 @@ public class CommandLoader {
|
||||
*/
|
||||
private void loadAdminVoteCommand() {
|
||||
plugin.setAdminVoteCommand(new ArrayList<>());
|
||||
com.bencodez.votingplugin.npc.VotingNPCCommands.register(plugin);
|
||||
|
||||
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, new String[] { "CurrentPluginTime" },
|
||||
"VotingPlugin.Commands.AdminVote.CurrentPluginTime|" + adminPerm, "Current plugin time") {
|
||||
@@ -3299,6 +3300,14 @@ public class CommandLoader {
|
||||
* Load tab complete.
|
||||
*/
|
||||
public void loadTabComplete() {
|
||||
TabCompleteHandler.getInstance().addTabCompleteOption(new TabCompleteHandle("(npcid)", new ArrayList<>()) {
|
||||
@Override public void reload() { updateReplacements(); }
|
||||
@Override public void updateReplacements() {
|
||||
var manager = plugin.getVotingNPCManager();
|
||||
setReplace(manager == null ? new ArrayList<>() : new ArrayList<>(manager.definitions().stream()
|
||||
.map(com.bencodez.votingplugin.npc.VotingNPCDefinition::id).toList()));
|
||||
}
|
||||
});
|
||||
ArrayList<String> sites = new ArrayList<>();
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
|
||||
sites.add(site.getKey());
|
||||
|
||||
@@ -24,6 +24,9 @@ import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
import lombok.Getter;
|
||||
|
||||
public class Config extends YMLFile {
|
||||
/** Opt-in persistent vanilla voting NPCs; missing configuration preserves legacy behavior. */
|
||||
public boolean isVotingNPCsEnabled() { return getData().getBoolean("VotingNPCs.Enabled", false); }
|
||||
|
||||
|
||||
/** Explicit permission for executable deployment over private-network plaintext HTTP. */
|
||||
public boolean getControlAllowInsecureHttpPluginDeployment() {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
|
||||
/** Normal /av command handlers; no separate command map or player GUI registry. */
|
||||
public final class VotingNPCCommands {
|
||||
private static final String PERMISSION = "VotingPlugin.Commands.AdminVote.NPC";
|
||||
private VotingNPCCommands() { }
|
||||
|
||||
public static void register(VotingPluginMain plugin) {
|
||||
add(plugin, new String[] { "NPC", "Create", "(string)" }, false);
|
||||
add(plugin, new String[] { "NPC", "Create", "(string)", "(list)" }, false);
|
||||
add(plugin, new String[] { "NPC", "List" }, true);
|
||||
add(plugin, new String[] { "NPC", "Inspect", "(npcid)" }, true);
|
||||
add(plugin, new String[] { "NPC", "Remove", "(npcid)" }, true);
|
||||
}
|
||||
|
||||
private static void add(VotingPluginMain plugin, String[] pattern, boolean console) {
|
||||
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, pattern, PERMISSION + "|VotingPlugin.Admin",
|
||||
"Manage saved vanilla voting NPCs", console) {
|
||||
@Override public void execute(CommandSender sender, String[] args) {
|
||||
// CommandHandler invokes execute asynchronously. Capture location,
|
||||
// permission and messages only after reaching the sender's owner.
|
||||
String[] captured = args.clone();
|
||||
onSender(plugin, sender, () -> executeOwned(plugin, sender, captured));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean allowed(CommandSender sender) {
|
||||
return (!(sender instanceof Player player) || player.isOnline())
|
||||
&& (sender.hasPermission(PERMISSION) || sender.hasPermission("VotingPlugin.Admin"));
|
||||
}
|
||||
|
||||
private static void onSender(VotingPluginMain plugin, CommandSender sender, Runnable action) {
|
||||
Player player = sender instanceof Player candidate ? candidate : null;
|
||||
BukkitCompletionScheduler.run(plugin, player, action, player == null ? action : () -> { }, () -> { });
|
||||
}
|
||||
|
||||
private static void executeOwned(VotingPluginMain plugin, CommandSender sender, String[] args) {
|
||||
if (!allowed(sender)) return;
|
||||
VotingNPCManager manager = plugin.getVotingNPCManager();
|
||||
if (manager == null || !manager.available()) {
|
||||
sender.sendMessage("Voting NPCs are disabled, unsupported, or waiting for valid storage. Check VotingNPCs.Enabled in Config.yml and reload.");
|
||||
return;
|
||||
}
|
||||
switch (args[1].toLowerCase(java.util.Locale.ROOT)) {
|
||||
case "create":
|
||||
if (!(sender instanceof Player player)) { sender.sendMessage("Create a voting NPC from in game."); return; }
|
||||
try {
|
||||
Location at = player.getLocation();
|
||||
String name = args.length > 3 ? String.join(" ", Arrays.copyOfRange(args, 3, args.length)) : "&aVote";
|
||||
VotingNPCDefinition definition = new VotingNPCDefinition(args[2], UUID.randomUUID(), at.getWorld().getUID(),
|
||||
at.getX(), at.getY(), at.getZ(), at.getYaw(), at.getPitch(), name);
|
||||
confirm(plugin, sender, manager, manager.create(definition),
|
||||
ignored -> "Saved voting NPC '" + definition.id() + "'. Its villager appears when the area is loaded and available.");
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Use a lowercase ID of 1-40 letters, digits, underscores or hyphens, and a name of 1-128 characters without controls.");
|
||||
}
|
||||
break;
|
||||
case "remove":
|
||||
confirm(plugin, sender, manager, manager.remove(args[2]), removed -> removed
|
||||
? "Removed saved voting NPC '" + args[2] + "'; exact entity cleanup has been requested."
|
||||
: "No voting NPC has that ID.");
|
||||
break;
|
||||
case "inspect":
|
||||
manager.definitions().stream().filter(value -> value.id().equals(args[2])).findFirst().ifPresentOrElse(value ->
|
||||
sender.sendMessage("Voting NPC " + value.id() + ": world " + value.world() + ", "
|
||||
+ value.x() + ", " + value.y() + ", " + value.z() + "; " + manager.runtimeState(value.id())),
|
||||
() -> sender.sendMessage("No voting NPC has that ID."));
|
||||
break;
|
||||
case "list":
|
||||
sender.sendMessage("Saved voting NPCs: " + manager.definitions().size() + "/64; tracked runtime slots: " + manager.runtimeCount());
|
||||
for (var value : manager.definitions()) sender.sendMessage(value.id() + ": " + manager.runtimeState(value.id()));
|
||||
break;
|
||||
default:
|
||||
sender.sendMessage("Use /av npc create <id> [name], list, inspect <id>, or remove <id>.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String failureMessage(VotingNPCManager manager, Throwable failure) {
|
||||
while ((failure instanceof java.util.concurrent.CompletionException
|
||||
|| failure instanceof java.util.concurrent.ExecutionException) && failure.getCause() != null) {
|
||||
failure = failure.getCause();
|
||||
}
|
||||
if (manager.available()) {
|
||||
if (failure instanceof VotingNPCRegistry.ValidationFailure validation) {
|
||||
return switch (validation.reason) {
|
||||
case DUPLICATE_ID -> "That voting NPC ID already exists. Use a different ID or remove it first.";
|
||||
case DUPLICATE_IDENTITY -> "That voting NPC identity already exists. No definition was changed.";
|
||||
case LIMIT -> "The 64 voting NPC limit has been reached. Remove an NPC before creating another.";
|
||||
};
|
||||
}
|
||||
if (failure instanceof java.util.concurrent.RejectedExecutionException) {
|
||||
return "Voting NPC storage is busy. Wait for pending changes to finish before retrying.";
|
||||
}
|
||||
}
|
||||
return "Voting NPC change was not confirmed. Check storage and reload before retrying; no success is assumed.";
|
||||
}
|
||||
|
||||
private static <T> void confirm(VotingPluginMain plugin, CommandSender sender, VotingNPCManager manager, CompletionStage<T> completion,
|
||||
java.util.function.Function<T, String> message) {
|
||||
completion.whenComplete((value, failure) -> onSender(plugin, sender, () -> {
|
||||
if (!allowed(sender)) return;
|
||||
sender.sendMessage(failure == null ? message.apply(value)
|
||||
: failureMessage(manager, failure));
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Durable definition; it contains no live world, player, entity or reward state. */
|
||||
public record VotingNPCDefinition(String id, UUID identity, UUID world, double x, double y, double z,
|
||||
float yaw, float pitch, String name) {
|
||||
private static final Pattern ID = Pattern.compile("[a-z0-9][a-z0-9_-]{0,39}");
|
||||
|
||||
public VotingNPCDefinition {
|
||||
if (id == null || !ID.matcher(id).matches()) throw new IllegalArgumentException("Invalid voting NPC ID");
|
||||
Objects.requireNonNull(identity, "identity");
|
||||
Objects.requireNonNull(world, "world");
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||
|| Math.abs(x) > 30_000_000 || Math.abs(z) > 30_000_000
|
||||
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
|
||||
throw new IllegalArgumentException("Invalid voting NPC position");
|
||||
}
|
||||
if (name == null || name.isEmpty() || name.length() > 128
|
||||
|| name.codePoints().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException("Invalid voting NPC name");
|
||||
}
|
||||
}
|
||||
|
||||
public int chunkX() { return Math.floorDiv((int) Math.floor(x), 16); }
|
||||
public int chunkZ() { return Math.floorDiv((int) Math.floor(z), 16); }
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
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.entity.EntityTransformEvent;
|
||||
import org.bukkit.event.player.PlayerInteractAtEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.vehicle.VehicleEnterEvent;
|
||||
import org.bukkit.event.world.ChunkLoadEvent;
|
||||
import org.bukkit.event.world.ChunkUnloadEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.event.world.WorldUnloadEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
|
||||
/** Persistent definitions, transient public villagers, and no vote/reward accounting. */
|
||||
public final class VotingNPCManager implements Listener, AutoCloseable {
|
||||
private final VotingPluginMain plugin;
|
||||
private final VotingNPCScheduler scheduler;
|
||||
private final Consumer<Player> open;
|
||||
private final Map<String, RuntimeNPC> resources = new ConcurrentHashMap<>();
|
||||
private final Set<String> capacityDemand = ConcurrentHashMap.newKeySet();
|
||||
private final AtomicBoolean capacityWakeQueued = new AtomicBoolean();
|
||||
private final Map<UUID, RuntimeNPC> entities = new ConcurrentHashMap<>();
|
||||
private final Set<ChunkKey> unloadingChunks = ConcurrentHashMap.newKeySet();
|
||||
private final Set<UUID> unloadingWorlds = ConcurrentHashMap.newKeySet();
|
||||
private final Map<UUID, Long> recentClicks = new LinkedHashMap<>();
|
||||
private volatile VotingNPCRegistry registry;
|
||||
private volatile boolean enabled;
|
||||
private volatile boolean closed;
|
||||
private volatile long configuration;
|
||||
private final AtomicBoolean warned = new AtomicBoolean();
|
||||
|
||||
private record ChunkKey(UUID world, int x, int z) { }
|
||||
private static final class RuntimeNPC {
|
||||
final VotingNPCDefinition definition;
|
||||
final Location anchor;
|
||||
final long configuration;
|
||||
boolean closed, cleanupQueued, reconstructRequested;
|
||||
Villager entity;
|
||||
UUID entityId;
|
||||
RuntimeNPC(VotingNPCDefinition definition, World world, long configuration) {
|
||||
this.definition = definition;
|
||||
this.configuration = configuration;
|
||||
anchor = new Location(world, definition.x(), definition.y(), definition.z(), definition.yaw(), definition.pitch());
|
||||
}
|
||||
}
|
||||
|
||||
public VotingNPCManager(VotingPluginMain plugin) {
|
||||
this(plugin, new VotingNPCScheduler(plugin), null, player -> openVotingSites(plugin, player));
|
||||
}
|
||||
|
||||
static void openVotingSites(VotingPluginMain plugin, Player player) {
|
||||
// Reuse the normal command's permissions, asynchronous user loading,
|
||||
// configured GUI, cooldown display, and URL handling; no root alias.
|
||||
for (var handler : plugin.getVoteCommand()) {
|
||||
if (handler.runCommand(player, new String[] { "URL" })) return;
|
||||
}
|
||||
player.sendMessage("Voting-site selection is unavailable.");
|
||||
}
|
||||
|
||||
VotingNPCManager(VotingPluginMain plugin, VotingNPCScheduler scheduler, VotingNPCRegistry registry, Consumer<Player> open) {
|
||||
this.plugin = plugin;
|
||||
this.scheduler = scheduler;
|
||||
this.registry = registry;
|
||||
this.open = open;
|
||||
}
|
||||
|
||||
public 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; }
|
||||
}
|
||||
|
||||
/** Configuration callers do no file I/O and do not wait for storage. */
|
||||
public void configure(boolean requested) {
|
||||
VotingNPCRegistry owner;
|
||||
CompletionStage<List<VotingNPCDefinition>> loading;
|
||||
long generation;
|
||||
boolean activate;
|
||||
synchronized (this) {
|
||||
if (closed) return;
|
||||
generation = ++configuration;
|
||||
activate = requested && supported();
|
||||
enabled = activate;
|
||||
if (!activate) { owner = null; loading = null; }
|
||||
else {
|
||||
if (registry == null) {
|
||||
registry = new VotingNPCRegistry(plugin.getDataFolder().toPath().resolve("VotingNPCs.json"));
|
||||
loading = registry.initialLoad();
|
||||
} else loading = registry.reload();
|
||||
owner = registry;
|
||||
}
|
||||
}
|
||||
if (!activate) {
|
||||
for (RuntimeNPC npc : resources.values()) retire(npc);
|
||||
if (requested) report(new IllegalStateException("Vanilla voting NPC API is unavailable"));
|
||||
return;
|
||||
}
|
||||
loading.whenComplete((definitions, failure) -> {
|
||||
synchronized (VotingNPCManager.this) {
|
||||
if (closed || !enabled || generation != configuration || registry != owner) return;
|
||||
}
|
||||
if (failure != null) {
|
||||
for (RuntimeNPC npc : resources.values()) retire(npc);
|
||||
report(failure);
|
||||
} else {
|
||||
warned.set(false);
|
||||
reconcile();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public boolean available() {
|
||||
VotingNPCRegistry owner = registry;
|
||||
return !closed && enabled && owner != null && owner.available();
|
||||
}
|
||||
|
||||
public List<VotingNPCDefinition> definitions() {
|
||||
VotingNPCRegistry owner = registry;
|
||||
return owner == null ? List.of() : owner.snapshot();
|
||||
}
|
||||
|
||||
/** Returns durable definition completion; runtime creation can still await a loaded chunk. */
|
||||
public CompletionStage<VotingNPCDefinition> create(VotingNPCDefinition definition) {
|
||||
VotingNPCRegistry owner = registry;
|
||||
if (!available()) return CompletableFuture.failedFuture(new IllegalStateException("Enable VotingNPCs and load valid storage first"));
|
||||
CompletionStage<VotingNPCDefinition> completion = owner.create(definition);
|
||||
completion.whenComplete((value, failure) -> mutationFinished(owner, failure));
|
||||
return completion;
|
||||
}
|
||||
|
||||
public CompletionStage<Boolean> remove(String id) {
|
||||
VotingNPCRegistry owner = registry;
|
||||
if (!available()) return CompletableFuture.failedFuture(new IllegalStateException("Enable VotingNPCs and load valid storage first"));
|
||||
CompletionStage<Boolean> completion = owner.remove(id);
|
||||
completion.whenComplete((value, failure) -> mutationFinished(owner, failure));
|
||||
return completion;
|
||||
}
|
||||
|
||||
private void mutationFinished(VotingNPCRegistry owner, Throwable failure) {
|
||||
if (owner != registry || closed) return;
|
||||
if (failure == null) reconcile();
|
||||
else if (!owner.available()) {
|
||||
for (RuntimeNPC npc : resources.values()) retire(npc);
|
||||
report(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean current(RuntimeNPC npc) {
|
||||
return available() && definitions().contains(npc.definition);
|
||||
}
|
||||
|
||||
private void reconcile() {
|
||||
retryPendingCleanup();
|
||||
capacityDemand.retainAll(definitions().stream().map(VotingNPCDefinition::id).toList());
|
||||
List<VotingNPCDefinition> snapshot = definitions();
|
||||
Set<ChunkKey> chunks = new HashSet<>();
|
||||
Set<UUID> worlds = new HashSet<>();
|
||||
for (var value : snapshot) { chunks.addAll(occupiedChunks(value)); worlds.add(value.world()); }
|
||||
unloadingChunks.retainAll(chunks);
|
||||
unloadingWorlds.retainAll(worlds);
|
||||
for (RuntimeNPC npc : resources.values()) if (!current(npc)) retire(npc);
|
||||
if (!available()) return;
|
||||
scheduler.global(() -> {
|
||||
if (!available()) return;
|
||||
for (var value : definitions()) ensure(value);
|
||||
}, this::report);
|
||||
}
|
||||
|
||||
/** Same conservative half-block bounds used for creation ownership and chunk availability. */
|
||||
private static Set<ChunkKey> occupiedChunks(VotingNPCDefinition value) {
|
||||
Set<ChunkKey> chunks = new HashSet<>();
|
||||
for (double dx : new double[] { -.5, .5 }) for (double dz : new double[] { -.5, .5 }) {
|
||||
chunks.add(new ChunkKey(value.world(), (int) Math.floor(value.x() + dx) >> 4,
|
||||
(int) Math.floor(value.z() + dz) >> 4));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
private boolean unloading(VotingNPCDefinition value) {
|
||||
return occupiedChunks(value).stream().anyMatch(unloadingChunks::contains);
|
||||
}
|
||||
|
||||
private void ensure(VotingNPCDefinition value) {
|
||||
if (!available() || !definitions().contains(value) || unloadingWorlds.contains(value.world()) || unloading(value)) return;
|
||||
World world = Bukkit.getWorld(value.world());
|
||||
if (world == null) return;
|
||||
RuntimeNPC npc = new RuntimeNPC(value, world, configuration);
|
||||
RuntimeNPC previous;
|
||||
synchronized (resources) {
|
||||
previous = resources.get(value.id());
|
||||
if (previous == null) {
|
||||
if (resources.size() >= VotingNPCStore.MAX_NPCS) {
|
||||
if (capacityDemand.size() < VotingNPCStore.MAX_NPCS) capacityDemand.add(value.id());
|
||||
return;
|
||||
}
|
||||
resources.put(value.id(), npc);
|
||||
capacityDemand.remove(value.id());
|
||||
}
|
||||
}
|
||||
if (previous != null) {
|
||||
synchronized (previous) { if (previous.closed) previous.reconstructRequested = true; }
|
||||
return;
|
||||
}
|
||||
scheduler.region(npc.anchor, () -> spawn(npc), failure -> { retire(npc); report(failure); });
|
||||
}
|
||||
|
||||
private boolean loadedArea(RuntimeNPC npc) {
|
||||
World world = npc.anchor.getWorld();
|
||||
if (Bukkit.getWorld(npc.definition.world()) != world || unloadingWorlds.contains(npc.definition.world())
|
||||
|| unloading(npc.definition)) 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 (!scheduler.owns(edge) || !world.isChunkLoaded(edge.getBlockX() >> 4, edge.getBlockZ() >> 4)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void spawn(RuntimeNPC npc) {
|
||||
try {
|
||||
synchronized (npc) {
|
||||
if (npc.closed || !current(npc) || !loadedArea(npc)) {
|
||||
boolean capacityReleased = !npc.closed && !current(npc);
|
||||
forget(npc, capacityReleased);
|
||||
return;
|
||||
}
|
||||
if (npc.entity != null) return;
|
||||
Villager spawned = npc.anchor.getWorld().spawn(npc.anchor, Villager.class, villager -> {
|
||||
npc.entity = villager;
|
||||
npc.entityId = villager.getUniqueId();
|
||||
entities.put(npc.entityId, npc);
|
||||
villager.setPersistent(false);
|
||||
villager.setAI(false);
|
||||
villager.setGravity(false);
|
||||
villager.setInvulnerable(true);
|
||||
villager.setSilent(true);
|
||||
villager.setCollidable(false);
|
||||
villager.setCanPickupItems(false);
|
||||
villager.setRemoveWhenFarAway(false);
|
||||
villager.setCustomName(ChatColor.translateAlternateColorCodes('&', npc.definition.name()));
|
||||
villager.setCustomNameVisible(true);
|
||||
});
|
||||
if (spawned != npc.entity || !spawned.isValid() || !current(npc) || npc.closed) {
|
||||
throw new IllegalStateException("Voting NPC spawn was cancelled or superseded");
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { retire(npc); report(failure); }
|
||||
}
|
||||
|
||||
private void retire(RuntimeNPC npc) {
|
||||
Villager entity;
|
||||
synchronized (npc) {
|
||||
npc.closed = true;
|
||||
entity = npc.entity;
|
||||
if (entity == null) { forget(npc, false); return; }
|
||||
if (npc.cleanupQueued) return;
|
||||
npc.cleanupQueued = true;
|
||||
}
|
||||
scheduler.entity(entity, () -> {
|
||||
try { entity.remove(); forget(npc); }
|
||||
catch (RuntimeException | LinkageError failure) { cleanupFailed(npc, failure); }
|
||||
}, () -> forget(npc), failure -> cleanupFailed(npc, failure));
|
||||
}
|
||||
|
||||
private void cleanupFailed(RuntimeNPC npc, Throwable failure) {
|
||||
synchronized (npc) {
|
||||
npc.cleanupQueued = false;
|
||||
npc.reconstructRequested = true;
|
||||
}
|
||||
// Keep the exact identity and capacity reservation. Rejected scheduling
|
||||
// is not successful removal and must not permit duplicate reconstruction.
|
||||
report(failure);
|
||||
}
|
||||
|
||||
private void retryPendingCleanup() {
|
||||
for (RuntimeNPC npc : resources.values()) {
|
||||
synchronized (npc) {
|
||||
if (!npc.closed || !npc.reconstructRequested || npc.cleanupQueued || npc.entity == null) continue;
|
||||
}
|
||||
retire(npc);
|
||||
}
|
||||
}
|
||||
|
||||
private void forget(RuntimeNPC npc) { forget(npc, false); }
|
||||
|
||||
private void forget(RuntimeNPC npc, boolean capacityReleased) {
|
||||
boolean reconstruct, removed;
|
||||
synchronized (npc) {
|
||||
reconstruct = npc.entity != null && (npc.reconstructRequested || npc.configuration != configuration
|
||||
|| definitions().stream().anyMatch(value -> value.id().equals(npc.definition.id()) && !value.equals(npc.definition)));
|
||||
if (npc.entityId != null) entities.remove(npc.entityId, npc);
|
||||
npc.entity = null;
|
||||
removed = resources.remove(npc.definition.id(), npc);
|
||||
}
|
||||
// A successor sharing this configured ID must wait for exact predecessor
|
||||
// cleanup. Missing/unloaded spawn attempts do not recursively retry.
|
||||
if (reconstruct && available()) scheduler.global(() -> {
|
||||
for (var value : definitions()) if (value.id().equals(npc.definition.id())) ensure(value);
|
||||
}, this::report);
|
||||
if (removed && !capacityDemand.isEmpty() && available()
|
||||
&& capacityWakeQueued.compareAndSet(false, true)) {
|
||||
scheduler.global(() -> {
|
||||
capacityWakeQueued.set(false);
|
||||
for (var value : definitions()) if (capacityDemand.contains(value.id())) ensure(value);
|
||||
}, failure -> {
|
||||
capacityWakeQueued.set(false);
|
||||
report(failure);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void report(Throwable failure) {
|
||||
if (warned.compareAndSet(false, true)) {
|
||||
try {
|
||||
plugin.getLogger().warning("Voting NPCs could not complete an operation; definitions are retained. Check API support and VotingNPCs.json, then reload. Cause: "
|
||||
+ failure.getClass().getSimpleName());
|
||||
} catch (RuntimeException ignored) { /* Reporting must not prevent independent cleanup. */ }
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void interact(PlayerInteractEntityEvent event) {
|
||||
RuntimeNPC npc = entities.get(event.getRightClicked().getUniqueId());
|
||||
if (npc == null) return;
|
||||
boolean cancelled = event.isCancelled();
|
||||
event.setCancelled(true); // Only this exact owned entity; never trading.
|
||||
if (cancelled || event.getHand() != EquipmentSlot.HAND || !current(npc)) return;
|
||||
synchronized (npc) { if (npc.closed) { retire(npc); return; } }
|
||||
Player player = event.getPlayer();
|
||||
long now = System.nanoTime();
|
||||
synchronized (recentClicks) {
|
||||
recentClicks.entrySet().removeIf(entry -> now - entry.getValue() >= 300_000_000L);
|
||||
if (recentClicks.containsKey(player.getUniqueId()) || recentClicks.size() >= 256) return;
|
||||
recentClicks.put(player.getUniqueId(), now);
|
||||
}
|
||||
BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
if (!player.isOnline() || !current(npc) || !player.getWorld().getUID().equals(npc.definition.world())
|
||||
|| player.getLocation().distanceSquared(npc.anchor) > 36) return;
|
||||
synchronized (npc) { if (npc.closed) return; }
|
||||
open.accept(player);
|
||||
}, () -> { }, () -> { });
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void interactAt(PlayerInteractAtEntityEvent event) { interact(event); }
|
||||
private boolean owned(Entity entity) { return entity != null && entities.containsKey(entity.getUniqueId()); }
|
||||
@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 combust(EntityCombustEvent 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 transform(EntityTransformEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void target(EntityTargetEvent event) { if (owned(event.getEntity()) || owned(event.getTarget())) event.setCancelled(true); }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void blockInteract(EntityInteractEvent 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 public void quit(PlayerQuitEvent event) { synchronized (recentClicks) { recentClicks.remove(event.getPlayer().getUniqueId()); } }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void chunkLoad(ChunkLoadEvent event) {
|
||||
UUID world = event.getWorld().getUID();
|
||||
unloadingChunks.remove(new ChunkKey(world, event.getChunk().getX(), event.getChunk().getZ()));
|
||||
retryPendingCleanup();
|
||||
ChunkKey chunk = new ChunkKey(world, event.getChunk().getX(), event.getChunk().getZ());
|
||||
for (var value : definitions()) if (occupiedChunks(value).contains(chunk)) ensure(value);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void chunkUnload(ChunkUnloadEvent event) {
|
||||
ChunkKey chunk = new ChunkKey(event.getWorld().getUID(), event.getChunk().getX(), event.getChunk().getZ());
|
||||
if (definitions().stream().noneMatch(value -> occupiedChunks(value).contains(chunk))) return;
|
||||
unloadingChunks.add(chunk);
|
||||
for (RuntimeNPC npc : resources.values()) if (occupiedChunks(npc.definition).contains(chunk)) retire(npc);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void worldLoad(WorldLoadEvent event) {
|
||||
UUID world = event.getWorld().getUID();
|
||||
if (definitions().stream().noneMatch(value -> value.world().equals(world))) return;
|
||||
unloadingWorlds.remove(world);
|
||||
reconcile();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void worldUnload(WorldUnloadEvent event) {
|
||||
UUID world = event.getWorld().getUID();
|
||||
if (definitions().stream().noneMatch(value -> value.world().equals(world))) return;
|
||||
unloadingWorlds.add(world);
|
||||
for (RuntimeNPC npc : resources.values()) if (npc.definition.world().equals(world)) retire(npc);
|
||||
}
|
||||
|
||||
public int runtimeCount() { return resources.size(); }
|
||||
|
||||
public String runtimeState(String id) {
|
||||
RuntimeNPC npc = resources.get(id);
|
||||
if (npc == null) return "waiting for a loaded, available area";
|
||||
synchronized (npc) {
|
||||
if (npc.closed) return "cleanup pending";
|
||||
return npc.entity == null ? "creation scheduled" : "villager created";
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
synchronized (this) { if (closed) return; closed = true; enabled = false; ++configuration; }
|
||||
VotingNPCRegistry owner = registry;
|
||||
if (owner != null) owner.close();
|
||||
for (RuntimeNPC npc : new ArrayList<>(resources.values())) retire(npc);
|
||||
synchronized (recentClicks) { recentClicks.clear(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* One bounded, daemon file owner. Completion means the definition was persisted,
|
||||
* not that a world entity has spawned. Callbacks have no player/region ownership.
|
||||
*/
|
||||
final class VotingNPCRegistry implements AutoCloseable {
|
||||
enum Rejection { DUPLICATE_ID, DUPLICATE_IDENTITY, LIMIT }
|
||||
static final class ValidationFailure extends IllegalArgumentException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
final Rejection reason;
|
||||
ValidationFailure(Rejection reason) { super("Voting NPC request rejected"); this.reason = reason; }
|
||||
}
|
||||
|
||||
private final Object admission = new Object();
|
||||
private final ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(128);
|
||||
private final CountDownLatch stopped = new CountDownLatch(1);
|
||||
private final VotingNPCStore store;
|
||||
private final Path lockPath;
|
||||
private final CompletableFuture<List<VotingNPCDefinition>> initial;
|
||||
private volatile Map<String, VotingNPCDefinition> definitions = Map.of();
|
||||
private volatile boolean available;
|
||||
private volatile boolean closed;
|
||||
// Only the file worker accesses these resources.
|
||||
private FileChannel lockChannel;
|
||||
private FileLock lease;
|
||||
private boolean storageReady;
|
||||
|
||||
VotingNPCRegistry(Path file) {
|
||||
this(file, new VotingNPCStore(file));
|
||||
}
|
||||
|
||||
VotingNPCRegistry(Path file, VotingNPCStore store) {
|
||||
this.store = store;
|
||||
lockPath = file.toAbsolutePath().normalize().resolveSibling(file.getFileName() + ".lock");
|
||||
initial = submit(false, this::readDefinitions);
|
||||
Thread thread = new Thread(this::work, "VotingPlugin-NPC-storage");
|
||||
thread.setDaemon(true);
|
||||
try { thread.start(); }
|
||||
catch (RuntimeException | Error failure) {
|
||||
closed = true;
|
||||
initial.completeExceptionally(failure);
|
||||
stopped.countDown();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
CompletionStage<List<VotingNPCDefinition>> initialLoad() { return initial; }
|
||||
boolean available() { return !closed && available; }
|
||||
List<VotingNPCDefinition> snapshot() { return List.copyOf(definitions.values()); }
|
||||
|
||||
CompletionStage<List<VotingNPCDefinition>> reload() {
|
||||
synchronized (admission) {
|
||||
if (closed) return rejected("Voting NPC registry is closed");
|
||||
var completion = submit(false, this::readDefinitions);
|
||||
// A rejected reload changes nothing. The worker publishes readiness
|
||||
// under this same short lock, after admission has finished.
|
||||
if (!completion.isCompletedExceptionally()) available = false;
|
||||
return completion;
|
||||
}
|
||||
}
|
||||
|
||||
CompletionStage<VotingNPCDefinition> create(VotingNPCDefinition definition) {
|
||||
return submit(true, () -> {
|
||||
if (definition == null) throw new IllegalArgumentException("Voting NPC definition is required");
|
||||
if (definitions.containsKey(definition.id())) throw new ValidationFailure(Rejection.DUPLICATE_ID);
|
||||
if (definitions.values().stream().anyMatch(value -> value.identity().equals(definition.identity()))) {
|
||||
throw new ValidationFailure(Rejection.DUPLICATE_IDENTITY);
|
||||
}
|
||||
if (definitions.size() >= VotingNPCStore.MAX_NPCS) throw new ValidationFailure(Rejection.LIMIT);
|
||||
var next = new LinkedHashMap<>(definitions);
|
||||
next.put(definition.id(), definition);
|
||||
publish(next);
|
||||
return definition;
|
||||
});
|
||||
}
|
||||
|
||||
CompletionStage<Boolean> remove(String id) {
|
||||
return submit(true, () -> {
|
||||
if (!definitions.containsKey(id)) return false;
|
||||
var next = new LinkedHashMap<>(definitions);
|
||||
next.remove(id);
|
||||
publish(next);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private List<VotingNPCDefinition> readDefinitions() throws IOException {
|
||||
try {
|
||||
ownFile();
|
||||
List<VotingNPCDefinition> loaded = store.load();
|
||||
var next = new LinkedHashMap<String, VotingNPCDefinition>();
|
||||
for (VotingNPCDefinition value : loaded) next.put(value.id(), value);
|
||||
definitions = java.util.Collections.unmodifiableMap(next);
|
||||
storageReady = true;
|
||||
synchronized (admission) { available = true; }
|
||||
return loaded;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
storageReady = false;
|
||||
synchronized (admission) { available = false; }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private void publish(LinkedHashMap<String, VotingNPCDefinition> next) throws IOException {
|
||||
// The worker holds the process-independent lease through shutdown drain.
|
||||
try { store.save(List.copyOf(next.values())); }
|
||||
catch (IOException | RuntimeException | Error failure) {
|
||||
// A failed write may already be visible. Neither later admitted
|
||||
// mutations nor a new request may overwrite it from the old map.
|
||||
storageReady = false;
|
||||
synchronized (admission) { available = false; }
|
||||
throw failure;
|
||||
}
|
||||
definitions = java.util.Collections.unmodifiableMap(next);
|
||||
}
|
||||
|
||||
private <T> CompletableFuture<T> submit(boolean needsReady, Callable<T> action) {
|
||||
synchronized (admission) {
|
||||
if (closed || (needsReady && !available)) return rejected("Voting NPC storage is not available");
|
||||
var completion = new CompletableFuture<T>();
|
||||
Runnable task = () -> {
|
||||
try {
|
||||
if (needsReady && !storageReady) throw new RejectedExecutionException("Reload valid voting NPC storage before editing");
|
||||
completion.complete(action.call());
|
||||
}
|
||||
catch (Throwable failure) { completion.completeExceptionally(failure); }
|
||||
};
|
||||
if (!queue.offer(task)) completion.completeExceptionally(new RejectedExecutionException("Voting NPC storage queue is full"));
|
||||
return completion;
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> CompletableFuture<T> rejected(String message) {
|
||||
return CompletableFuture.failedFuture(new RejectedExecutionException(message));
|
||||
}
|
||||
|
||||
private void ownFile() throws IOException {
|
||||
if (lease != null && lease.isValid()) return;
|
||||
if (!Files.isDirectory(lockPath.getParent().toRealPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Voting NPC data directory is unavailable");
|
||||
}
|
||||
FileChannel candidate = FileChannel.open(lockPath, StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
try {
|
||||
FileLock acquired;
|
||||
try { acquired = candidate.tryLock(); }
|
||||
catch (OverlappingFileLockException overlap) { throw new IOException("Voting NPC file still has an active owner", overlap); }
|
||||
if (acquired == null) throw new IOException("Voting NPC file still has an active owner");
|
||||
lockChannel = candidate;
|
||||
lease = acquired;
|
||||
} finally {
|
||||
if (lockChannel != candidate) candidate.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void work() {
|
||||
try {
|
||||
while (!closed || !queue.isEmpty()) {
|
||||
Runnable task;
|
||||
try { task = queue.take(); }
|
||||
catch (InterruptedException interrupted) { continue; }
|
||||
task.run();
|
||||
}
|
||||
} finally {
|
||||
available = false;
|
||||
try {
|
||||
if (lockChannel != null) lockChannel.close();
|
||||
} catch (IOException ignored) {
|
||||
// Closing this channel releases its OS lease; never do this I/O
|
||||
// under an admission or executor lock on a gameplay caller.
|
||||
} finally { stopped.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops admission without blocking or interrupting an accepted file write. */
|
||||
@Override public void close() {
|
||||
synchronized (admission) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
queue.offer(() -> { }); // Wake an idle worker; a full queue already wakes it.
|
||||
}
|
||||
}
|
||||
|
||||
/** Test/owner-worker observation only; gameplay callers must never wait here. */
|
||||
boolean awaitClosed(long time, TimeUnit unit) throws InterruptedException { return stopped.await(time, unit); }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
|
||||
|
||||
/** World creation uses a region; subsequent cleanup follows the actual entity. */
|
||||
class VotingNPCScheduler {
|
||||
private final VotingPluginMain plugin;
|
||||
private final Method locationOwner;
|
||||
private final Method entityOwner;
|
||||
|
||||
VotingNPCScheduler(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
Method location = null, entity = null;
|
||||
try {
|
||||
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
|
||||
location = Bukkit.class.getMethod("isOwnedByCurrentRegion", Location.class);
|
||||
entity = Bukkit.class.getMethod("isOwnedByCurrentRegion", Entity.class);
|
||||
} catch (ClassNotFoundException ordinaryServer) {
|
||||
// Spigot/Paper have a single world-owner thread.
|
||||
} catch (ReflectiveOperationException unavailable) {
|
||||
throw new IllegalStateException("Voting NPC region ownership API is unavailable", unavailable);
|
||||
}
|
||||
locationOwner = location;
|
||||
entityOwner = entity;
|
||||
}
|
||||
|
||||
boolean owns(Location location) { return owns(locationOwner, location); }
|
||||
boolean owns(Entity entity) { return owns(entityOwner, entity); }
|
||||
|
||||
private boolean owns(Method method, Object value) {
|
||||
if (method == null) return Bukkit.isPrimaryThread();
|
||||
try { return Boolean.TRUE.equals(method.invoke(null, value)); }
|
||||
catch (ReflectiveOperationException failure) { throw new IllegalStateException("Cannot verify voting NPC ownership", failure); }
|
||||
}
|
||||
|
||||
void global(Runnable action, Consumer<Throwable> failure) {
|
||||
try {
|
||||
plugin.getBukkitScheduler().getFoliaLib().getImpl().runNextTick(ignored -> action.run())
|
||||
.whenComplete((ignored, failed) -> { if (failed != null) failure.accept(failed); });
|
||||
} catch (RuntimeException | LinkageError rejected) { failure.accept(rejected); }
|
||||
}
|
||||
|
||||
void region(Location anchor, Runnable action, Consumer<Throwable> failure) {
|
||||
try {
|
||||
if (owns(anchor)) action.run();
|
||||
else plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(anchor, ignored -> action.run())
|
||||
.whenComplete((ignored, failed) -> { if (failed != null) failure.accept(failed); });
|
||||
} catch (RuntimeException | LinkageError rejected) { failure.accept(rejected); }
|
||||
}
|
||||
|
||||
void entity(Entity entity, Runnable action, Runnable retired, Consumer<Throwable> failure) {
|
||||
AtomicBoolean executed = new AtomicBoolean();
|
||||
Runnable run = () -> { if (executed.compareAndSet(false, true)) action.run(); };
|
||||
Runnable gone = () -> { if (executed.compareAndSet(false, true)) retired.run(); };
|
||||
try {
|
||||
if (owns(entity)) { run.run(); return; }
|
||||
plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(entity,
|
||||
ignored -> run.run(), gone).whenComplete((status, failed) -> {
|
||||
if (failed != null) failure.accept(failed);
|
||||
else if (status instanceof Enum<?> result && "ENTITY_RETIRED".equals(result.name())) gone.run();
|
||||
else if (!BukkitCompletionScheduler.isSuccessfulEntityTaskResult(status)) {
|
||||
// Scheduler retirement does not prove the entity was removed.
|
||||
failure.accept(new IllegalStateException("Voting NPC entity scheduler is unavailable"));
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | LinkageError rejected) { failure.accept(rejected); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import com.bencodez.votingplugin.util.DurableFiles;
|
||||
|
||||
/** Single storage-worker owner. Never call this file boundary on a gameplay thread. */
|
||||
class VotingNPCStore {
|
||||
static final int MAX_NPCS = 64;
|
||||
private static final int MAX_BYTES = 128 * 1024;
|
||||
private static final Set<String> FIELDS = Set.of("id", "identity", "world", "x", "y", "z", "yaw", "pitch", "name");
|
||||
private final Path file;
|
||||
private boolean initialized;
|
||||
private boolean uncertain;
|
||||
|
||||
VotingNPCStore(Path file) { this.file = file.toAbsolutePath().normalize(); }
|
||||
|
||||
synchronized List<VotingNPCDefinition> load() throws IOException {
|
||||
initialized = false;
|
||||
checkParent();
|
||||
BasicFileAttributes attributes;
|
||||
try { attributes = Files.readAttributes(file, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
|
||||
catch (NoSuchFileException missing) {
|
||||
if (uncertain) throw new IOException("Uncertain voting NPC definitions are missing", missing);
|
||||
initialized = true;
|
||||
return List.of();
|
||||
}
|
||||
if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() > MAX_BYTES) {
|
||||
throw new IOException("Voting NPC definitions are not a bounded regular file");
|
||||
}
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
}
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Voting NPC definitions exceed limit");
|
||||
String text;
|
||||
try { text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); }
|
||||
catch (CharacterCodingException invalid) { throw new IOException("Invalid voting NPC encoding", invalid); }
|
||||
List<VotingNPCDefinition> result = decode(text);
|
||||
uncertain = false;
|
||||
initialized = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
synchronized void save(List<VotingNPCDefinition> definitions) throws IOException {
|
||||
if (!initialized || uncertain) throw new IOException("Load valid voting NPC definitions before editing");
|
||||
validate(definitions);
|
||||
byte[] bytes = encode(definitions).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Voting NPC definitions exceed limit");
|
||||
checkParent();
|
||||
if (Files.isSymbolicLink(file)) throw new IOException("Voting NPC definitions cannot be a symbolic link");
|
||||
Path staged = Files.createTempFile(file.getParent(), ".voting-npcs-", ".tmp");
|
||||
boolean published = false;
|
||||
Throwable primary = null;
|
||||
try {
|
||||
try (FileChannel output = FileChannel.open(staged, StandardOpenOption.WRITE)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(staged, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
published = true;
|
||||
forceDirectory(file.getParent());
|
||||
} catch (IOException | RuntimeException | Error failure) {
|
||||
// A visible replacement followed by a failed directory sync is not a
|
||||
// rollback. Fence later mutations rather than overwrite it from old state.
|
||||
if (published) uncertain = true;
|
||||
primary = failure;
|
||||
throw failure;
|
||||
} finally {
|
||||
try { Files.deleteIfExists(staged); }
|
||||
catch (IOException cleanupFailure) {
|
||||
if (primary != null) primary.addSuppressed(cleanupFailure);
|
||||
else {
|
||||
if (published) uncertain = true;
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkParent() throws IOException {
|
||||
// Operators may place the data directory below a directory alias. Only
|
||||
// the definitions and lease files themselves must remain non-links.
|
||||
if (!Files.isDirectory(file.getParent().toRealPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Voting NPC data directory is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
/** Kept as a narrow I/O seam so failure-after-publication can be tested. */
|
||||
void forceDirectory(Path directory) throws IOException {
|
||||
DurableFiles.forceDirectory(directory);
|
||||
}
|
||||
|
||||
private static void validate(List<VotingNPCDefinition> values) throws IOException {
|
||||
if (values == null || values.size() > MAX_NPCS) throw new IOException("Voting NPC definition limit exceeded");
|
||||
Set<String> ids = new HashSet<>();
|
||||
Set<UUID> identities = new HashSet<>();
|
||||
for (VotingNPCDefinition value : values) {
|
||||
if (value == null || !ids.add(value.id()) || !identities.add(value.identity())) {
|
||||
throw new IOException("Duplicate or missing voting NPC identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String encode(List<VotingNPCDefinition> values) throws IOException {
|
||||
var text = new java.io.StringWriter();
|
||||
try (JsonWriter writer = new JsonWriter(text)) {
|
||||
writer.setIndent(" ");
|
||||
writer.beginObject().name("version").value(1).name("npcs").beginArray();
|
||||
for (VotingNPCDefinition value : values) {
|
||||
writer.beginObject().name("id").value(value.id()).name("identity").value(value.identity().toString())
|
||||
.name("world").value(value.world().toString()).name("x").value(value.x()).name("y").value(value.y())
|
||||
.name("z").value(value.z()).name("yaw").value(value.yaw()).name("pitch").value(value.pitch())
|
||||
.name("name").value(value.name()).endObject();
|
||||
}
|
||||
writer.endArray().endObject();
|
||||
}
|
||||
return text.toString() + "\n";
|
||||
}
|
||||
|
||||
private static List<VotingNPCDefinition> decode(String text) throws IOException {
|
||||
try (JsonReader reader = new JsonReader(new StringReader(text))) {
|
||||
Set<String> keys = new HashSet<>();
|
||||
List<VotingNPCDefinition> values = null;
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String key = reader.nextName();
|
||||
if (!keys.add(key)) throw new IOException("Duplicate voting NPC document field");
|
||||
if (key.equals("version")) {
|
||||
if (reader.peek() != JsonToken.NUMBER || !reader.nextString().equals("1")) {
|
||||
throw new IOException("Unsupported voting NPC document version");
|
||||
}
|
||||
} else if (key.equals("npcs")) {
|
||||
values = new ArrayList<>();
|
||||
reader.beginArray();
|
||||
while (reader.hasNext()) {
|
||||
if (values.size() >= MAX_NPCS) throw new IOException("Voting NPC definition limit exceeded");
|
||||
values.add(readDefinition(reader));
|
||||
}
|
||||
reader.endArray();
|
||||
} else throw new IOException("Unknown voting NPC document field");
|
||||
}
|
||||
reader.endObject();
|
||||
if (!keys.equals(Set.of("version", "npcs")) || reader.peek() != JsonToken.END_DOCUMENT) {
|
||||
throw new IOException("Incomplete or trailing voting NPC document");
|
||||
}
|
||||
validate(values);
|
||||
return List.copyOf(values);
|
||||
} catch (IllegalArgumentException | IllegalStateException invalid) {
|
||||
throw new IOException("Invalid voting NPC definitions", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static VotingNPCDefinition readDefinition(JsonReader reader) throws IOException {
|
||||
var fields = new java.util.HashMap<String, String>();
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String field = reader.nextName();
|
||||
if (!FIELDS.contains(field) || fields.containsKey(field)) throw new IOException("Unknown or duplicate voting NPC field");
|
||||
boolean numeric = Set.of("x", "y", "z", "yaw", "pitch").contains(field);
|
||||
if (reader.peek() != (numeric ? JsonToken.NUMBER : JsonToken.STRING)) throw new IOException("Invalid voting NPC field type");
|
||||
fields.put(field, reader.nextString());
|
||||
}
|
||||
reader.endObject();
|
||||
if (!fields.keySet().equals(FIELDS)) throw new IOException("Missing voting NPC field");
|
||||
return new VotingNPCDefinition(fields.get("id"), uuid(fields.get("identity")), uuid(fields.get("world")),
|
||||
Double.parseDouble(fields.get("x")), Double.parseDouble(fields.get("y")), Double.parseDouble(fields.get("z")),
|
||||
Float.parseFloat(fields.get("yaw")), Float.parseFloat(fields.get("pitch")), fields.get("name"));
|
||||
}
|
||||
|
||||
private static UUID uuid(String text) {
|
||||
UUID value = UUID.fromString(text);
|
||||
if (!value.toString().equals(text)) throw new IllegalArgumentException("Noncanonical voting NPC identity");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1256,3 +1256,9 @@ DisableUpdateChecking: false
|
||||
# Disable checking permissions on tab complete
|
||||
# Could increase performance slightly, but probably not
|
||||
DisableAdvancedTab: false
|
||||
|
||||
# Optional public vanilla villagers opening the existing voting-site menu.
|
||||
# Enable and /av reload, then /av npc create <id> [name]. No Citizens required.
|
||||
# Definitions live in VotingNPCs.json; disabling keeps them but removes live NPCs.
|
||||
VotingNPCs:
|
||||
Enabled: false
|
||||
@@ -170,6 +170,8 @@ permissions:
|
||||
description: Allows mod commands
|
||||
VotingPlugin.Admin:
|
||||
description: Allows admincommands
|
||||
children:
|
||||
VotingPlugin.Commands.AdminVote: true
|
||||
VotingPlugin.TopVoter.Ignore:
|
||||
description: Allows you to be exempt from top voter
|
||||
VotingPlugin.NoRemind:
|
||||
@@ -195,3 +197,9 @@ permissions:
|
||||
VotingPlugin.Login.RemindVotes.All:
|
||||
description: Remind if player can vote on all votesites, rather than any
|
||||
default: false
|
||||
|
||||
VotingPlugin.Commands.AdminVote.NPC:
|
||||
description: Create, inspect, list and remove saved vanilla voting NPCs
|
||||
default: op
|
||||
children:
|
||||
VotingPlugin.Commands.AdminVote: true
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
class VotingNPCCommandsTest {
|
||||
private static class Fixture {
|
||||
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
final BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
final VotingNPCManager manager = mock(VotingNPCManager.class);
|
||||
final ArrayList<CommandHandler> handlers = new ArrayList<>();
|
||||
final LinkedBlockingQueue<Runnable> tasks = new LinkedBlockingQueue<>();
|
||||
final LinkedBlockingQueue<Runnable> asynchronous = new LinkedBlockingQueue<>();
|
||||
Fixture() {
|
||||
when(plugin.getAdminVoteCommand()).thenReturn(handlers);
|
||||
when(plugin.getVotingNPCManager()).thenReturn(manager);
|
||||
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
|
||||
when(manager.available()).thenReturn(true);
|
||||
doAnswer(inv -> { tasks.add(inv.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class));
|
||||
doAnswer(inv -> { tasks.add(inv.getArgument(1)); return null; })
|
||||
.when(scheduler).runTask(eq(plugin), any(Runnable.class));
|
||||
doAnswer(inv -> { asynchronous.add(inv.getArgument(1)); return null; })
|
||||
.when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
|
||||
VotingNPCCommands.register(plugin);
|
||||
}
|
||||
Player player() {
|
||||
Player player = mock(Player.class);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.hasPermission("VotingPlugin.Commands.AdminVote.NPC")).thenReturn(true);
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(new UUID(1, 0));
|
||||
// Location holds its world weakly; retain it as a real online player does.
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 4, 64, 4));
|
||||
return player;
|
||||
}
|
||||
CommandHandler command(String action, int arguments) {
|
||||
return handlers.stream().filter(value -> value.getArgs()[1].equals(action)
|
||||
&& value.getArgs().length == arguments).findFirst().orElseThrow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void usesOnlyDedicatedNpcPatternsAndExplicitAdminPermission() {
|
||||
var f = new Fixture();
|
||||
assertEquals(List.of(List.of("NPC", "Create", "(string)"), List.of("NPC", "Create", "(string)", "(list)"),
|
||||
List.of("NPC", "List"), List.of("NPC", "Inspect", "(npcid)"), List.of("NPC", "Remove", "(npcid)")),
|
||||
f.handlers.stream().map(value -> List.of(value.getArgs())).toList());
|
||||
for (var handler : f.handlers) assertEquals("VotingPlugin.Commands.AdminVote.NPC|VotingPlugin.Admin", handler.getPerm());
|
||||
}
|
||||
|
||||
@Test void metadataGrantsTheAdminRootGateWithoutGrantingOtherAdminLeaves() throws Exception {
|
||||
org.bukkit.plugin.PluginDescriptionFile description;
|
||||
try (var input = getClass().getResourceAsStream("/plugin.yml")) {
|
||||
assertNotNull(input); description = new org.bukkit.plugin.PluginDescriptionFile(input);
|
||||
}
|
||||
var permissions = description.getPermissions().stream().collect(java.util.stream.Collectors.toMap(
|
||||
permission -> permission.getName().toLowerCase(java.util.Locale.ROOT), java.util.function.Function.identity()));
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.isEnabled()).thenReturn(true);
|
||||
var pm = mock(org.bukkit.plugin.PluginManager.class);
|
||||
when(pm.getDefaultPermissions(false)).thenReturn(java.util.Set.of());
|
||||
when(pm.getPermission(anyString())).thenAnswer(call -> permissions.get(((String)call.getArgument(0)).toLowerCase(java.util.Locale.ROOT)));
|
||||
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
|
||||
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(pm);
|
||||
var server = mock(org.bukkit.Server.class); when(server.getPluginManager()).thenReturn(pm);
|
||||
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
|
||||
for (String grant : List.of("VotingPlugin.Commands.AdminVote.NPC", "VotingPlugin.Admin")) {
|
||||
var permissible = new org.bukkit.permissions.PermissibleBase(mock(org.bukkit.permissions.ServerOperator.class));
|
||||
permissible.addAttachment(plugin, grant, true);
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
when(sender.hasPermission(anyString())).thenAnswer(call -> permissible.hasPermission((String)call.getArgument(0)));
|
||||
var root = new org.bukkit.command.Command("av") {
|
||||
@Override public boolean execute(CommandSender sender, String label, String[] args) { return true; }
|
||||
};
|
||||
root.setPermission("VotingPlugin.Commands.AdminVote");
|
||||
assertTrue(root.testPermissionSilent(sender), grant);
|
||||
if (grant.endsWith(".NPC")) {
|
||||
assertFalse(permissible.hasPermission("VotingPlugin.Commands.AdminVote.Edit.Config"));
|
||||
assertFalse(permissible.hasPermission("VotingPlugin.Admin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void capturesPlayerStateOnlyOnOwnerAndSuccessOnlyAfterDurableCompletion() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
var completion = new CompletableFuture<VotingNPCDefinition>();
|
||||
when(f.manager.create(any())).thenReturn(completion);
|
||||
f.command("Create", 4).execute(player, new String[] { "NPC", "Create", "voting", "Vote", "here" });
|
||||
verify(player, never()).getLocation(); verify(f.manager, never()).create(any());
|
||||
f.tasks.remove().run();
|
||||
var definition = ArgumentCaptor.forClass(VotingNPCDefinition.class); verify(f.manager).create(definition.capture());
|
||||
assertEquals("Vote here", definition.getValue().name()); assertEquals("voting", definition.getValue().id());
|
||||
verify(player, never()).sendMessage(anyString());
|
||||
completion.complete(definition.getValue());
|
||||
verify(player, never()).sendMessage(anyString());
|
||||
f.tasks.remove().run();
|
||||
verify(player).sendMessage(contains("Saved voting NPC 'voting'"));
|
||||
}
|
||||
|
||||
@Test void delayedPermissionRevocationAndDisabledFeaturePreventMutation() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
var handler = f.command("Create", 3);
|
||||
handler.execute(player, new String[] { "NPC", "Create", "voting" });
|
||||
when(player.hasPermission("VotingPlugin.Commands.AdminVote.NPC")).thenReturn(false);
|
||||
f.tasks.remove().run(); verify(f.manager, never()).create(any());
|
||||
when(player.hasPermission("VotingPlugin.Commands.AdminVote.NPC")).thenReturn(true);
|
||||
when(f.manager.available()).thenReturn(false);
|
||||
handler.execute(player, new String[] { "NPC", "Create", "voting" }); f.tasks.remove().run();
|
||||
verify(f.manager, never()).create(any()); verify(player).sendMessage(contains("VotingNPCs.Enabled"));
|
||||
}
|
||||
|
||||
@Test void rejectedStorageDoesNotSendSuccessAndDoesNotLeakRawFailure() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
when(f.manager.remove("voting")).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("Synthetic private diagnostic")));
|
||||
f.command("Remove", 3).execute(player, new String[] { "NPC", "Remove", "voting" });
|
||||
f.tasks.remove().run(); f.tasks.remove().run();
|
||||
verify(player).sendMessage(contains("was not confirmed"));
|
||||
verify(player, never()).sendMessage(contains("Synthetic private"));
|
||||
verify(player, never()).sendMessage(startsWith("Removed saved"));
|
||||
}
|
||||
|
||||
@Test void validationRejectionsHaveFixedActionableMessagesAndUnavailableStorageStaysConservative() {
|
||||
for (var reason : VotingNPCRegistry.Rejection.values()) {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
when(f.manager.create(any())).thenReturn(CompletableFuture.failedFuture(
|
||||
new java.util.concurrent.CompletionException(new VotingNPCRegistry.ValidationFailure(reason))));
|
||||
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "voting" });
|
||||
f.tasks.remove().run(); f.tasks.remove().run();
|
||||
String expected = switch (reason) {
|
||||
case DUPLICATE_ID -> "ID already exists";
|
||||
case DUPLICATE_IDENTITY -> "identity already exists";
|
||||
case LIMIT -> "64 voting NPC limit";
|
||||
};
|
||||
verify(player).sendMessage(contains(expected));
|
||||
verify(player, never()).sendMessage(contains("reload before retrying"));
|
||||
}
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
var pending = new CompletableFuture<VotingNPCDefinition>();
|
||||
when(f.manager.create(any())).thenReturn(pending);
|
||||
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "voting" }); f.tasks.remove().run();
|
||||
when(f.manager.available()).thenReturn(false);
|
||||
pending.completeExceptionally(new java.util.concurrent.RejectedExecutionException("Synthetic")); f.tasks.remove().run();
|
||||
verify(player).sendMessage(contains("reload before retrying"));
|
||||
verify(player, never()).sendMessage(contains("busy"));
|
||||
}
|
||||
|
||||
@Test void healthyQueueSaturationDoesNotTellTheAdministratorToReloadStorage() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
when(f.manager.create(any())).thenReturn(CompletableFuture.failedFuture(
|
||||
new java.util.concurrent.RejectedExecutionException("Synthetic queue rejection")));
|
||||
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "voting" });
|
||||
f.tasks.remove().run(); f.tasks.remove().run();
|
||||
verify(player).sendMessage(contains("busy"));
|
||||
verify(player, never()).sendMessage(contains("reload before retrying"));
|
||||
verify(player, never()).sendMessage(contains("Synthetic"));
|
||||
}
|
||||
|
||||
@Test void invalidDefinitionAndConsoleCreationDoNotMutate() {
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "bad id" }); f.tasks.remove().run();
|
||||
verify(f.manager, never()).create(any()); verify(player).sendMessage(contains("lowercase ID"));
|
||||
CommandSender console = mock(CommandSender.class); when(console.hasPermission("VotingPlugin.Admin")).thenReturn(true);
|
||||
f.command("Create", 3).execute(console, new String[] { "NPC", "Create", "voting" }); f.tasks.remove().run();
|
||||
verify(console).sendMessage("Create a voting NPC from in game."); verify(f.manager, never()).create(any());
|
||||
}
|
||||
|
||||
@Test void bothCreationFormsMatchRealCommandDispatchWithStandardFrameworkPlaceholders() {
|
||||
var tabs = new com.bencodez.simpleapi.command.TabCompleteHandler();
|
||||
// These are the standard placeholders AdvancedCore registers before commands.
|
||||
tabs.addTabCompleteOption("(String)", new ArrayList<>());
|
||||
tabs.addTabCompleteOption("(List)", new ArrayList<>());
|
||||
try (var tabSingleton = mockStatic(com.bencodez.simpleapi.command.TabCompleteHandler.class)) {
|
||||
tabSingleton.when(com.bencodez.simpleapi.command.TabCompleteHandler::getInstance).thenReturn(tabs);
|
||||
var f = new Fixture(); Player player = f.player();
|
||||
when(f.manager.create(any())).thenReturn(new CompletableFuture<>());
|
||||
assertTrue(f.command("Create", 3).runCommand(player, new String[] { "npc", "create", "new-voting" }));
|
||||
f.asynchronous.remove().run(); f.tasks.remove().run();
|
||||
assertTrue(f.command("Create", 4).runCommand(player, new String[] { "npc", "create", "another", "Vote", "here" }));
|
||||
f.asynchronous.remove().run(); f.tasks.remove().run();
|
||||
var values = ArgumentCaptor.forClass(VotingNPCDefinition.class); verify(f.manager, times(2)).create(values.capture());
|
||||
assertEquals(List.of("new-voting", "another"), values.getAllValues().stream().map(VotingNPCDefinition::id).toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.AdditionalMatchers.aryEq;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.world.ChunkLoadEvent;
|
||||
import org.bukkit.event.world.ChunkUnloadEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.event.world.WorldUnloadEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
class VotingNPCManagerTest {
|
||||
@TempDir Path directory;
|
||||
private static final UUID WORLD = new UUID(1, 0);
|
||||
|
||||
private static VotingNPCDefinition definition(int identity) {
|
||||
return new VotingNPCDefinition("voting", new UUID(0, identity), WORLD, 8, 64, 8, 0, 0, "&aVote");
|
||||
}
|
||||
|
||||
private static VotingNPCDefinition definitionAt(int identity, double x) {
|
||||
return new VotingNPCDefinition("npc-" + identity, new UUID(0, identity), WORLD, x, 64, 8, 0, 0, "&aVote");
|
||||
}
|
||||
|
||||
private static <T> T await(CompletionStage<T> stage) throws Exception {
|
||||
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static class Scheduler extends VotingNPCScheduler {
|
||||
final LinkedBlockingQueue<Runnable> global = new LinkedBlockingQueue<>();
|
||||
final LinkedBlockingQueue<Runnable> regions = new LinkedBlockingQueue<>();
|
||||
final LinkedBlockingQueue<Runnable> entities = new LinkedBlockingQueue<>();
|
||||
String owner = "none";
|
||||
boolean rejectCleanup;
|
||||
Scheduler(VotingPluginMain plugin) { super(plugin); }
|
||||
@Override boolean owns(Location value) { return owner.equals("region"); }
|
||||
@Override boolean owns(Entity value) { return owner.equals("entity"); }
|
||||
@Override void global(Runnable action, Consumer<Throwable> failed) { global.add(action); }
|
||||
@Override void region(Location anchor, Runnable action, Consumer<Throwable> failed) { regions.add(action); }
|
||||
@Override void entity(Entity entity, Runnable action, Runnable retired, Consumer<Throwable> failed) {
|
||||
if (rejectCleanup) failed.accept(new IllegalStateException("Synthetic rejection"));
|
||||
else entities.add(action);
|
||||
}
|
||||
void runNext(LinkedBlockingQueue<Runnable> queue, String phase) throws Exception {
|
||||
Runnable action = queue.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(action, "Expected scheduled " + phase + " work");
|
||||
owner = phase;
|
||||
try { action.run(); } finally { owner = "none"; }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void duplicateChunkNotificationsDoNotReconstructAnotherEntityAndOrdinaryEntitiesAreIgnored() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin);
|
||||
List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, player -> fail("Unexpected menu"));
|
||||
try {
|
||||
manager.configure(true);
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
Chunk chunk = chunk(world);
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
|
||||
assertEquals(1, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
verify(spawned.getFirst()).setPersistent(false);
|
||||
verify(spawned.getFirst()).setAI(false);
|
||||
verify(spawned.getFirst()).setCustomName("\u00a7aVote");
|
||||
Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(new UUID(0, 100));
|
||||
Villager unrelated = mock(Villager.class); when(unrelated.getUniqueId()).thenReturn(new UUID(0, 99));
|
||||
var interact = new PlayerInteractEntityEvent(player, unrelated, EquipmentSlot.HAND);
|
||||
manager.interact(interact); assertFalse(interact.isCancelled());
|
||||
var damage = mock(EntityDamageEvent.class); when(damage.getEntity()).thenReturn(unrelated);
|
||||
manager.damage(damage); verify(damage, never()).setCancelled(anyBoolean());
|
||||
var protectedDamage = mock(EntityDamageEvent.class); when(protectedDamage.getEntity()).thenReturn(spawned.getFirst());
|
||||
manager.damage(protectedDamage); verify(protectedDamage).setCancelled(true);
|
||||
var hostileTarget = new org.bukkit.event.entity.EntityTargetEvent(unrelated, spawned.getFirst(),
|
||||
org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_ENTITY);
|
||||
manager.target(hostileTarget);
|
||||
assertTrue(hostileTarget.isCancelled(), "ordinary mobs must not target a tracked NPC");
|
||||
var normalTarget = new org.bukkit.event.entity.EntityTargetEvent(unrelated, player,
|
||||
org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_ENTITY);
|
||||
manager.target(normalTarget); assertFalse(normalTarget.isCancelled());
|
||||
var clearedTarget = new org.bukkit.event.entity.EntityTargetEvent(unrelated, null,
|
||||
org.bukkit.event.entity.EntityTargetEvent.TargetReason.FORGOT_TARGET);
|
||||
manager.target(clearedTarget); assertFalse(clearedTarget.isCancelled());
|
||||
var witch = mock(org.bukkit.entity.Witch.class);
|
||||
var transform = new org.bukkit.event.entity.EntityTransformEvent(spawned.getFirst(), List.of(witch),
|
||||
org.bukkit.event.entity.EntityTransformEvent.TransformReason.LIGHTNING);
|
||||
var unrelatedTransform = new org.bukkit.event.entity.EntityTransformEvent(unrelated, List.of(witch),
|
||||
org.bukkit.event.entity.EntityTransformEvent.TransformReason.LIGHTNING);
|
||||
// Discover actual listener methods, as event registration does;
|
||||
// absence of a transformation handler must fail this regression.
|
||||
for (var method : VotingNPCManager.class.getDeclaredMethods()) {
|
||||
if (method.isAnnotationPresent(org.bukkit.event.EventHandler.class) && method.getParameterCount() == 1
|
||||
&& method.getParameterTypes()[0].isInstance(transform)) {
|
||||
method.invoke(manager, transform); method.invoke(manager, unrelatedTransform);
|
||||
}
|
||||
}
|
||||
assertTrue(transform.isCancelled(), "lightning must not replace a tracked villager with an untracked entity");
|
||||
assertFalse(unrelatedTransform.isCancelled());
|
||||
} finally {
|
||||
manager.close(); scheduler.runNext(scheduler.entities, "entity");
|
||||
assertEquals(0, manager.runtimeCount()); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void freedFullCapacityWakesOnePendingDifferentId() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("capacity.json")); await(registry.initialLoad());
|
||||
for (int i = 1; i <= VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
for (int i = 0; i < VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(VotingNPCStore.MAX_NPCS, manager.runtimeCount());
|
||||
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
scheduler.runNext(scheduler.entities, "entity");
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(VotingNPCStore.MAX_NPCS, manager.runtimeCount());
|
||||
assertEquals(VotingNPCStore.MAX_NPCS + 1, spawned.size());
|
||||
} finally {
|
||||
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unloadedCurrentReservationWakesDifferentPendingIdWithoutRetryingItself() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("unloaded-current-capacity.json")); await(registry.initialLoad());
|
||||
for (int i = 1; i < VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
for (int i = 1; i < VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
|
||||
await(manager.create(definitionAt(64, 128)));
|
||||
scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(64, manager.runtimeCount()); assertEquals(1, scheduler.regions.size());
|
||||
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(1, scheduler.entities.size()); assertTrue(scheduler.global.isEmpty());
|
||||
assertEquals(1, scheduler.regions.size());
|
||||
// The existing queued reservation is now unloaded; the new target remains loaded.
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenAnswer(call -> (Integer) call.getArgument(0) != 8);
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(63, manager.runtimeCount());
|
||||
assertEquals(1, scheduler.global.size(), "Released capacity must wake the different waiting ID");
|
||||
scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(1, scheduler.regions.size(), "Only the pending ID may retry, not the unloaded reservation");
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(64, spawned.size()); assertEquals(64, manager.runtimeCount());
|
||||
assertTrue(scheduler.global.isEmpty()); assertTrue(scheduler.regions.isEmpty());
|
||||
verify(world).spawn(argThat(anchor -> anchor.getX() == 2000), eq(Villager.class), any(Consumer.class));
|
||||
} finally {
|
||||
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void successorWaitsForExactPredecessorCleanupAndCleanupRejectionRetainsOwnership() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
scheduler.rejectCleanup = true;
|
||||
assertTrue(await(manager.remove("voting")));
|
||||
await(manager.create(definition(2)));
|
||||
// Process both durable mutation notifications while old removal is rejected.
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(1, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
verify(spawned.getFirst(), never()).remove();
|
||||
// A rejected cleanup keeps ownership; repeated load notifications may
|
||||
// request a retry, but must never create a successor or duplicate task.
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false));
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false));
|
||||
assertEquals(1, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
scheduler.rejectCleanup = false;
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false));
|
||||
scheduler.runNext(scheduler.entities, "entity");
|
||||
verify(spawned.getFirst()).remove();
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
} finally {
|
||||
scheduler.rejectCleanup = false; manager.close();
|
||||
if (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void transientUnloadCleanupRetriesAtLoadAndCoalescesUntilRemovalFinishes() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
Chunk chunk = chunk(world); scheduler.rejectCleanup = true;
|
||||
manager.chunkUnload(new ChunkUnloadEvent(chunk));
|
||||
assertEquals(1, manager.runtimeCount()); assertTrue(scheduler.global.isEmpty());
|
||||
assertTrue(scheduler.entities.isEmpty(), "rejection must not create an autonomous retry loop");
|
||||
scheduler.rejectCleanup = false;
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
|
||||
assertEquals(1, scheduler.entities.size(), "a queued exact predecessor cleanup must coalesce");
|
||||
assertEquals(1, spawned.size());
|
||||
scheduler.runNext(scheduler.entities, "entity"); verify(spawned.getFirst()).remove();
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
assertTrue(scheduler.entities.isEmpty()); assertTrue(scheduler.global.isEmpty());
|
||||
} finally {
|
||||
scheduler.rejectCleanup = false; manager.close();
|
||||
if (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedRemovedReservationWakesPendingCapacityWithoutEntity() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("queued-capacity.json")); await(registry.initialLoad());
|
||||
for (int i = 1; i <= VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
for (int i = 0; i <= VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
|
||||
assertTrue(scheduler.regions.isEmpty()); assertTrue(scheduler.global.isEmpty());
|
||||
assertEquals(VotingNPCStore.MAX_NPCS, manager.runtimeCount());
|
||||
assertEquals(VotingNPCStore.MAX_NPCS, spawned.size());
|
||||
} finally {
|
||||
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unloadedQueuedReservationWithPendingCapacityDoesNotRetry() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("unloaded-capacity.json")); await(registry.initialLoad());
|
||||
for (int i = 1; i <= VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
|
||||
for (int i = 0; i <= VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(0, spawned.size()); assertTrue(scheduler.global.isEmpty());
|
||||
assertTrue(scheduler.regions.isEmpty()); assertEquals(0, manager.runtimeCount());
|
||||
} finally { manager.close(); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unloadedChunksNeverSpawnOrLoadAndLaterLoadReconstructsOnce() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertTrue(spawned.isEmpty()); assertEquals(0, manager.runtimeCount());
|
||||
verify(world, never()).getChunkAt(anyInt(), anyInt()); verify(world, never()).loadChunk(anyInt(), anyInt());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false)); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(1, spawned.size());
|
||||
manager.chunkUnload(new ChunkUnloadEvent(chunk(world))); scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(scheduler.global.isEmpty(), "unload must not immediately retry reconstruction");
|
||||
assertTrue(scheduler.regions.isEmpty());
|
||||
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false)); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size());
|
||||
} finally { manager.close(); scheduler.runNext(scheduler.entities, "entity"); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partialSpawnFailureRemovesOnlyTheCapturedEntityAndCloseInvalidatesQueuedSpawns() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
// Close before a deferred creation runs: no entity may be created.
|
||||
manager.configure(false); scheduler.runNext(scheduler.regions, "region");
|
||||
assertTrue(spawned.isEmpty()); assertEquals(0, manager.runtimeCount());
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
doAnswer(invocation -> {
|
||||
Villager entity = mock(Villager.class); when(entity.getUniqueId()).thenReturn(new UUID(0, 17));
|
||||
doThrow(new IllegalStateException("Synthetic entity property failure")).when(entity).setAI(false);
|
||||
spawned.add(entity);
|
||||
Consumer<Villager> initializer = invocation.getArgument(2); initializer.accept(entity); return entity;
|
||||
}).when(world).spawn(any(Location.class), eq(Villager.class), any(Consumer.class));
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(1, manager.runtimeCount());
|
||||
scheduler.runNext(scheduler.entities, "entity");
|
||||
verify(spawned.getFirst()).remove(); assertEquals(0, manager.runtimeCount());
|
||||
assertTrue(scheduler.global.isEmpty(), "failed spawn must not cause an automatic retry loop");
|
||||
} finally { manager.close(); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void occupiedNeighbourUnloadRetiresAndReloadReconstructsOnceAtBothCoordinateSigns() throws Exception {
|
||||
for (double position : new double[] {15.75, -.25}) {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("boundary-" + (position > 0) + ".json"));
|
||||
await(registry.initialLoad());
|
||||
var definition = new VotingNPCDefinition("voting", new UUID(0, 1), WORLD, position, 64, position, 0, 0, "Vote");
|
||||
await(registry.create(definition));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
manager.chunkUnload(new ChunkUnloadEvent(atChunk(world, 7, 7)));
|
||||
assertTrue(scheduler.entities.isEmpty(), "unrelated chunk unload must leave NPC alone");
|
||||
int neighbour = definition.chunkX() + 1;
|
||||
manager.chunkUnload(new ChunkUnloadEvent(atChunk(world, neighbour, neighbour)));
|
||||
assertEquals(1, scheduler.entities.size(), "occupied adjacent chunk must trigger entity-owned cleanup");
|
||||
// Bukkit may still report the unloading chunk as loaded during its event.
|
||||
manager.chunkLoad(new ChunkLoadEvent(atChunk(world, definition.chunkX(), definition.chunkZ()), false));
|
||||
scheduler.runNext(scheduler.entities, "entity");
|
||||
assertEquals(0, manager.runtimeCount()); assertTrue(scheduler.regions.isEmpty());
|
||||
manager.chunkLoad(new ChunkLoadEvent(atChunk(world, neighbour, neighbour), false));
|
||||
manager.chunkLoad(new ChunkLoadEvent(atChunk(world, neighbour, neighbour), false));
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size()); assertEquals(1, manager.runtimeCount());
|
||||
verify(world, never()).getChunkAt(anyInt(), anyInt()); verify(world, never()).loadChunk(anyInt(), anyInt());
|
||||
} finally {
|
||||
manager.close(); if (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void neighbourUnloadFenceSurvivesReconciliationAndBlocksDeferredCreation() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("pending-boundary.json")); await(registry.initialLoad());
|
||||
await(registry.create(new VotingNPCDefinition("voting", new UUID(0, 1), WORLD, 15.75, 64, 15.75, 0, 0, "Vote")));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global");
|
||||
manager.chunkUnload(new ChunkUnloadEvent(atChunk(world, 1, 1)));
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertTrue(spawned.isEmpty(), "a deferred creation cannot cross an occupied chunk unload");
|
||||
assertEquals(0, manager.runtimeCount());
|
||||
await(manager.create(new VotingNPCDefinition("other", new UUID(0, 2), WORLD, 40, 64, 40, 0, 0, "Vote")));
|
||||
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(1, spawned.size(), "unrelated mutation must not erase the first NPC's occupied-chunk fence");
|
||||
assertTrue(scheduler.regions.isEmpty());
|
||||
} finally {
|
||||
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Chunk atChunk(World world, int x, int z) {
|
||||
Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(world);
|
||||
when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z); return chunk;
|
||||
}
|
||||
|
||||
@Test void disabledFeatureDoesNotReadStorageOrRegisterAnyRuntimeWork() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var scheduler = new Scheduler(plugin);
|
||||
var manager = new VotingNPCManager(plugin, scheduler, null, ignored -> fail("Disabled menu"));
|
||||
manager.configure(false);
|
||||
assertFalse(manager.available()); assertEquals(0, manager.runtimeCount());
|
||||
assertTrue(scheduler.global.isEmpty()); assertTrue(scheduler.regions.isEmpty());
|
||||
verify(plugin, never()).getDataFolder();
|
||||
manager.close(); manager.close();
|
||||
}
|
||||
|
||||
@Test void worldUnloadPreservesDefinitionsAndAWorldLoadAfterCleanupRecreatesOnce() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
manager.worldUnload(new WorldUnloadEvent(world));
|
||||
// A load before delayed cleanup must record demand, not spawn a duplicate.
|
||||
manager.worldLoad(new WorldLoadEvent(world)); scheduler.runNext(scheduler.global, "global");
|
||||
assertEquals(1, spawned.size());
|
||||
scheduler.runNext(scheduler.entities, "entity"); scheduler.runNext(scheduler.global, "global");
|
||||
scheduler.runNext(scheduler.regions, "region");
|
||||
assertEquals(2, spawned.size()); assertEquals(List.of(definition(1)), manager.definitions());
|
||||
} finally { manager.close(); scheduler.runNext(scheduler.entities, "entity"); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledAndOffhandClicksDoNotOpenAndRemovalInvalidatesAQueuedPlayerCallback() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
var bukkitScheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(bukkitScheduler);
|
||||
var playerCallbacks = new LinkedBlockingQueue<Runnable>();
|
||||
doAnswer(invocation -> { playerCallbacks.add(invocation.getArgument(1)); return null; })
|
||||
.when(bukkitScheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class));
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
|
||||
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
|
||||
await(registry.create(definition(1)));
|
||||
List<Player> opened = new ArrayList<>();
|
||||
var manager = new VotingNPCManager(plugin, scheduler, registry, opened::add);
|
||||
try {
|
||||
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
|
||||
Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(new UUID(0, 50));
|
||||
when(player.isOnline()).thenReturn(true); when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 8, 64, 9));
|
||||
var cancelled = new PlayerInteractEntityEvent(player, spawned.getFirst(), EquipmentSlot.HAND); cancelled.setCancelled(true);
|
||||
manager.interact(cancelled);
|
||||
manager.interact(new PlayerInteractEntityEvent(player, spawned.getFirst(), EquipmentSlot.OFF_HAND));
|
||||
assertTrue(playerCallbacks.isEmpty());
|
||||
var click = new PlayerInteractEntityEvent(player, spawned.getFirst(), EquipmentSlot.HAND);
|
||||
manager.interact(click); assertTrue(click.isCancelled());
|
||||
assertEquals(1, playerCallbacks.size());
|
||||
playerCallbacks.remove().run();
|
||||
assertEquals(List.of(player), opened, "healthy main-hand selection opens on the player callback");
|
||||
Player second = mock(Player.class); when(second.getUniqueId()).thenReturn(new UUID(0, 51));
|
||||
when(second.isOnline()).thenReturn(true); when(second.getWorld()).thenReturn(world);
|
||||
when(second.getLocation()).thenReturn(new Location(world, 8, 64, 9));
|
||||
manager.interact(new PlayerInteractEntityEvent(second, spawned.getFirst(), EquipmentSlot.HAND));
|
||||
playerCallbacks.remove().run();
|
||||
assertEquals(List.of(player, second), opened, "one player's selection must not block another player");
|
||||
Player third = mock(Player.class); when(third.getUniqueId()).thenReturn(new UUID(0, 52));
|
||||
when(third.isOnline()).thenReturn(true); when(third.getWorld()).thenReturn(world);
|
||||
when(third.getLocation()).thenReturn(new Location(world, 8, 64, 9));
|
||||
manager.interact(new PlayerInteractEntityEvent(third, spawned.getFirst(), EquipmentSlot.HAND));
|
||||
await(manager.remove("voting"));
|
||||
playerCallbacks.remove().run(); assertEquals(List.of(player, second), opened);
|
||||
} finally { manager.close(); scheduler.runNext(scheduler.entities, "entity"); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void productionMenuEntryDelegatesToTheExistingUrlCommandRatherThanAnotherMenuOrRewardPath() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
var unrelated = mock(com.bencodez.advancedcore.api.command.CommandHandler.class);
|
||||
var url = mock(com.bencodez.advancedcore.api.command.CommandHandler.class);
|
||||
var later = mock(com.bencodez.advancedcore.api.command.CommandHandler.class);
|
||||
var player = mock(Player.class);
|
||||
when(plugin.getVoteCommand()).thenReturn(new ArrayList<>(List.of(unrelated, url, later)));
|
||||
when(url.runCommand(eq(player), aryEq(new String[] { "URL" }))).thenReturn(true);
|
||||
VotingNPCManager.openVotingSites(plugin, player);
|
||||
verify(unrelated).runCommand(eq(player), aryEq(new String[] { "URL" }));
|
||||
verify(url).runCommand(eq(player), aryEq(new String[] { "URL" }));
|
||||
verifyNoInteractions(later);
|
||||
verify(player, never()).performCommand(anyString());
|
||||
}
|
||||
|
||||
private static Chunk chunk(World world) {
|
||||
Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(world); when(chunk.getX()).thenReturn(0); when(chunk.getZ()).thenReturn(0); return chunk;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Villager> installSpawner(World world, Scheduler scheduler) {
|
||||
List<Villager> spawned = new ArrayList<>();
|
||||
doAnswer(invocation -> {
|
||||
assertEquals("region", scheduler.owner);
|
||||
Villager entity = mock(Villager.class); when(entity.getUniqueId()).thenReturn(new UUID(2, spawned.size() + 1));
|
||||
when(entity.isValid()).thenReturn(true);
|
||||
Consumer<Villager> initializer = invocation.getArgument(2); initializer.accept(entity);
|
||||
doAnswer(removal -> { assertEquals("entity", scheduler.owner); return null; }).when(entity).remove();
|
||||
spawned.add(entity); return entity;
|
||||
}).when(world).spawn(any(Location.class), eq(Villager.class), any(Consumer.class));
|
||||
return spawned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class VotingNPCRegistryTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private VotingNPCDefinition definition(int id) {
|
||||
return new VotingNPCDefinition("npc-" + id, new UUID(0, id), new UUID(1, 0),
|
||||
0.5, 64, 0.5, 0, 0, "Vote");
|
||||
}
|
||||
|
||||
private static <T> T await(CompletionStage<T> stage) throws Exception {
|
||||
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static void close(VotingNPCRegistry registry) throws InterruptedException {
|
||||
registry.close();
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
private static void waitOn(CountDownLatch latch) throws IOException {
|
||||
try {
|
||||
if (!latch.await(5, TimeUnit.SECONDS)) throw new IOException("Synthetic worker latch timed out");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Synthetic worker interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void linkedDirectoryAliasesShareTheSameExclusiveOwnerAndLeafLockLinksAreRejected() throws Exception {
|
||||
Path real = Files.createDirectory(directory.resolve("real"));
|
||||
Path linked = Files.createSymbolicLink(directory.resolve("linked"), real);
|
||||
Path file = linked.resolve("npcs.json");
|
||||
var first = new VotingNPCRegistry(file);
|
||||
try {
|
||||
await(first.initialLoad()); await(first.create(definition(1)));
|
||||
var alias = new VotingNPCRegistry(real.resolve("npcs.json"));
|
||||
try { assertThrows(ExecutionException.class, () -> await(alias.initialLoad())); }
|
||||
finally { close(alias); }
|
||||
} finally { close(first); }
|
||||
var recovered = new VotingNPCRegistry(file);
|
||||
try { assertEquals(List.of(definition(1)), await(recovered.initialLoad())); }
|
||||
finally { close(recovered); }
|
||||
Path lock = linked.resolve("other.json.lock");
|
||||
Path unrelated = real.resolve("unrelated"); Files.writeString(unrelated, "unchanged");
|
||||
Files.createSymbolicLink(lock, unrelated);
|
||||
var rejected = new VotingNPCRegistry(linked.resolve("other.json"));
|
||||
try { assertThrows(ExecutionException.class, () -> await(rejected.initialLoad())); }
|
||||
finally { close(rejected); }
|
||||
assertEquals("unchanged", Files.readString(unrelated));
|
||||
}
|
||||
|
||||
@Test void orderedDefinitionsAreDurableAndDuplicateRequestsDoNotOverwrite() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var registry = new VotingNPCRegistry(file);
|
||||
try {
|
||||
assertEquals(List.of(), await(registry.initialLoad()));
|
||||
var first = registry.create(definition(1));
|
||||
var duplicate = registry.create(definition(1));
|
||||
var second = registry.create(definition(2));
|
||||
assertEquals(definition(1), await(first));
|
||||
assertInstanceOf(IllegalArgumentException.class,
|
||||
assertThrows(ExecutionException.class, () -> await(duplicate)).getCause());
|
||||
await(second);
|
||||
assertEquals(List.of(definition(1), definition(2)), new VotingNPCStore(file).load());
|
||||
assertTrue(await(registry.remove("npc-1")));
|
||||
assertFalse(await(registry.remove("npc-1")));
|
||||
} finally { close(registry); }
|
||||
var restarted = new VotingNPCRegistry(file);
|
||||
try { assertEquals(List.of(definition(2)), await(restarted.initialLoad())); }
|
||||
finally { close(restarted); }
|
||||
}
|
||||
|
||||
@Test void failedReloadRetainsLastSnapshotButCannotPublishUntilRepaired() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var registry = new VotingNPCRegistry(file);
|
||||
try {
|
||||
await(registry.initialLoad()); await(registry.create(definition(1)));
|
||||
byte[] healthy = Files.readAllBytes(file);
|
||||
Files.writeString(file, "{invalid");
|
||||
assertThrows(ExecutionException.class, () -> await(registry.reload()));
|
||||
assertFalse(registry.available());
|
||||
assertEquals(List.of(definition(1)), registry.snapshot());
|
||||
assertThrows(ExecutionException.class, () -> await(registry.create(definition(2))));
|
||||
assertEquals("{invalid", Files.readString(file));
|
||||
Files.write(file, healthy);
|
||||
assertEquals(List.of(definition(1)), await(registry.reload()));
|
||||
await(registry.create(definition(2)));
|
||||
} finally { close(registry); }
|
||||
}
|
||||
|
||||
@Test void invalidInitialFileDoesNotAppearReadyOrGetReplaced() throws Exception {
|
||||
Path file = directory.resolve("npcs.json"); Files.writeString(file, "{invalid");
|
||||
var registry = new VotingNPCRegistry(file);
|
||||
try {
|
||||
assertThrows(ExecutionException.class, () -> await(registry.initialLoad()));
|
||||
assertFalse(registry.available());
|
||||
assertThrows(ExecutionException.class, () -> await(registry.create(definition(1))));
|
||||
assertEquals("{invalid", Files.readString(file));
|
||||
} finally { close(registry); }
|
||||
}
|
||||
|
||||
@Test void closeReturnsWhileAcceptedWriteDrainsAndKeepsExclusiveFileOwnership() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override synchronized void save(List<VotingNPCDefinition> values) throws IOException {
|
||||
entered.countDown(); waitOn(release); super.save(values);
|
||||
}
|
||||
};
|
||||
var first = new VotingNPCRegistry(file, store);
|
||||
VotingNPCRegistry replacement = null;
|
||||
try {
|
||||
await(first.initialLoad());
|
||||
var writing = first.create(definition(1));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
first.close(); first.close();
|
||||
assertFalse(first.awaitClosed(0, TimeUnit.SECONDS));
|
||||
assertThrows(ExecutionException.class, () -> await(first.remove("npc-1")));
|
||||
replacement = new VotingNPCRegistry(file);
|
||||
var next = replacement;
|
||||
assertThrows(ExecutionException.class, () -> await(next.initialLoad()));
|
||||
assertFalse(replacement.available());
|
||||
release.countDown(); await(writing);
|
||||
assertTrue(first.awaitClosed(5, TimeUnit.SECONDS));
|
||||
assertEquals(List.of(definition(1)), await(replacement.reload()));
|
||||
} finally {
|
||||
release.countDown(); close(first);
|
||||
if (replacement != null) close(replacement);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void saturatedReloadIsRejectedWithoutDisablingTheHealthyOwner() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override synchronized void save(List<VotingNPCDefinition> values) throws IOException {
|
||||
entered.countDown(); waitOn(release); super.save(values);
|
||||
}
|
||||
};
|
||||
var registry = new VotingNPCRegistry(file, store);
|
||||
try {
|
||||
await(registry.initialLoad());
|
||||
var writing = registry.create(definition(1));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var queued = new java.util.ArrayList<CompletionStage<Boolean>>();
|
||||
for (int i = 0; i < 128; i++) queued.add(registry.remove("missing"));
|
||||
assertThrows(ExecutionException.class, () -> await(registry.reload()));
|
||||
assertTrue(registry.available(), "rejected reload must leave existing readiness intact");
|
||||
release.countDown(); await(writing);
|
||||
for (var entry : queued) assertFalse(await(entry));
|
||||
await(registry.create(definition(2)));
|
||||
} finally { release.countDown(); close(registry); }
|
||||
}
|
||||
|
||||
@Test void failedWriteFencesAlreadyQueuedMutationsUntilCheckedReload() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
AtomicBoolean fail = new AtomicBoolean(true);
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override synchronized void save(List<VotingNPCDefinition> values) throws IOException {
|
||||
if (fail.getAndSet(false)) {
|
||||
entered.countDown(); waitOn(release);
|
||||
super.save(values);
|
||||
throw new IOException("Synthetic failure after visible write");
|
||||
}
|
||||
super.save(values);
|
||||
}
|
||||
};
|
||||
var registry = new VotingNPCRegistry(file, store);
|
||||
try {
|
||||
await(registry.initialLoad());
|
||||
var first = registry.create(definition(1));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var second = registry.create(definition(2));
|
||||
release.countDown();
|
||||
assertThrows(ExecutionException.class, () -> await(first));
|
||||
assertThrows(ExecutionException.class, () -> await(second));
|
||||
assertFalse(registry.available());
|
||||
assertEquals(List.of(), registry.snapshot());
|
||||
assertEquals(List.of(definition(1)), new VotingNPCStore(file).load());
|
||||
assertEquals(List.of(definition(1)), await(registry.reload()));
|
||||
await(registry.create(definition(2)));
|
||||
assertEquals(List.of(definition(1), definition(2)), registry.snapshot());
|
||||
} finally { release.countDown(); close(registry); }
|
||||
}
|
||||
|
||||
@Test void completionCallbackMayCloseWithoutDeadlockingOrInterruptingCommittedWork() throws Exception {
|
||||
var registry = new VotingNPCRegistry(directory.resolve("npcs.json"));
|
||||
try {
|
||||
await(registry.initialLoad());
|
||||
CompletableFuture<Void> callback = registry.create(definition(1)).thenAccept(value -> registry.close())
|
||||
.toCompletableFuture();
|
||||
await(callback);
|
||||
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
|
||||
assertEquals(List.of(definition(1)), new VotingNPCStore(directory.resolve("npcs.json")).load());
|
||||
} finally { close(registry); }
|
||||
}
|
||||
|
||||
@Test void capacityAndIdentityRejectionsLeaveHealthyStorageAvailable() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var seed = new VotingNPCStore(file); seed.load();
|
||||
seed.save(java.util.stream.IntStream.rangeClosed(1, 63).mapToObj(this::definition).toList());
|
||||
var registry = new VotingNPCRegistry(file);
|
||||
try {
|
||||
await(registry.initialLoad());
|
||||
byte[] original = Files.readAllBytes(file);
|
||||
var duplicateIdentity = new VotingNPCDefinition("another", definition(1).identity(), definition(1).world(),
|
||||
0, 64, 0, 0, 0, "Vote");
|
||||
assertThrows(ExecutionException.class, () -> await(registry.create(duplicateIdentity)));
|
||||
assertTrue(registry.available(), "invalid new identity must not disable existing NPC storage");
|
||||
assertArrayEquals(original, Files.readAllBytes(file));
|
||||
await(registry.create(definition(64)));
|
||||
byte[] atLimit = Files.readAllBytes(file);
|
||||
assertThrows(ExecutionException.class, () -> await(registry.create(definition(65))));
|
||||
assertTrue(registry.available(), "one extra creation must not disable 64 existing NPCs");
|
||||
assertEquals(64, registry.snapshot().size());
|
||||
assertArrayEquals(atLimit, Files.readAllBytes(file));
|
||||
} finally { close(registry); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
class VotingNPCSchedulerTest {
|
||||
@Test void alreadyOwnedCleanupRunsInlineEvenDuringDisable() {
|
||||
var plugin = mock(VotingPluginMain.class);
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
|
||||
var scheduler = new VotingNPCScheduler(plugin); var ran = new AtomicInteger();
|
||||
scheduler.entity(mock(Entity.class), ran::incrementAndGet, () -> fail("retired"), error -> fail(error));
|
||||
assertEquals(1, ran.get()); verify(plugin, never()).getBukkitScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void deferredRegionActionUsesTheLocationSchedulerAndReportsFailure() {
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
var scheduler = new VotingNPCScheduler(plugin); Location anchor = mock(Location.class);
|
||||
var completion = new CompletableFuture<Void>();
|
||||
var callback = new AtomicReference<Runnable>();
|
||||
when(plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(eq(anchor), any()))
|
||||
.thenAnswer(inv -> { Consumer<?> action = inv.getArgument(1); callback.set(() -> action.accept(null)); return completion; });
|
||||
var ran = new AtomicInteger(); List<Throwable> failures = new ArrayList<>();
|
||||
scheduler.region(anchor, ran::incrementAndGet, failures::add);
|
||||
assertEquals(0, ran.get()); callback.get().run(); assertEquals(1, ran.get());
|
||||
completion.completeExceptionally(new IllegalStateException("Synthetic scheduler failure"));
|
||||
assertEquals(1, failures.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test @SuppressWarnings({"unchecked", "rawtypes"})
|
||||
void schedulerRetirementDoesNotPretendThatAnEntityWasRemoved() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
var scheduler = new VotingNPCScheduler(plugin); Entity entity = mock(Entity.class);
|
||||
CompletableFuture completion = new CompletableFuture();
|
||||
when(plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(eq(entity), any(), any()))
|
||||
.thenReturn(completion);
|
||||
var ran = new AtomicInteger(); var retired = new AtomicInteger(); List<Throwable> failures = new ArrayList<>();
|
||||
scheduler.entity(entity, ran::incrementAndGet, retired::incrementAndGet, failures::add);
|
||||
completion.complete(Enum.valueOf((Class) Class.forName("com.bencodez.simpleapi.folialib.enums.EntityTaskResult"), "SCHEDULER_RETIRED"));
|
||||
assertEquals(0, ran.get()); assertEquals(0, retired.get()); assertEquals(1, failures.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test @SuppressWarnings({"unchecked", "rawtypes"})
|
||||
void entityRetirementFallbackAndFutureCompletionAreHandledOnce() throws Exception {
|
||||
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
|
||||
try (var bukkit = mockStatic(Bukkit.class)) {
|
||||
var scheduler = new VotingNPCScheduler(plugin); Entity entity = mock(Entity.class);
|
||||
CompletableFuture completion = new CompletableFuture(); var fallback = new AtomicReference<Runnable>();
|
||||
when(plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(eq(entity), any(), any()))
|
||||
.thenAnswer(inv -> { fallback.set(inv.getArgument(2)); return completion; });
|
||||
var retired = new AtomicInteger();
|
||||
scheduler.entity(entity, () -> fail("Entity already retired"), retired::incrementAndGet, error -> fail(error));
|
||||
fallback.get().run();
|
||||
completion.complete(Enum.valueOf((Class) Class.forName("com.bencodez.simpleapi.folialib.enums.EntityTaskResult"), "ENTITY_RETIRED"));
|
||||
assertEquals(1, retired.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.bencodez.votingplugin.npc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class VotingNPCStoreTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
private VotingNPCDefinition definition(int id) {
|
||||
return new VotingNPCDefinition("npc-" + id, new UUID(0, id), new UUID(1, 0),
|
||||
-0.5, 64, 16.5, 90, 0, "Vote");
|
||||
}
|
||||
|
||||
@Test void roundTripPreservesStableIdentitiesAndPositionAcrossRestart() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file);
|
||||
assertEquals(List.of(), store.load());
|
||||
store.save(List.of(definition(1), definition(2)));
|
||||
assertEquals(List.of(definition(1), definition(2)), new VotingNPCStore(file).load());
|
||||
assertEquals(-1, definition(1).chunkX());
|
||||
assertEquals(1, definition(1).chunkZ());
|
||||
}
|
||||
|
||||
@Test void missingInitialReadDoesNotCreateFilesAndUninitializedStoreCannotOverwrite() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file);
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1))));
|
||||
assertFalse(Files.exists(file));
|
||||
assertTrue(store.load().isEmpty());
|
||||
assertFalse(Files.exists(file));
|
||||
}
|
||||
|
||||
@Test void invalidLoadKeepsOriginalBytesAndBlocksMutations() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
byte[] original = "{ damaged definitions".getBytes(StandardCharsets.UTF_8);
|
||||
Files.write(file, original);
|
||||
var store = new VotingNPCStore(file);
|
||||
assertThrows(IOException.class, store::load);
|
||||
assertThrows(IOException.class, () -> store.save(List.of()));
|
||||
assertArrayEquals(original, Files.readAllBytes(file));
|
||||
}
|
||||
|
||||
@Test void duplicateIdentitiesAndCapacityRejectionDoNotChangeDurableDefinitions() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file); store.load(); store.save(List.of(definition(1)));
|
||||
byte[] before = Files.readAllBytes(file);
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1), definition(1))));
|
||||
var sameIdentity = new VotingNPCDefinition("another", definition(1).identity(), definition(1).world(),
|
||||
0, 64, 0, 0, 0, "Vote");
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1), sameIdentity)));
|
||||
assertThrows(IOException.class, () -> store.save(IntStream.rangeClosed(1, 65).mapToObj(this::definition).toList()));
|
||||
assertArrayEquals(before, Files.readAllBytes(file));
|
||||
}
|
||||
|
||||
@Test void publicationFailureFencesOldStateUntilCheckedReload() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
AtomicBoolean fail = new AtomicBoolean();
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override void forceDirectory(Path parent) throws IOException {
|
||||
if (fail.get()) throw new IOException("Synthetic directory sync failure");
|
||||
super.forceDirectory(parent);
|
||||
}
|
||||
};
|
||||
store.load(); store.save(List.of(definition(1)));
|
||||
fail.set(true);
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1), definition(2))));
|
||||
byte[] uncertain = Files.readAllBytes(file);
|
||||
fail.set(false);
|
||||
assertThrows(IOException.class, () -> store.save(List.of(definition(1))));
|
||||
assertArrayEquals(uncertain, Files.readAllBytes(file));
|
||||
assertEquals(List.of(definition(1), definition(2)), store.load());
|
||||
store.save(List.of(definition(2)));
|
||||
assertEquals(List.of(definition(2)), new VotingNPCStore(file).load());
|
||||
}
|
||||
|
||||
@Test void missingUncertainFileDoesNotBecomeAnEmptySuccessfulRecovery() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file) {
|
||||
@Override void forceDirectory(Path ignored) throws IOException { throw new IOException("Synthetic failure"); }
|
||||
};
|
||||
store.load(); assertThrows(IOException.class, () -> store.save(List.of(definition(1))));
|
||||
Files.delete(file);
|
||||
assertThrows(IOException.class, store::load);
|
||||
assertThrows(IOException.class, () -> store.save(List.of()));
|
||||
assertFalse(Files.exists(file));
|
||||
}
|
||||
|
||||
@Test void strictFieldsAndTypesRejectMalformedDocumentsIndividually() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file); store.load(); store.save(List.of(definition(1)));
|
||||
String healthy = Files.readString(file);
|
||||
for (String damaged : List.of(
|
||||
healthy.replace("\"version\": 1", "\"version\": 1, \"version\": 1"),
|
||||
healthy.replace("\"version\": 1", "\"version\": \"1\""),
|
||||
healthy.replace("\"version\": 1", "\"version\": 1.0"),
|
||||
healthy.replace("\"id\": \"npc-1\"", "\"id\": \"npc-1\", \"id\": \"other\""),
|
||||
healthy.replace("\"x\": -0.5", "\"x\": \"-0.5\""),
|
||||
healthy.replace("\"name\": \"Vote\"", "\"unknown\": \"Vote\""),
|
||||
healthy.replace("\"name\": \"Vote\"", "\"name\": null"),
|
||||
healthy.replace("\"yaw\": 90.0", "\"yaw\": 1e100"),
|
||||
healthy + "{}")) {
|
||||
Files.writeString(file, damaged);
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(file).load(), damaged);
|
||||
assertEquals(damaged, Files.readString(file));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidUtf8OversizeAndSymlinkCannotBeLoadedOrOverwritten() throws Exception {
|
||||
Path file = directory.resolve("npcs.json");
|
||||
Files.write(file, new byte[] {(byte) 0xc3, (byte) 0x28});
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(file).load());
|
||||
Files.write(file, new byte[128 * 1024 + 1]);
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(file).load());
|
||||
Files.delete(file);
|
||||
Path target = directory.resolve("unrelated.json");
|
||||
Files.writeString(target, "unrelated");
|
||||
Files.createSymbolicLink(file, target);
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(file).load());
|
||||
assertEquals("unrelated", Files.readString(target));
|
||||
}
|
||||
|
||||
@Test void linkedDirectoriesAllowDurableDefinitionsButLeafLinksStayRejected() throws Exception {
|
||||
Path real = Files.createDirectory(directory.resolve("real"));
|
||||
Path linked = Files.createSymbolicLink(directory.resolve("linked"), real);
|
||||
Path nested = Files.createDirectory(real.resolve("data"));
|
||||
for (Path parent : List.of(linked, linked.resolve("data"))) {
|
||||
Path file = parent.resolve("npcs.json");
|
||||
var store = new VotingNPCStore(file);
|
||||
assertTrue(store.load().isEmpty());
|
||||
store.save(List.of(definition(1)));
|
||||
assertEquals(List.of(definition(1)), new VotingNPCStore(file).load());
|
||||
}
|
||||
Path leaf = linked.resolve("alias.json");
|
||||
Files.createSymbolicLink(leaf, nested.resolve("npcs.json"));
|
||||
assertThrows(IOException.class, () -> new VotingNPCStore(leaf).load());
|
||||
assertEquals(List.of(definition(1)), new VotingNPCStore(nested.resolve("npcs.json")).load());
|
||||
}
|
||||
|
||||
@Test void invalidDefinitionsAreRejectedBeforePublication() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new VotingNPCDefinition("bad id", new UUID(0, 1),
|
||||
new UUID(1, 0), 0, 64, 0, 0, 0, "Vote"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new VotingNPCDefinition("valid", new UUID(0, 1),
|
||||
new UUID(1, 0), Double.NaN, 64, 0, 0, 0, "Vote"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new VotingNPCDefinition("valid", new UUID(0, 1),
|
||||
new UUID(1, 0), 0, 64, 0, 0, 0, "Vote\nNew line"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Vanilla voting NPCs
|
||||
|
||||
Voting NPCs are public, named vanilla villagers that open the existing voting-site
|
||||
selection. They do not receive votes, grant rewards, change totals, or replace
|
||||
the normal `/vote` menus. No Citizens, fake-player internals, resource pack or
|
||||
client mod is required.
|
||||
|
||||
## Enable and create
|
||||
|
||||
The feature is disabled by default. Add this to **Config.yml**, or change the
|
||||
bundled default, then run `/av reload`:
|
||||
|
||||
```yaml
|
||||
VotingNPCs:
|
||||
Enabled: true
|
||||
```
|
||||
|
||||
Stand where the villager should appear and use:
|
||||
|
||||
```text
|
||||
/av npc create voting &aVote here
|
||||
/av npc list
|
||||
/av npc inspect voting
|
||||
/av npc remove voting
|
||||
```
|
||||
|
||||
IDs contain 1–40 lowercase letters, digits, underscores or hyphens and start with
|
||||
a letter or digit. Names are optional, default to `&aVote`, and allow up to 128
|
||||
characters without control characters. `&` legacy colors are supported; names
|
||||
are not interpreted as commands, PlaceholderAPI, JavaScript or rich-text actions.
|
||||
Creation is player-only. List, inspect and remove also work from console.
|
||||
Administration requires `VotingPlugin.Commands.AdminVote.NPC` (operator default)
|
||||
or `VotingPlugin.Admin`. Both also permit entering the `/av` command root; the
|
||||
NPC permission does not grant other administrative subcommands. Normal vote-URL command permissions still apply when
|
||||
a player interacts; an NPC never grants that permission.
|
||||
|
||||
Right-click with the main hand to open the existing configured vote-URL GUI.
|
||||
Already-cancelled interactions do not open it. Off-hand and repeated interaction
|
||||
packets are suppressed. Trading, damage, combustion, targeting, vehicles and
|
||||
teleportation and transformations (including lightning) are blocked only for the
|
||||
exact tracked NPC entities. Ordinary
|
||||
villagers, other plugins' NPCs and unrelated inventories are unaffected.
|
||||
|
||||
## Persistence and lifecycle
|
||||
|
||||
Definitions are saved asynchronously in **VotingNPCs.json** in the plugin data
|
||||
directory. A success message confirms the definition write, not that a live
|
||||
entity has already spawned. Inspect reports pending creation or cleanup as well
|
||||
as created villagers. At most 64 definitions and 64 runtime reservations exist;
|
||||
storage work also has a bounded queue. File ownership remains exclusive while
|
||||
accepted writes drain during shutdown; shutdown does not wait for file I/O.
|
||||
|
||||
Villagers themselves are **nonpersistent**. Definitions recreate them when their
|
||||
world and surrounding chunks are available; the plugin never loads distant
|
||||
chunks or scans worlds to find NPCs. There are no animation/tick loops. Unloading
|
||||
any chunk occupied by the checked half-block bounds retires its tracked entity;
|
||||
chunk/world loading later reconstructs it. Changing
|
||||
`Enabled` to false and reloading removes live NPCs while retaining definitions.
|
||||
Reloading does not create a second entity for the same configured ID, including
|
||||
when an earlier entity's cleanup is still pending.
|
||||
|
||||
Operator-configured directory aliases are supported; the definitions and lock
|
||||
files themselves must not be symbolic links. Duplicate IDs, capacity limits and
|
||||
a busy worker report fixed actionable messages without requiring a reload.
|
||||
|
||||
An invalid/unreadable definition file is unavailable, never an empty successful
|
||||
load. Failed or uncertain writes stop further edits until a checked reload;
|
||||
do not blindly repeat a command whose result was not confirmed. Preserve the
|
||||
file, correct the problem and reload. Atomic file replacement and the existing
|
||||
platform directory-sync policy are used. Abrupt shutdown has the filesystem's
|
||||
normal durability limits; no universal exactly-once side effects are claimed.
|
||||
|
||||
## Compatibility and limitations
|
||||
|
||||
Spigot/Paper need the ordinary villager APIs, nonpersistent entities and the
|
||||
`World.spawn(Location, Class, Consumer)` initialization API. Capability checks
|
||||
reject unsupported implementations without changing normal voting. This does
|
||||
not raise VotingPlugin's global Java or Minecraft requirement. It is a vanilla
|
||||
villager, not a custom player skin or a Citizens-style scripted NPC.
|
||||
|
||||
Folia creation uses the anchor region and verifies ownership of the villager's
|
||||
bounds; cleanup follows the entity scheduler. A position crossing unavailable
|
||||
or independently owned region bounds may remain unavailable. Choose a position
|
||||
well inside a loaded chunk and retry through reload. No unsafe global-thread
|
||||
entity removal is used. During final server shutdown, scheduler rejection can
|
||||
prevent immediate removal; the nonpersistent entity is discarded at world
|
||||
unload. External hot-disable/reload tools are not a supported substitute for a
|
||||
server restart, particularly on Folia. Definition files remain intact.
|
||||
|
||||
## Testing
|
||||
|
||||
On a disposable server, enable the feature and create an NPC. Check main-hand
|
||||
selection, ordinary vote permissions, cancellation by protections, off-hand
|
||||
input and an unrelated villager. Inspect/list must agree with the saved ID.
|
||||
Create/remove/recreate an ID while cleanup is delayed, reload both enabled and
|
||||
disabled, unload/reload its chunk/world, then restart: one NPC should appear for
|
||||
each enabled definition. Confirm removal affects only the selected NPC.
|
||||
|
||||
Automated tests use real file storage, controlled Bukkit events, and explicit
|
||||
region/entity scheduler queues. They are not visual Minecraft testing or proof
|
||||
of an actual Paper/Folia deployment. Record native platform and client results
|
||||
separately before asserting those scenarios were exercised.
|
||||
Reference in new issue
Block a user