feat(vigilante): add arena boss succession
Release / release (push) Successful in 2m44s
CI / build (push) Successful in 1m3s

This commit is contained in:
dmg
2026-08-23 17:31:42 -04:00
parent 7318d4afa6
commit 10f670a7da
46 changed files with 1280 additions and 148 deletions
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
public final class ArenaGeometry {
private ArenaGeometry() {
}
public static boolean contains(
ArenaLocation center,
double radius,
String worldName,
double x,
double y,
double z
) {
if (!center.worldName().equals(worldName)) {
return false;
}
double deltaX = x - center.x();
double deltaZ = z - center.z();
return deltaX * deltaX + deltaZ * deltaZ <= radius * radius;
}
public static ArenaPoint outside(
ArenaLocation center,
double radius,
double x,
double y,
double z
) {
double deltaX = x - center.x();
double deltaZ = z - center.z();
double length = Math.hypot(deltaX, deltaZ);
if (length < 0.0001) {
deltaX = 1.0;
deltaZ = 0.0;
length = 1.0;
}
double distance = radius + 1.0;
return new ArenaPoint(
center.x() + deltaX / length * distance,
y,
center.z() + deltaZ / length * distance
);
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigottyrant;
public record ArenaLocation(
String worldName,
double x,
double y,
double z,
float yaw,
float pitch
) {
public ArenaLocation {
if (worldName == null || worldName.isBlank()) {
throw new IllegalArgumentException("arena world must not be blank");
}
worldName = worldName.trim();
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("arena coordinates must be finite");
}
}
public String display() {
return worldName + " at " + Math.round(x) + ", " + Math.round(y) + ", "
+ Math.round(z);
}
}
@@ -0,0 +1,23 @@
package games.dmg.spigottyrant;
import java.util.Optional;
public interface ArenaLocationStore {
Optional<ArenaLocation> location();
void set(ArenaLocation location);
static ArenaLocationStore unavailable() {
return new ArenaLocationStore() {
@Override
public Optional<ArenaLocation> location() {
return Optional.empty();
}
@Override
public void set(ArenaLocation location) {
throw new IllegalStateException("arena location storage is unavailable");
}
};
}
}
@@ -0,0 +1,4 @@
package games.dmg.spigottyrant;
public record ArenaPoint(double x, double y, double z) {
}
@@ -0,0 +1,55 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
public final class BukkitConfigArenaLocationStore implements ArenaLocationStore {
private static final String PATH = "vigilante-arena-location";
private final JavaPlugin plugin;
public BukkitConfigArenaLocationStore(JavaPlugin plugin) {
this.plugin = plugin;
}
@Override
public Optional<ArenaLocation> location() {
FileConfiguration config = plugin.getConfig();
if (!config.isConfigurationSection(PATH)) {
return Optional.empty();
}
String world = config.getString(PATH + ".world");
if (world == null) {
throw new IllegalArgumentException(PATH + ".world must be configured");
}
return Optional.of(new ArenaLocation(
world,
finite(config, "x"),
finite(config, "y"),
finite(config, "z"),
(float) finite(config, "yaw"),
(float) finite(config, "pitch")
));
}
@Override
public void set(ArenaLocation location) {
FileConfiguration config = plugin.getConfig();
config.set(PATH + ".world", location.worldName());
config.set(PATH + ".x", location.x());
config.set(PATH + ".y", location.y());
config.set(PATH + ".z", location.z());
config.set(PATH + ".yaw", location.yaw());
config.set(PATH + ".pitch", location.pitch());
plugin.saveConfig();
}
private double finite(FileConfiguration config, String key) {
String path = PATH + "." + key;
if (!config.isSet(path) || !(config.get(path) instanceof Number number)
|| !Double.isFinite(number.doubleValue())) {
throw new IllegalArgumentException(path + " must be finite and numeric");
}
return number.doubleValue();
}
}
@@ -262,7 +262,7 @@ public final class BukkitVigilanteControlPanelRenderer
Inventory inventory = create(holder, 27, TITLE + " — Role Control");
inventory.setItem(13, item(Material.REDSTONE_TORCH,
ChatColor.RED + "Relinquish Vigilante Role",
List.of("Followers will be cleared and a replacement selected.",
List.of("Followers will be cleared and the arena will open.",
"Click to review confirmation.")));
inventory.setItem(22, backItem());
player.openInventory(inventory);
@@ -276,7 +276,7 @@ public final class BukkitVigilanteControlPanelRenderer
Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment");
inventory.setItem(11, cancelItem());
inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role",
List.of("This clears all Followers and rerolls the Vigilante.")));
List.of("This clears all Followers and opens the arena challenge.")));
inventory.setItem(15, confirmItem());
player.openInventory(inventory);
}
@@ -12,6 +12,10 @@ import org.bukkit.configuration.ConfigurationSection;
public record PluginSettings(
double tyrantRangeBlocks,
double followerRangeBlocks,
double vigilanteArenaRadiusBlocks,
double vigilanteBossHealth,
double vigilanteBossDamage,
double vigilanteBossArmor,
Duration optOutDuration,
Duration roleInactivity,
Duration candidateActivityWindow,
@@ -48,6 +52,12 @@ public record PluginSettings(
public PluginSettings {
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
requirePositiveFinite(followerRangeBlocks, "follower-range-blocks");
requireFiniteRange(
vigilanteArenaRadiusBlocks, 1.0, 512.0, "vigilante-arena-radius-blocks"
);
requireFiniteRange(vigilanteBossHealth, 1.0, 1024.0, "vigilante-boss-health");
requireFiniteRange(vigilanteBossDamage, 0.1, 2048.0, "vigilante-boss-damage");
requireFiniteRange(vigilanteBossArmor, 0.0, 30.0, "vigilante-boss-armor");
requirePositive(optOutDuration, "opt-out-seconds");
requirePositive(roleInactivity, "role-inactivity-seconds");
requirePositive(candidateActivityWindow, "candidate-activity-window-seconds");
@@ -93,6 +103,10 @@ public record PluginSettings(
return new PluginSettings(
decimal(values, "tyrant-range-blocks", 50.0),
decimal(values, "follower-range-blocks", 50.0),
decimal(values, "vigilante-arena-radius-blocks", 10.0),
decimal(values, "vigilante-boss-health", 80.0),
decimal(values, "vigilante-boss-damage", 8.0),
decimal(values, "vigilante-boss-armor", 10.0),
duration(values, "opt-out-seconds", Duration.ofDays(7)),
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
duration(values, "candidate-activity-window-seconds", Duration.ofHours(24)),
@@ -227,6 +241,19 @@ public record PluginSettings(
}
}
private static void requireFiniteRange(
double value,
double minimum,
double maximum,
String key
) {
if (!Double.isFinite(value) || value < minimum || value > maximum) {
throw new IllegalArgumentException(
key + " must be between " + minimum + " and " + maximum
);
}
}
private static void requirePositive(Duration value, String key) {
Objects.requireNonNull(value, key);
if (value.isZero() || value.isNegative()) {
@@ -53,17 +53,9 @@ public final class RoleMaintenanceService {
);
}
current = rerollExpiredTyrant(current, onlinePlayerIds, now);
current = rerollExpiredVigilante(current, onlinePlayerIds, now);
if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) {
current = selectTyrant(current, Optional.empty(), onlinePlayerIds, now);
}
if (current.game().tyrantId().isPresent()
&& current.game().vigilanteId().isEmpty()
&& current.game().pendingVigilante().isEmpty()) {
current = rerollVigilante(
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
);
}
return current;
}
@@ -74,17 +66,9 @@ public final class RoleMaintenanceService {
Instant now
) {
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
Set<UUID> excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of);
Optional<UUID> selected = candidates.select(
players, excluded, previousVigilante, now, activityWindow, random
);
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pending = selected
.filter(playerId -> active.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = copyRoles(
state.game(), state.game().tyrantId(), active,
state.game().pendingTyrant(), pending
state.game(), state.game().tyrantId(), Optional.empty(),
state.game().pendingTyrant(), Optional.empty()
);
return new LifecycleState(game, players);
}
@@ -109,16 +93,8 @@ public final class RoleMaintenanceService {
pendingTyrant = Optional.empty();
}
}
if (pendingVigilante.isPresent()) {
PendingSelection pending = pendingVigilante.orElseThrow();
if (!now.isAfter(pending.expiresAt())
&& onlinePlayerIds.contains(pending.candidateId())
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
&& tyrant.filter(pending.candidateId()::equals).isEmpty()) {
vigilante = Optional.of(pending.candidateId());
pendingVigilante = Optional.empty();
}
}
// Legacy pending Vigilante selections become arena vacancies.
pendingVigilante = Optional.empty();
return new LifecycleState(
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
state.players()
@@ -163,23 +139,6 @@ public final class RoleMaintenanceService {
return new LifecycleState(game, state.players());
}
private LifecycleState rerollExpiredVigilante(
LifecycleState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
Optional<PendingSelection> pending = state.game().pendingVigilante();
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
return state;
}
return rerollVigilante(
state.asPersistentState(),
Optional.of(pending.orElseThrow().candidateId()),
onlinePlayerIds,
now
);
}
private boolean isInactive(
Optional<UUID> roleHolder,
Map<UUID, PlayerState> players,
@@ -16,6 +16,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private FixerEffectController fixerEffects;
private TyrantEffectController tyrantEffects;
private OptOutGlowController optOutGlow;
private VigilanteArenaController vigilanteArena;
@Override
public void onEnable() {
@@ -33,6 +34,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
return;
}
ArenaLocationStore arenaLocations = new BukkitConfigArenaLocationStore(this);
try {
arenaLocations.location();
} catch (IllegalArgumentException exception) {
getLogger().log(Level.SEVERE, "Invalid Vigilante arena location", exception);
getServer().getPluginManager().disablePlugin(this);
return;
}
Clock clock = Clock.systemUTC();
RoleCandidateSelector candidateSelector = new RoleCandidateSelector();
java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault();
@@ -131,10 +140,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
onlinePlayers,
managedEffects,
clock,
candidateSelector,
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
arenaLocations
));
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
.setTabCompleter(new TyrantTabCompleter(stateManager, onlinePlayers));
@@ -148,6 +154,10 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
this
);
vigilanteArena = new VigilanteArenaController(
stateManager, getServer(), arenaLocations, settings
);
getServer().getPluginManager().registerEvents(vigilanteArena, this);
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
getServer().getPluginManager().registerEvents(
@@ -243,6 +253,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, vigilanteArena, 1L, 10L);
getServer().getScheduler().runTaskTimer(
this,
new AbilityItemRefreshTask(
@@ -272,6 +283,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (vigilanteArena != null) {
vigilanteArena.close();
}
if (optOutGlow != null) {
optOutGlow.clearAll();
}
@@ -1,12 +1,8 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
@@ -20,10 +16,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
private final OnlinePlayerDirectory onlinePlayers;
private final ManagedRoleEffects effects;
private final Clock clock;
private final RoleCandidateSelector candidateSelector;
private final Duration activityWindow;
private final Duration pendingTimeout;
private final RandomGenerator random;
private final ArenaLocationStore arenaLocations;
public TyrantAdminCommand(
TyrantStateManager stateManager,
@@ -34,8 +27,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
) {
this(
stateManager, lifecycle, onlinePlayers, effects, clock,
new RoleCandidateSelector(), Duration.ofHours(24), Duration.ofHours(24),
RandomGenerator.getDefault()
ArenaLocationStore.unavailable()
);
}
@@ -45,20 +37,14 @@ public final class TyrantAdminCommand implements CommandExecutor {
OnlinePlayerDirectory onlinePlayers,
ManagedRoleEffects effects,
Clock clock,
RoleCandidateSelector candidateSelector,
Duration activityWindow,
Duration pendingTimeout,
RandomGenerator random
ArenaLocationStore arenaLocations
) {
this.stateManager = stateManager;
this.lifecycle = lifecycle;
this.onlinePlayers = onlinePlayers;
this.effects = effects;
this.clock = clock;
this.candidateSelector = candidateSelector;
this.activityWindow = activityWindow;
this.pendingTimeout = pendingTimeout;
this.random = random;
this.arenaLocations = arenaLocations;
}
@Override
@@ -82,6 +68,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
case "pause" -> pause(sender);
case "resume" -> resume(sender);
case "reset" -> reset(sender, arguments);
case "arena" -> arena(sender, arguments);
default -> usage(sender);
}
} catch (IllegalArgumentException | IllegalStateException exception) {
@@ -112,38 +99,17 @@ public final class TyrantAdminCommand implements CommandExecutor {
stateManager.player(tyrant.getUniqueId(), tyrant.getName());
vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName()));
InstantHolder now = new InstantHolder(clock.instant());
Optional<UUID> randomCandidate = vigilante.isEmpty()
? candidateSelector.select(
stateManager.players(), Set.of(tyrant.getUniqueId()), Optional.empty(),
now.value(), activityWindow, random
)
: Optional.empty();
Optional<UUID> activeVigilante = vigilante.map(Player::getUniqueId);
if (activeVigilante.isEmpty() && randomCandidate.isPresent()
&& onlinePlayers.findById(randomCandidate.orElseThrow()) != null) {
activeVigilante = randomCandidate;
}
LifecycleState started = lifecycle.start(
stateManager.snapshot(), tyrant.getUniqueId(), activeVigilante, now.value()
stateManager.snapshot(), tyrant.getUniqueId(),
vigilante.map(Player::getUniqueId), now.value()
);
if (activeVigilante.isEmpty() && randomCandidate.isPresent()) {
GameState game = started.game();
started = new LifecycleState(new GameState(
game.lifecycle(), game.tyrantId(), game.vigilanteId(), game.pendingTyrant(),
Optional.of(new PendingSelection(
randomCandidate.orElseThrow(), now.value().plus(pendingTimeout)
)),
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
game.unspentChoices(), game.purchases()
), started.players());
}
stateManager.replaceState(started);
stateManager.saveIfDirty();
effects.restoreAll();
sender.sendMessage(ChatColor.GREEN + "Tyrant game started.");
if (vigilante.isEmpty()) {
sender.sendMessage(ChatColor.YELLOW
+ "An eligible recently active Vigilante will be selected automatically.");
+ "The Vigilante arena challenge is open.");
}
}
@@ -175,6 +141,30 @@ public final class TyrantAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
}
private void arena(CommandSender sender, String[] arguments) {
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
sender.sendMessage("Vigilante arena: " + arenaLocations.location()
.map(ArenaLocation::display).orElse("not configured"));
return;
}
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("set")) {
sender.sendMessage(ChatColor.YELLOW
+ "Usage: /tyrantadmin arena <set|status>");
return;
}
if (!(sender instanceof Player player)) {
sender.sendMessage(ChatColor.RED + "Only a player can set the arena location.");
return;
}
org.bukkit.Location location = player.getLocation();
ArenaLocation arena = new ArenaLocation(
location.getWorld().getName(), location.getX(), location.getY(), location.getZ(),
location.getYaw(), location.getPitch()
);
arenaLocations.set(arena);
sender.sendMessage(ChatColor.GREEN + "Vigilante arena set to " + arena.display() + ".");
}
private void showStatus(CommandSender sender) {
GameState game = stateManager.game();
sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle());
@@ -186,6 +176,8 @@ public final class TyrantAdminCommand implements CommandExecutor {
+ ", purchases: " + game.purchases());
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
sender.sendMessage("Vigilante arena: " + arenaLocations.location()
.map(ArenaLocation::display).orElse("not configured"));
for (PlayerState player : stateManager.players().values()) {
boolean optedOut = player.optedOutUntil().isPresent();
sender.sendMessage(
@@ -202,6 +194,6 @@ public final class TyrantAdminCommand implements CommandExecutor {
private static void usage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm>");
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set>");
}
}
@@ -11,7 +11,7 @@ import org.bukkit.entity.Player;
public final class TyrantAdminTabCompleter implements TabCompleter {
private static final String PERMISSION = "spigottyrant.admin";
private static final List<String> SUBCOMMANDS = List.of(
"status", "start", "pause", "resume", "reset"
"status", "start", "pause", "resume", "reset", "arena"
);
private final OnlinePlayerDirectory onlinePlayers;
@@ -37,6 +37,9 @@ public final class TyrantAdminTabCompleter implements TabCompleter {
return switch (subcommand) {
case "start" -> TabSuggestions.matching(arguments[1], onlineNames(null));
case "reset" -> TabSuggestions.matching(arguments[1], List.of("confirm"));
case "arena" -> TabSuggestions.matching(
arguments[1], List.of("set", "status")
);
default -> List.of();
};
}
@@ -365,7 +365,7 @@ public final class TyrantCommand implements CommandExecutor {
private void relinquish(Player player, String[] arguments) {
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
player.sendMessage(ChatColor.RED
+ "Relinquishing rerolls your role. Use /tyrant relinquish confirm.");
+ "Relinquishing ends your role. Use /tyrant relinquish confirm.");
return;
}
if (relinquishment == null) {
@@ -38,7 +38,6 @@ public final class TyrantSuccessionService {
throw new IllegalStateException("a running Tyrant reign is required");
}
UUID formerTyrant = state.game().tyrantId().orElseThrow();
Optional<UUID> formerVigilante = state.game().vigilanteId();
Map<UUID, PlayerState> clearedPlayers = clearAssignments(state.players());
Optional<UUID> selectedTyrant = killerId.isPresent()
? killerId
@@ -56,28 +55,12 @@ public final class TyrantSuccessionService {
.filter(playerId -> activeTyrant.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
Optional<UUID> selectedVigilante = activeTyrant.flatMap(tyrantId ->
candidates.select(
clearedPlayers,
Set.of(tyrantId),
formerVigilante,
now,
activityWindow,
random
)
);
Optional<UUID> activeVigilante = selectedVigilante
.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pendingVigilante = selectedVigilante
.filter(playerId -> activeVigilante.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = new GameState(
GameLifecycle.RUNNING,
activeTyrant,
activeVigilante,
Optional.empty(),
pendingTyrant,
pendingVigilante,
Optional.empty(),
Optional.empty(),
state.game().accumulatedPausedTime(),
0,
@@ -0,0 +1,59 @@
package games.dmg.spigottyrant;
import java.util.LinkedHashMap;
import java.util.Map;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
final class VigilanteArenaBarrier {
private final Map<Location, BlockData> replaced = new LinkedHashMap<>();
private boolean built;
void build(World world, ArenaLocation center, double radius) {
if (built) {
return;
}
built = true;
int samples = Math.max(64, (int) Math.ceil(2.0 * Math.PI * radius * 2.0));
int baseY = (int) Math.floor(center.y());
for (int index = 0; index < samples; index++) {
double angle = 2.0 * Math.PI * index / samples;
int x = (int) Math.floor(center.x() + Math.cos(angle) * radius);
int z = (int) Math.floor(center.z() + Math.sin(angle) * radius);
for (int y = baseY; y < baseY + 3; y++) {
Block block = world.getBlockAt(x, y, z);
Location location = block.getLocation();
if (replaced.containsKey(location) || !isAir(block.getType())) {
continue;
}
replaced.put(location, block.getBlockData().clone());
block.setType(Material.BARRIER, false);
}
}
}
void clear() {
for (Map.Entry<Location, BlockData> entry : replaced.entrySet()) {
Location location = entry.getKey();
World world = location.getWorld();
if (world != null) {
world.getBlockAt(location).setBlockData(entry.getValue(), false);
}
}
replaced.clear();
built = false;
}
boolean active() {
return built;
}
private static boolean isAir(Material material) {
return material == Material.AIR
|| material == Material.CAVE_AIR
|| material == Material.VOID_AIR;
}
}
@@ -0,0 +1,505 @@
package games.dmg.spigottyrant;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.entity.Zombie;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.event.entity.EntityCombustEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityTargetLivingEntityEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
public final class VigilanteArenaController implements Listener, Runnable {
private static final String BOSS_TAG = "spigottyrant-vigilante-boss";
private final TyrantStateManager stateManager;
private final Server server;
private final ArenaLocationStore locations;
private final PluginSettings settings;
private final VigilanteArenaSuccessionService succession =
new VigilanteArenaSuccessionService();
private final VigilanteArenaBarrier barrier = new VigilanteArenaBarrier();
private final Set<UUID> relocating = new HashSet<>();
private UUID bossId;
private UUID challengerId;
private boolean spawningBoss;
private boolean wasOpen;
private boolean warnedMissingLocation;
private ArenaLocation observedLocation;
public VigilanteArenaController(
TyrantStateManager stateManager,
Server server,
ArenaLocationStore locations,
PluginSettings settings
) {
this.stateManager = stateManager;
this.server = server;
this.locations = locations;
this.settings = settings;
}
@Override
public void run() {
Optional<ArenaLocation> configured = locations.location();
if (configured.isEmpty()) {
closeFight();
observedLocation = null;
warnIfOpenWithoutLocation();
wasOpen = false;
return;
}
ArenaLocation arena = configured.orElseThrow();
if (observedLocation != null && !observedLocation.equals(arena)) {
closeFight();
}
observedLocation = arena;
World world = server.getWorld(arena.worldName());
if (world == null) {
closeFight();
warnIfOpenWithoutLocation();
wasOpen = false;
return;
}
warnedMissingLocation = false;
drawBoundary(world, arena);
removeOtherMobs(world, arena);
boolean open = VigilanteArenaPolicy.isOpen(stateManager.game());
if (open && !wasOpen) {
announceVacancy(arena);
}
wasOpen = open;
if (!open) {
closeFight();
return;
}
Zombie boss = boss(world);
if (boss == null) {
boss = spawnBoss(world, arena);
}
boss.setFireTicks(0);
if (!contains(arena, boss.getLocation())) {
boss.teleport(toBukkit(world, arena));
}
Player challenger = challengerId == null ? null : server.getPlayer(challengerId);
if (challenger == null || challenger.isDead()
|| !contains(arena, challenger.getLocation())
|| !VigilanteArenaPolicy.isEligible(
stateManager.game(), playerState(challenger)
)) {
if (challengerId != null) {
resetFight();
}
boss.setTarget(null);
} else {
boss.setTarget(challenger);
}
for (Player player : world.getPlayers()) {
enforcePosition(player, null, player.getLocation());
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if (event.getTo() != null && movedHorizontally(event.getFrom(), event.getTo())
&& enforcePosition(event.getPlayer(), event.getFrom(), event.getTo())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) {
if (event.getTo() != null
&& enforcePosition(event.getPlayer(), event.getFrom(), event.getTo())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onRespawn(PlayerRespawnEvent event) {
Optional<ArenaLocation> configured = locations.location();
if (configured.isPresent() && contains(
configured.orElseThrow(), event.getRespawnLocation())) {
event.setRespawnLocation(outside(configured.orElseThrow(), event.getRespawnLocation()));
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerDeath(PlayerDeathEvent event) {
if (event.getEntity().getUniqueId().equals(challengerId)) {
resetFight();
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
if (event.getPlayer().getUniqueId().equals(challengerId)) {
resetFight();
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
if (!VigilanteArenaPolicy.isOpen(stateManager.game())) {
return;
}
PlayerState state = playerState(event.getPlayer());
locations.location().filter(location -> VigilanteArenaPolicy.isEligible(
stateManager.game(), state
)).ifPresent(location -> event.getPlayer().sendMessage(vacancyMessage(location)));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCreatureSpawn(CreatureSpawnEvent event) {
Optional<ArenaLocation> configured = locations.location();
if (!spawningBoss && configured.isPresent()
&& contains(configured.orElseThrow(), event.getLocation())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCombust(EntityCombustEvent event) {
if (event.getEntity().getUniqueId().equals(bossId)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onTarget(EntityTargetLivingEntityEvent event) {
if (!event.getEntity().getUniqueId().equals(bossId)) {
return;
}
Entity target = event.getTarget();
if (target != null && (challengerId == null
|| !target.getUniqueId().equals(challengerId))) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDamage(EntityDamageByEntityEvent event) {
if (!event.getEntity().getUniqueId().equals(bossId)) {
return;
}
Player attacker = attackingPlayer(event.getDamager());
if (attacker == null || challengerId == null
|| !attacker.getUniqueId().equals(challengerId)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onBossDeath(EntityDeathEvent event) {
if (!event.getEntity().getUniqueId().equals(bossId)) {
return;
}
UUID defeatedBoss = bossId;
bossId = null;
Player killer = event.getEntity().getKiller();
if (killer != null && challengerId != null
&& killer.getUniqueId().equals(challengerId)
&& VigilanteArenaPolicy.isEligible(
stateManager.game(), playerState(killer)
)) {
assignVigilante(challengerId);
return;
}
bossId = defeatedBoss;
resetFight();
}
public void close() {
closeFight();
}
private boolean enforcePosition(Player player, Location from, Location to) {
Optional<ArenaLocation> configured = locations.location();
if (configured.isEmpty() || relocating.contains(player.getUniqueId())) {
return false;
}
ArenaLocation arena = configured.orElseThrow();
boolean inside = contains(arena, to);
if (player.getUniqueId().equals(challengerId)) {
if (!inside) {
resetFight();
}
return false;
}
if (!inside) {
return false;
}
PlayerState state = playerState(player);
if (challengerId == null && bossId != null
&& VigilanteArenaPolicy.isEligible(stateManager.game(), state)) {
challengerId = player.getUniqueId();
World world = server.getWorld(arena.worldName());
if (world != null) {
barrier.build(world, arena, settings.vigilanteArenaRadiusBlocks());
Zombie boss = boss(world);
if (boss != null) {
boss.setHealth(settings.vigilanteBossHealth());
boss.teleport(toBukkit(world, arena));
boss.setTarget(player);
}
}
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become Vigilante!");
return false;
}
bounce(player, from, to, arena);
return true;
}
private void bounce(Player player, Location from, Location attempted, ArenaLocation arena) {
Location destination = from != null && !contains(arena, from)
? from.clone() : outside(arena, attempted);
relocating.add(player.getUniqueId());
try {
player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
} finally {
relocating.remove(player.getUniqueId());
}
player.sendMessage(ChatColor.RED + "Only the active challenger may enter the arena.");
}
private void resetFight() {
challengerId = null;
barrier.clear();
Optional<ArenaLocation> configured = locations.location();
if (configured.isEmpty()) {
return;
}
World world = server.getWorld(configured.orElseThrow().worldName());
Zombie boss = world == null ? null : boss(world);
if (boss != null && !boss.isDead()) {
boss.setHealth(settings.vigilanteBossHealth());
boss.teleport(toBukkit(world, configured.orElseThrow()));
boss.setTarget(null);
} else {
bossId = null;
}
}
private void closeFight() {
challengerId = null;
barrier.clear();
if (bossId != null) {
Entity entity = server.getEntity(bossId);
if (entity != null) {
entity.remove();
}
bossId = null;
}
}
private Zombie spawnBoss(World world, ArenaLocation arena) {
for (Entity entity : world.getEntities()) {
if (entity.getScoreboardTags().contains(BOSS_TAG)) {
entity.remove();
}
}
spawningBoss = true;
try {
Zombie boss = world.spawn(toBukkit(world, arena), Zombie.class, zombie -> {
zombie.setAdult();
zombie.setCustomName(ChatColor.DARK_RED + "Vigilante's Trial");
zombie.setCustomNameVisible(true);
zombie.setRemoveWhenFarAway(false);
zombie.setCanPickupItems(false);
zombie.addScoreboardTag(BOSS_TAG);
setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth());
setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage());
setAttribute(zombie, Attribute.ARMOR, settings.vigilanteBossArmor());
zombie.setHealth(settings.vigilanteBossHealth());
});
bossId = boss.getUniqueId();
return boss;
} finally {
spawningBoss = false;
}
}
private Zombie boss(World world) {
if (bossId == null) {
return null;
}
Entity entity = server.getEntity(bossId);
return entity instanceof Zombie zombie && entity.getWorld().equals(world)
&& !zombie.isDead() ? zombie : null;
}
private void assignVigilante(UUID playerId) {
LifecycleState assigned;
try {
assigned = succession.claim(stateManager.snapshot(), playerId);
} catch (IllegalStateException exception) {
resetFight();
return;
}
stateManager.replaceState(assigned);
stateManager.saveIfDirty();
challengerId = null;
barrier.clear();
String name = Optional.ofNullable(server.getPlayer(playerId))
.map(Player::getName).orElse(playerId.toString());
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the Vigilante!";
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
private void announceVacancy(ArenaLocation arena) {
String message = vacancyMessage(arena);
for (Player player : server.getOnlinePlayers()) {
PlayerState state = playerState(player);
if (VigilanteArenaPolicy.isEligible(stateManager.game(), state)) {
player.sendMessage(message);
}
}
}
private void warnIfOpenWithoutLocation() {
if (!VigilanteArenaPolicy.isOpen(stateManager.game()) || warnedMissingLocation) {
return;
}
warnedMissingLocation = true;
server.getConsoleSender().sendMessage(ChatColor.RED
+ "The Vigilante role is vacant, but no valid arena location is configured. "
+ "Use /tyrantadmin arena set.");
for (Player player : server.getOnlinePlayers()) {
if (player.hasPermission("spigottyrant.admin")) {
player.sendMessage(ChatColor.RED
+ "The Vigilante arena location is not configured.");
}
}
}
private String vacancyMessage(ArenaLocation arena) {
return ChatColor.YELLOW + "The Vigilante position is open! Challenge the boss in "
+ arena.display() + ".";
}
private void removeOtherMobs(World world, ArenaLocation arena) {
Location center = toBukkit(world, arena);
double radius = settings.vigilanteArenaRadiusBlocks();
for (Entity entity : world.getNearbyEntities(center, radius, world.getMaxHeight(), radius)) {
if (entity instanceof Mob && !entity.getUniqueId().equals(bossId)
&& contains(arena, entity.getLocation())) {
entity.remove();
}
}
}
private void drawBoundary(World world, ArenaLocation arena) {
if (barrier.active()) {
return;
}
double radius = settings.vigilanteArenaRadiusBlocks();
for (int index = 0; index < 48; index++) {
double angle = 2.0 * Math.PI * index / 48.0;
Location point = new Location(
world,
arena.x() + Math.cos(angle) * radius,
arena.y() + 1.0,
arena.z() + Math.sin(angle) * radius
);
world.spawnParticle(Particle.END_ROD, point, 1, 0.0, 0.0, 0.0, 0.0);
}
}
private boolean contains(ArenaLocation arena, Location location) {
World world = location.getWorld();
return world != null && ArenaGeometry.contains(
arena, settings.vigilanteArenaRadiusBlocks(), world.getName(),
location.getX(), location.getY(), location.getZ()
);
}
private Location outside(ArenaLocation arena, Location attempted) {
ArenaPoint point = ArenaGeometry.outside(
arena, settings.vigilanteArenaRadiusBlocks(), attempted.getX(),
attempted.getY(), attempted.getZ()
);
World world = server.getWorld(arena.worldName());
if (world == null) {
return attempted;
}
int x = (int) Math.floor(point.x());
int z = (int) Math.floor(point.z());
int preferredY = (int) Math.floor(point.y());
for (int offset = 0; offset <= 8; offset++) {
int[] candidates = offset == 0
? new int[] {preferredY} : new int[] {preferredY + offset, preferredY - offset};
for (int y : candidates) {
if (y > world.getMinHeight() && y + 1 < world.getMaxHeight()
&& world.getBlockAt(x, y, z).isPassable()
&& world.getBlockAt(x, y + 1, z).isPassable()
&& !world.getBlockAt(x, y - 1, z).isPassable()) {
return new Location(world, x + 0.5, y, z + 0.5,
attempted.getYaw(), attempted.getPitch());
}
}
}
int surfaceY = Math.min(
world.getMaxHeight() - 2,
Math.max(world.getMinHeight() + 1, world.getHighestBlockYAt(x, z) + 1)
);
return new Location(world, x + 0.5, surfaceY, z + 0.5,
attempted.getYaw(), attempted.getPitch());
}
private PlayerState playerState(Player player) {
PlayerState existing = stateManager.players().get(player.getUniqueId());
return existing == null
? stateManager.player(player.getUniqueId(), player.getName()) : existing;
}
private static Location toBukkit(World world, ArenaLocation arena) {
return new Location(
world, arena.x(), arena.y(), arena.z(), arena.yaw(), arena.pitch()
);
}
private static Player attackingPlayer(Entity damager) {
if (damager instanceof Player player) {
return player;
}
if (damager instanceof Projectile projectile
&& projectile.getShooter() instanceof Player player) {
return player;
}
return null;
}
private static void setAttribute(Zombie zombie, Attribute attribute, double value) {
if (zombie.getAttribute(attribute) != null) {
zombie.getAttribute(attribute).setBaseValue(value);
}
}
private static boolean movedHorizontally(Location from, Location to) {
return from.getWorld() != to.getWorld()
|| from.getX() != to.getX() || from.getZ() != to.getZ();
}
}
@@ -0,0 +1,19 @@
package games.dmg.spigottyrant;
public final class VigilanteArenaPolicy {
private VigilanteArenaPolicy() {
}
public static boolean isOpen(GameState game) {
return game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().isPresent()
&& game.vigilanteId().isEmpty();
}
public static boolean isEligible(GameState game, PlayerState player) {
return player != null
&& isOpen(game)
&& player.optedOutUntil().isEmpty()
&& game.tyrantId().filter(player.playerId()::equals).isEmpty();
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
public final class VigilanteArenaSuccessionService {
public LifecycleState claim(PersistentState state, UUID winnerId) {
PlayerState winner = state.players().get(winnerId);
if (!VigilanteArenaPolicy.isEligible(state.game(), winner)) {
throw new IllegalStateException("player is not eligible to claim the Vigilante role");
}
GameState game = state.game();
GameState assigned = new GameState(
game.lifecycle(), game.tyrantId(), Optional.of(winnerId), game.pendingTyrant(),
Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(),
game.tyrantLevel(), game.unspentChoices(), game.purchases()
);
return new LifecycleState(assigned, state.players());
}
}
@@ -55,8 +55,8 @@ public final class VigilanteDeathListener implements Listener {
);
stateManager.replaceState(rerolled);
stateManager.saveIfDirty();
String message = ChatColor.YELLOW + "The Vigilante died; a replacement "
+ (rerolled.game().vigilanteId().isPresent() ? "has been selected." : "is pending.");
String message = ChatColor.YELLOW
+ "The Vigilante died; the arena challenge is now open.";
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
}
+7
View File
@@ -2,6 +2,13 @@
tyrant-range-blocks: 50
follower-range-blocks: 50
# Vigilante arena and boss
vigilante-arena-radius-blocks: 10
vigilante-boss-health: 80
vigilante-boss-damage: 8
vigilante-boss-armor: 10
# Set with /tyrantadmin arena set
# Participation, inactivity, and selection
opt-out-seconds: 604800
role-inactivity-seconds: 172800
+1 -1
View File
@@ -13,7 +13,7 @@ commands:
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
tyrantadmin:
description: Administer the Spigot Tyrant game.
usage: /tyrantadmin <status|start|pause|resume|reset confirm>
usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena <set|status>>
permission: spigottyrant.admin
permissions:
spigottyrant.admin:
@@ -0,0 +1,27 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
final class ArenaGeometryTest {
private final ArenaLocation center = new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F);
@Test
void circleUsesHorizontalDistanceAndWorld() {
assertTrue(ArenaGeometry.contains(center, 10.0, "world", 16.0, 100.0, 3.0));
assertFalse(ArenaGeometry.contains(center, 10.0, "world", 21.0, 64.0, -5.0));
assertFalse(ArenaGeometry.contains(center, 10.0, "nether", 10.0, 64.0, -5.0));
}
@Test
void outsidePointProjectsBeyondNearestBoundary() {
ArenaPoint point = ArenaGeometry.outside(center, 10.0, 10.0, 64.0, -5.0);
assertEquals(21.0, point.x(), 0.001);
assertEquals(64.0, point.y(), 0.001);
assertEquals(-5.0, point.z(), 0.001);
}
}
@@ -0,0 +1,28 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
final class BukkitConfigArenaLocationStoreTest {
@Test
void arenaLocationRoundTripsThroughPluginConfiguration() {
JavaPlugin plugin = mock(JavaPlugin.class);
YamlConfiguration config = new YamlConfiguration();
when(plugin.getConfig()).thenReturn(config);
BukkitConfigArenaLocationStore store = new BukkitConfigArenaLocationStore(plugin);
ArenaLocation expected = new ArenaLocation(
"arena_world", 12.5, 70.0, -4.5, 90.0F, 5.0F
);
store.set(expected);
assertEquals(expected, store.location().orElseThrow());
verify(plugin).saveConfig();
}
}
@@ -24,6 +24,10 @@ final class PluginSettingsTest {
assertEquals(Duration.ofHours(1), settings.fixerCooldown());
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
assertEquals(10.0, settings.vigilanteArenaRadiusBlocks());
assertEquals(80.0, settings.vigilanteBossHealth());
assertEquals(8.0, settings.vigilanteBossDamage());
assertEquals(10.0, settings.vigilanteBossArmor());
}
@Test
@@ -67,5 +71,17 @@ final class PluginSettingsTest {
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("role-inactivity-seconds", -1))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-arena-radius-blocks", 0))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-boss-health", Double.NaN))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-boss-armor", 31))
);
}
}
@@ -32,7 +32,7 @@ final class RoleMaintenanceServiceTest {
);
@Test
void onlinePendingVigilanteConfirmsWithoutConflictingWithTyrant() {
void legacyPendingVigilanteIsClearedForArenaSuccession() {
GameState game = game(
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60)))
@@ -43,12 +43,12 @@ final class RoleMaintenanceServiceTest {
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, CANDIDATE), NOW);
assertEquals(Optional.of(CANDIDATE), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
}
@Test
void expiredPendingSelectionRerollsAwayFromPreviousCandidate() {
void expiredLegacyPendingVigilanteOpensArenaInsteadOfRerolling() {
GameState game = game(
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1)))
@@ -59,12 +59,12 @@ final class RoleMaintenanceServiceTest {
LifecycleState maintained = service.maintain(state, Set.of(TYRANT), NOW);
assertEquals(OTHER,
maintained.game().pendingVigilante().orElseThrow().candidateId());
assertEquals(Optional.empty(), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
}
@Test
void inactiveVigilanteIsRerolledAndFollowersAreCleared() {
void inactiveVigilanteOpensArenaAndFollowersAreCleared() {
PlayerState follower = withFollower(
withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))),
VIGILANTE
@@ -80,7 +80,7 @@ final class RoleMaintenanceServiceTest {
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, OTHER), NOW);
assertEquals(Optional.of(OTHER), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.players().get(FOLLOWER).followerOf());
assertEquals(Optional.of(TYRANT), maintained.game().tyrantId());
}
@@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test;
final class RoleRelinquishmentServiceTest {
@Test
void vigilanteCanRelinquishAndTriggerNormalReroll() {
void vigilanteCanRelinquishAndOpenArenaChallenge() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333");
@@ -49,7 +49,8 @@ final class RoleRelinquishmentServiceTest {
);
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
assertEquals(Optional.of(candidate), result.state().game().vigilanteId());
assertEquals(Optional.empty(), result.state().game().vigilanteId());
assertEquals(Optional.empty(), result.state().game().pendingVigilante());
}
private static PlayerState active(UUID id, Instant now) {
@@ -14,8 +14,11 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class TyrantAdminCommandTest {
@@ -59,6 +62,30 @@ final class TyrantAdminCommandTest {
verify(effects).suppressAll();
}
@Test
void playerCanPersistArenaAtCurrentLocation() {
TyrantStateManager manager = mock(TyrantStateManager.class);
Player sender = mock(Player.class);
World world = mock(World.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
when(world.getName()).thenReturn("arena_world");
when(sender.getLocation()).thenReturn(new Location(world, 12.5, 70.0, -4.5, 90.0F, 0.0F));
ArenaLocationStore store = mock(ArenaLocationStore.class);
TyrantAdminCommand command = new TyrantAdminCommand(
manager, new GameLifecycleService(), mock(OnlinePlayerDirectory.class),
mock(ManagedRoleEffects.class),
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC), store
);
command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"arena", "set"});
verify(store).set(org.mockito.ArgumentMatchers.argThat(location ->
location.worldName().equals("arena_world")
&& location.x() == 12.5 && location.y() == 70.0 && location.z() == -4.5
));
verify(sender).sendMessage(contains("Vigilante arena set"));
}
@Test
void resetRequiresExplicitConfirmation() {
TyrantStateManager manager = mock(TyrantStateManager.class);
@@ -34,7 +34,9 @@ final class TyrantAdminTabCompleterTest {
));
assertTrue(completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {""}
).containsAll(java.util.List.of("status", "start", "pause", "resume", "reset")));
).containsAll(java.util.List.of(
"status", "start", "pause", "resume", "reset", "arena"
)));
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", ""}
));
@@ -44,6 +46,9 @@ final class TyrantAdminTabCompleterTest {
assertEquals(java.util.List.of("confirm"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"reset", "c"}
));
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"arena", "s"}
));
}
private static Player player(UUID id, String name) {
@@ -37,7 +37,8 @@ final class TyrantSuccessionServiceTest {
assertEquals(0, after.game().tyrantLevel());
assertEquals(1, after.game().unspentChoices());
assertEquals(Set.of(), after.game().purchases());
assertNotEquals(after.game().tyrantId(), after.game().vigilanteId());
assertEquals(Optional.empty(), after.game().vigilanteId());
assertEquals(Optional.empty(), after.game().pendingVigilante());
for (PlayerState player : after.players().values()) {
assertEquals(TyrantClass.NONE, player.tyrantClass());
assertEquals(Optional.empty(), player.followerOf());
@@ -0,0 +1,56 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.junit.jupiter.api.Test;
final class VigilanteArenaBarrierTest {
private static final ArenaLocation CENTER = new ArenaLocation(
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F
);
@Test
void doesNotReplaceExistingNonAirBlocks() {
World world = mock(World.class);
Block solid = mock(Block.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid);
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(solid.getType()).thenReturn(Material.CHEST);
new VigilanteArenaBarrier().build(world, CENTER, 10.0);
verify(solid, never()).setType(Material.BARRIER, false);
}
@Test
void restoresAirReplacedByTemporaryBarrier() {
World world = mock(World.class);
Block air = mock(Block.class);
BlockData original = mock(BlockData.class);
BlockData snapshot = mock(BlockData.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(air);
when(world.getBlockAt(any(Location.class))).thenReturn(air);
when(air.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(air.getType()).thenReturn(Material.AIR);
when(air.getBlockData()).thenReturn(original);
when(original.clone()).thenReturn(snapshot);
VigilanteArenaBarrier barrier = new VigilanteArenaBarrier();
barrier.build(world, CENTER, 10.0);
barrier.clear();
verify(air, atLeastOnce()).setType(Material.BARRIER, false);
verify(air).setBlockData(snapshot, false);
}
}
@@ -0,0 +1,80 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
final class VigilanteArenaControllerTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
@Test
void optedInPlayerJoiningDuringVacancyReceivesArenaCoordinates() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(openGame());
when(manager.players()).thenReturn(Map.of(PLAYER, PlayerState.newPlayer(PLAYER, "Player")));
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(
new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F)
));
VigilanteArenaController controller = new VigilanteArenaController(
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
);
controller.onJoin(mockJoin(player));
verify(player).sendMessage(contains("world at 10, 64, -5"));
}
@Test
void optedOutPlayerDoesNotReceiveVacancyNotice() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER);
PlayerState optedOut = new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.parse("2026-08-24T00:00:00Z")),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(openGame());
when(manager.players()).thenReturn(Map.of(PLAYER, optedOut));
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(
new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F)
));
VigilanteArenaController controller = new VigilanteArenaController(
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
);
controller.onJoin(mockJoin(player));
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString());
}
private static PlayerJoinEvent mockJoin(Player player) {
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player);
return event;
}
private static GameState openGame() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class VigilanteArenaPolicyTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
@Test
void opensOnlyForRunningGameWithTyrantAndVacantVigilante() {
assertTrue(VigilanteArenaPolicy.isOpen(game(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty()
)));
assertFalse(VigilanteArenaPolicy.isOpen(game(
GameLifecycle.PAUSED, Optional.of(TYRANT), Optional.empty()
)));
assertFalse(VigilanteArenaPolicy.isOpen(game(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty()
)));
assertFalse(VigilanteArenaPolicy.isOpen(game(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(PLAYER)
)));
}
@Test
void onlyOptedInNonTyrantMayChallenge() {
GameState game = game(GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty());
PlayerState eligible = PlayerState.newPlayer(PLAYER, "Player");
PlayerState optedOut = new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.parse("2026-08-24T12:00:00Z")),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
assertTrue(VigilanteArenaPolicy.isEligible(game, eligible));
assertFalse(VigilanteArenaPolicy.isEligible(game, optedOut));
assertFalse(VigilanteArenaPolicy.isEligible(game,
PlayerState.newPlayer(TYRANT, "Tyrant")));
}
private static GameState game(
GameLifecycle lifecycle,
Optional<UUID> tyrant,
Optional<UUID> vigilante
) {
return new GameState(
lifecycle, tyrant, vigilante, Optional.empty(), Optional.empty(),
lifecycle == GameLifecycle.PAUSED
? Optional.of(Instant.parse("2026-08-23T12:00:00Z")) : Optional.empty(),
Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -0,0 +1,30 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class VigilanteArenaSuccessionServiceTest {
@Test
void eligibleBossWinnerClaimsVacantVigilanteRole() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID winner = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
);
PersistentState before = new PersistentState(
game, Map.of(winner, PlayerState.newPlayer(winner, "Winner"))
);
LifecycleState claimed = new VigilanteArenaSuccessionService().claim(before, winner);
assertEquals(Optional.of(winner), claimed.game().vigilanteId());
assertEquals(Optional.empty(), claimed.game().pendingVigilante());
}
}