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));
}
}