feat(tyrant): summon arena bosses only near players
Release / release (push) Successful in 3m9s
CI / build (push) Successful in 1m16s

This commit is contained in:
dmg
2026-09-11 18:40:27 -04:00
parent 58ffad6748
commit a4e80ddce0
4 changed files with 725 additions and 52 deletions
@@ -12,14 +12,29 @@ final class RoleArenaBarrier {
private final Map<Location, BlockData> replaced = new LinkedHashMap<>(); private final Map<Location, BlockData> replaced = new LinkedHashMap<>();
private boolean built; private boolean built;
void build( boolean build(
World world, World world,
ArenaLocation center, ArenaLocation center,
double radius, double radius,
int wallHeight int wallHeight
) { ) {
if (built) { if (built) {
return; return true;
}
restorePending();
if (!replaced.isEmpty()) {
return false;
}
int minX = ((int) Math.floor(center.x() - radius)) >> 4;
int maxX = ((int) Math.floor(center.x() + radius)) >> 4;
int minZ = ((int) Math.floor(center.z() - radius)) >> 4;
int maxZ = ((int) Math.floor(center.z() + radius)) >> 4;
for (int x = minX; x <= maxX; x++) {
for (int z = minZ; z <= maxZ; z++) {
if (!world.isChunkLoaded(x, z)) {
return false;
}
}
} }
built = true; built = true;
int samples = Math.max(64, (int) Math.ceil(2.0 * Math.PI * radius * 2.0)); int samples = Math.max(64, (int) Math.ceil(2.0 * Math.PI * radius * 2.0));
@@ -38,18 +53,33 @@ final class RoleArenaBarrier {
block.setType(Material.BARRIER, false); block.setType(Material.BARRIER, false);
} }
} }
return true;
} }
void clear() { void clear() {
for (Map.Entry<Location, BlockData> entry : replaced.entrySet()) { built = false;
restorePending();
}
void restorePending() {
if (built) {
return;
}
var iterator = replaced.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Location, BlockData> entry = iterator.next();
Location location = entry.getKey(); Location location = entry.getKey();
World world = location.getWorld(); World world = location.getWorld();
if (world != null) { if (world != null && world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
world.getBlockAt(location).setBlockData(entry.getValue(), false); world.getBlockAt(location).setBlockData(entry.getValue(), false);
iterator.remove();
} }
} }
replaced.clear(); }
built = false;
boolean occupies(org.bukkit.Chunk chunk) {
return replaced.keySet().stream().anyMatch(location -> location.getWorld() == chunk.getWorld()
&& location.getBlockX() >> 4 == chunk.getX() && location.getBlockZ() >> 4 == chunk.getZ());
} }
boolean active() { boolean active() {
@@ -33,6 +33,9 @@ import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.world.ChunkUnloadEvent;
import org.bukkit.event.world.EntitiesLoadEvent;
import org.bukkit.event.world.EntitiesUnloadEvent;
public final class RoleArenaController implements Listener, Runnable { public final class RoleArenaController implements Listener, Runnable {
private static final String BOSS_TAG = "spigottyrant-role-arena-boss"; private static final String BOSS_TAG = "spigottyrant-role-arena-boss";
@@ -40,6 +43,7 @@ public final class RoleArenaController implements Listener, Runnable {
private final Server server; private final Server server;
private final ArenaLocationStore locations; private final ArenaLocationStore locations;
private final PluginSettings settings; private final PluginSettings settings;
private final java.util.function.BiConsumer<Zombie, PluginSettings> configureBossAttributes;
private final VigilanteArenaSuccessionService vigilanteSuccession = private final VigilanteArenaSuccessionService vigilanteSuccession =
new VigilanteArenaSuccessionService(); new VigilanteArenaSuccessionService();
private final TyrantArenaSuccessionService tyrantSuccession = private final TyrantArenaSuccessionService tyrantSuccession =
@@ -47,27 +51,49 @@ public final class RoleArenaController implements Listener, Runnable {
private final RoleArenaBarrier barrier = new RoleArenaBarrier(); private final RoleArenaBarrier barrier = new RoleArenaBarrier();
private final Set<UUID> relocating = new HashSet<>(); private final Set<UUID> relocating = new HashSet<>();
private UUID bossId; private UUID bossId;
private Zombie bossEntity;
private UUID challengerId; private UUID challengerId;
private boolean spawningBoss; private Zombie spawningBoss;
private int spawnRetrySweeps;
private boolean spawnWarning;
private ArenaRole previousOpenRole; private ArenaRole previousOpenRole;
private ArenaRole bossRole; private ArenaRole bossRole;
private ArenaRole warnedMissingRole; private ArenaRole warnedMissingRole;
private ArenaLocation observedLocation; private ArenaLocation observedLocation;
private boolean reconciledLoadedBosses;
public RoleArenaController( public RoleArenaController(
TyrantStateManager stateManager, TyrantStateManager stateManager,
Server server, Server server,
ArenaLocationStore locations, ArenaLocationStore locations,
PluginSettings settings PluginSettings settings
) {
this(stateManager, server, locations, settings, RoleArenaController::configureBossAttributes);
}
// Isolate registry-backed attributes from the lifecycle adapter's headless tests.
RoleArenaController(
TyrantStateManager stateManager, Server server, ArenaLocationStore locations,
PluginSettings settings,
java.util.function.BiConsumer<Zombie, PluginSettings> configureBossAttributes
) { ) {
this.stateManager = stateManager; this.stateManager = stateManager;
this.server = server; this.server = server;
this.locations = locations; this.locations = locations;
this.settings = settings; this.settings = settings;
this.configureBossAttributes = configureBossAttributes;
} }
@Override @Override
public void run() { public void run() {
if (spawnRetrySweeps > 0) {
spawnRetrySweeps--;
}
barrier.restorePending();
if (!reconciledLoadedBosses) {
server.getWorlds().forEach(world -> removeStaleBosses(world.getEntities()));
reconciledLoadedBosses = true;
}
Optional<ArenaRole> openRole = RoleArenaPolicy.openRole(stateManager.game()); Optional<ArenaRole> openRole = RoleArenaPolicy.openRole(stateManager.game());
Optional<ArenaLocation> configured = locations.location(); Optional<ArenaLocation> configured = locations.location();
if (configured.isEmpty()) { if (configured.isEmpty()) {
@@ -100,8 +126,15 @@ public final class RoleArenaController implements Listener, Runnable {
} }
previousOpenRole = role; previousOpenRole = role;
drawBoundary(world, arena, role); drawBoundary(world, arena, role);
int chunkX = ((int) Math.floor(arena.x())) >> 4;
int chunkZ = ((int) Math.floor(arena.z())) >> 4;
if (!world.isChunkLoaded(chunkX, chunkZ)
|| !world.getChunkAt(chunkX, chunkZ).isEntitiesLoaded()) {
closeFight();
return;
}
removeOtherMobs(world, arena); removeOtherMobs(world, arena);
if (role == null) { if (role == null || !hasNearbyPlayer(world, arena)) {
closeFight(); closeFight();
return; return;
} }
@@ -109,14 +142,21 @@ public final class RoleArenaController implements Listener, Runnable {
Zombie boss = boss(world); Zombie boss = boss(world);
if (boss == null || bossRole != role) { if (boss == null || bossRole != role) {
closeFight(); closeFight();
if (spawnRetrySweeps > 0) {
return;
}
boss = spawnBoss(world, arena, role); boss = spawnBoss(world, arena, role);
} }
if (boss == null) {
return;
}
boss.setFireTicks(0); boss.setFireTicks(0);
if (!contains(arena, boss.getLocation())) { if (!contains(arena, boss.getLocation())) {
boss.teleport(toBukkit(world, arena)); boss.teleport(toBukkit(world, arena));
} }
Player challenger = challengerId == null ? null : server.getPlayer(challengerId); Player challenger = challengerId == null ? null : server.getPlayer(challengerId);
if (challenger == null || challenger.isDead() if (challenger == null || !challenger.isOnline() || challenger.isDead()
|| !withinBossRange(arena, challenger.getLocation())
|| !contains(arena, challenger.getLocation()) || !contains(arena, challenger.getLocation())
|| !RoleArenaPolicy.isEligible( || !RoleArenaPolicy.isEligible(
stateManager.game(), role, playerState(challenger) stateManager.game(), role, playerState(challenger)
@@ -189,10 +229,45 @@ public final class RoleArenaController implements Listener, Runnable {
)); ));
} }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChunkUnload(ChunkUnloadEvent event) {
Zombie current = bossEntity;
boolean bossChunk = current != null && current.getWorld() == event.getWorld()
&& (current.getLocation().getBlockX() >> 4) == event.getChunk().getX()
&& (current.getLocation().getBlockZ() >> 4) == event.getChunk().getZ();
if (bossChunk || barrier.occupies(event.getChunk())) {
// Restore walls while block chunks are still loaded, before entities unload.
closeFight();
}
}
@EventHandler
public void onEntitiesLoad(EntitiesLoadEvent event) {
// Event payloads may not yet be visible through world/server entity lookups.
removeStaleBosses(event.getEntities());
}
@EventHandler
public void onEntitiesUnload(EntitiesUnloadEvent event) {
removeStaleBosses(event.getEntities());
if (event.getEntities().stream().anyMatch(entity -> entity.getUniqueId().equals(bossId))) {
closeFight();
}
}
private void removeStaleBosses(Iterable<Entity> entities) {
for (Entity entity : entities) {
if (entity.getScoreboardTags().contains(BOSS_TAG) && !entity.getUniqueId().equals(bossId)) {
entity.setPersistent(false);
entity.remove();
}
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCreatureSpawn(CreatureSpawnEvent event) { public void onCreatureSpawn(CreatureSpawnEvent event) {
Optional<ArenaLocation> configured = locations.location(); Optional<ArenaLocation> configured = locations.location();
if (!spawningBoss && configured.isPresent() if (event.getEntity() != spawningBoss && configured.isPresent()
&& contains(configured.orElseThrow(), event.getLocation())) { && contains(configured.orElseThrow(), event.getLocation())) {
event.setCancelled(true); event.setCancelled(true);
} }
@@ -253,8 +328,10 @@ public final class RoleArenaController implements Listener, Runnable {
return; return;
} }
UUID defeatedBoss = bossId; UUID defeatedBoss = bossId;
Zombie defeatedEntity = bossEntity;
ArenaRole defeatedRole = bossRole; ArenaRole defeatedRole = bossRole;
bossId = null; bossId = null;
bossEntity = null;
bossRole = null; bossRole = null;
Player killer = event.getEntity().getKiller(); Player killer = event.getEntity().getKiller();
if (killer != null && challengerId != null && defeatedRole != null if (killer != null && challengerId != null && defeatedRole != null
@@ -266,6 +343,7 @@ public final class RoleArenaController implements Listener, Runnable {
return; return;
} }
bossId = defeatedBoss; bossId = defeatedBoss;
bossEntity = defeatedEntity;
bossRole = defeatedRole; bossRole = defeatedRole;
resetFight(); resetFight();
} }
@@ -276,13 +354,14 @@ public final class RoleArenaController implements Listener, Runnable {
private boolean enforcePosition(Player player, Location from, Location to) { private boolean enforcePosition(Player player, Location from, Location to) {
Optional<ArenaLocation> configured = locations.location(); Optional<ArenaLocation> configured = locations.location();
if (configured.isEmpty() || relocating.contains(player.getUniqueId())) { if (configured.isEmpty() || !player.isOnline() || player.isDead()
|| relocating.contains(player.getUniqueId())) {
return false; return false;
} }
ArenaLocation arena = configured.orElseThrow(); ArenaLocation arena = configured.orElseThrow();
boolean inside = contains(arena, to); boolean inside = contains(arena, to);
if (player.getUniqueId().equals(challengerId)) { if (player.getUniqueId().equals(challengerId)) {
if (!inside) { if (!inside || !withinBossRange(arena, to)) {
resetFight(); resetFight();
} }
return false; return false;
@@ -291,23 +370,29 @@ public final class RoleArenaController implements Listener, Runnable {
return false; return false;
} }
PlayerState state = playerState(player); PlayerState state = playerState(player);
ArenaRole role = bossRole; if (bossId == null && withinBossRange(arena, to)) {
if (challengerId == null && bossId != null && role != null Optional<ArenaRole> openRole = RoleArenaPolicy.openRole(stateManager.game());
&& RoleArenaPolicy.isEligible(stateManager.game(), role, state)) { if (openRole.isPresent() && RoleArenaPolicy.isEligible(
challengerId = player.getUniqueId(); stateManager.game(), openRole.orElseThrow(), state)) {
World world = server.getWorld(arena.worldName()); // Allow an eligible arrival to commit; the sweep summons/admits, not a
if (world != null) { // proposed teleport which another listener could still cancel.
barrier.build( return false;
world, arena, settings.vigilanteArenaRadiusBlocks(),
settings.arenaBarrierHeightBlocks()
);
Zombie boss = boss(world);
if (boss != null) {
boss.setHealth(settings.vigilanteBossHealth());
boss.teleport(toBukkit(world, arena));
ArenaBossState.engage(boss, player);
}
} }
}
ArenaRole role = bossRole;
if (challengerId == null && bossId != null && role != null && withinBossRange(arena, to)
&& RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
World world = server.getWorld(arena.worldName());
Zombie boss = world == null ? null : boss(world);
if (boss == null || !barrier.build(
world, arena, settings.vigilanteArenaRadiusBlocks(),
settings.arenaBarrierHeightBlocks())) {
return false;
}
challengerId = player.getUniqueId();
boss.setHealth(settings.vigilanteBossHealth());
boss.teleport(toBukkit(world, arena));
ArenaBossState.engage(boss, player);
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become " player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become "
+ readable(role) + "!"); + readable(role) + "!");
return false; return false;
@@ -350,25 +435,22 @@ public final class RoleArenaController implements Listener, Runnable {
private void closeFight() { private void closeFight() {
challengerId = null; challengerId = null;
barrier.clear(); barrier.clear();
if (bossId != null) { Zombie retiring = bossEntity;
Entity entity = server.getEntity(bossId); bossEntity = null;
if (entity != null) { bossId = null;
entity.remove();
}
bossId = null;
}
bossRole = null; bossRole = null;
if (retiring != null) {
bossParticles(retiring.getLocation());
retiring.remove();
}
} }
private Zombie spawnBoss(World world, ArenaLocation arena, ArenaRole role) { private Zombie spawnBoss(World world, ArenaLocation arena, ArenaRole role) {
for (Entity entity : world.getEntities()) {
if (entity.getScoreboardTags().contains(BOSS_TAG)) {
entity.remove();
}
}
spawningBoss = true;
try { try {
Zombie boss = world.spawn(toBukkit(world, arena), Zombie.class, zombie -> { Zombie boss = world.spawn(toBukkit(world, arena), Zombie.class, zombie -> {
spawningBoss = zombie;
zombie.setPersistent(false);
zombie.addScoreboardTag(BOSS_TAG);
zombie.setAdult(); zombie.setAdult();
zombie.setCustomName(role == ArenaRole.TYRANT zombie.setCustomName(role == ArenaRole.TYRANT
? ChatColor.RED + "Tyrant's Trial" ? ChatColor.RED + "Tyrant's Trial"
@@ -377,27 +459,48 @@ public final class RoleArenaController implements Listener, Runnable {
zombie.setRemoveWhenFarAway(false); zombie.setRemoveWhenFarAway(false);
zombie.setCanPickupItems(false); zombie.setCanPickupItems(false);
zombie.setAI(false); zombie.setAI(false);
zombie.addScoreboardTag(BOSS_TAG); configureBossAttributes.accept(zombie, settings);
setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth());
setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage());
setAttribute(zombie, Attribute.ARMOR, settings.vigilanteBossArmor());
zombie.setHealth(settings.vigilanteBossHealth()); zombie.setHealth(settings.vigilanteBossHealth());
}); });
spawningBoss = boss;
// Reassert after spawn listeners and never own a cancelled/removed candidate.
boss.setPersistent(false);
if (!boss.isValid() || boss.isDead()) {
boss.remove();
spawnFailed();
return null;
}
spawnWarning = false;
bossEntity = boss;
bossId = boss.getUniqueId(); bossId = boss.getUniqueId();
bossRole = role; bossRole = role;
bossParticles(boss.getLocation());
return boss; return boss;
} catch (RuntimeException exception) {
bossEntity = null;
bossId = null;
bossRole = null;
if (spawningBoss != null) {
spawningBoss.remove();
}
spawnFailed();
return null;
} finally { } finally {
spawningBoss = false; spawningBoss = null;
}
}
private void spawnFailed() {
spawnRetrySweeps = 10; // Five seconds at the controller's ten-tick cadence.
if (!spawnWarning) {
server.getLogger().warning("Tyrant arena boss spawn failed or was cancelled; retrying in five seconds.");
spawnWarning = true;
} }
} }
private Zombie boss(World world) { private Zombie boss(World world) {
if (bossId == null) { return bossEntity != null && bossEntity.getWorld().equals(world)
return null; && bossEntity.isValid() && !bossEntity.isDead() ? bossEntity : null;
}
Entity entity = server.getEntity(bossId);
return entity instanceof Zombie zombie && entity.getWorld().equals(world)
&& !zombie.isDead() ? zombie : null;
} }
void assignRole(UUID playerId, ArenaRole role) { void assignRole(UUID playerId, ArenaRole role) {
@@ -528,6 +631,26 @@ public final class RoleArenaController implements Listener, Runnable {
} }
} }
private void bossParticles(Location location) {
World world = location.getWorld();
if (world != null && world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
world.spawnParticle(Particle.PORTAL, location.clone().add(0, 1, 0),
60, 0.6, 1.0, 0.6, 0.05);
}
}
private boolean hasNearbyPlayer(World world, ArenaLocation arena) {
return world.getPlayers().stream().anyMatch(player -> player.isOnline() && !player.isDead()
&& withinBossRange(arena, player.getLocation()));
}
private boolean withinBossRange(ArenaLocation arena, Location location) {
World world = location.getWorld();
double range = settings.vigilanteArenaRadiusBlocks() + 8.0;
return world != null && world.getName().equals(arena.worldName())
&& location.distanceSquared(toBukkit(world, arena)) <= range * range;
}
private boolean protects(Location location) { private boolean protects(Location location) {
Optional<ArenaLocation> configured = locations.location(); Optional<ArenaLocation> configured = locations.location();
World world = location.getWorld(); World world = location.getWorld();
@@ -603,6 +726,12 @@ public final class RoleArenaController implements Listener, Runnable {
return null; return null;
} }
private static void configureBossAttributes(Zombie zombie, PluginSettings settings) {
setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth());
setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage());
setAttribute(zombie, Attribute.ARMOR, settings.vigilanteBossArmor());
}
private static void setAttribute(Zombie zombie, Attribute attribute, double value) { private static void setAttribute(Zombie zombie, Attribute attribute, double value) {
if (zombie.getAttribute(attribute) != null) { if (zombie.getAttribute(attribute) != null) {
zombie.getAttribute(attribute).setBaseValue(value); zombie.getAttribute(attribute).setBaseValue(value);
@@ -23,9 +23,45 @@ final class RoleArenaBarrierTest {
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F "world", 0.0, 64.0, 0.0, 0.0F, 0.0F
); );
@Test
void missingPerimeterChunkDoesNotLoadOrPartiallyBuild() {
World world = mock(World.class);
Block solid = mock(Block.class);
when(solid.getType()).thenReturn(Material.STONE);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid);
RoleArenaBarrier barrier = new RoleArenaBarrier();
barrier.build(world, CENTER, 10.0, 12);
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
org.junit.jupiter.api.Assertions.assertFalse(barrier.active());
}
@Test
void restorationWaitsForLoadedChunksWithoutLoadingThem() {
World world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
Block air = mock(Block.class);
BlockData original = 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, 64, 0));
when(air.getType()).thenReturn(Material.AIR);
when(air.getBlockData()).thenReturn(original);
when(original.clone()).thenReturn(original);
RoleArenaBarrier barrier = new RoleArenaBarrier();
barrier.build(world, CENTER, 10, 12);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
barrier.clear();
verify(world, never()).getBlockAt(any(Location.class));
verify(air, never()).setBlockData(any(), org.mockito.ArgumentMatchers.eq(false));
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
barrier.clear();
verify(air).setBlockData(original, false);
}
@Test @Test
void doesNotReplaceExistingNonAirBlocks() { void doesNotReplaceExistingNonAirBlocks() {
World world = mock(World.class); World world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
Block solid = mock(Block.class); Block solid = mock(Block.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid); when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid);
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0)); when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
@@ -39,6 +75,7 @@ final class RoleArenaBarrierTest {
@Test @Test
void restoresAirReplacedByTemporaryBarrier() { void restoresAirReplacedByTemporaryBarrier() {
World world = mock(World.class); World world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
Block air = mock(Block.class); Block air = mock(Block.class);
BlockData original = mock(BlockData.class); BlockData original = mock(BlockData.class);
BlockData snapshot = mock(BlockData.class); BlockData snapshot = mock(BlockData.class);
@@ -60,6 +97,7 @@ final class RoleArenaBarrierTest {
@Test @Test
void wallExtendsOneBelowFloorForTwelveBlocks() { void wallExtendsOneBelowFloorForTwelveBlocks() {
World world = mock(World.class); World world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
Block solid = mock(Block.class); Block solid = mock(Block.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid); when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid);
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0)); when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
@@ -0,0 +1,476 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Zombie;
import org.junit.jupiter.api.Test;
final class RoleArenaLifecycleTest {
@Test
void vacantArenaWithoutNearbyPlayersDoesNotSpawnABoss() {
Fixture f = new Fixture();
f.controller.run();
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(ArenaRole.class)
void sameInclusiveThresholdSpawnsOnceAndDespawnsWithParticles(ArenaRole role) {
Fixture f = new Fixture();
if (role == ArenaRole.VIGILANTE) {
when(f.manager.game()).thenReturn(Fixture.vigilanteVacancy());
}
Player player = f.player(18.01, 64, 0);
f.controller.run();
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any());
when(player.getLocation()).thenReturn(new Location(f.world, 18, 64, 0));
f.controller.run();
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
verify(f.world).spawnParticle(eq(org.bukkit.Particle.PORTAL), any(Location.class),
eq(60), eq(0.6), eq(1.0), eq(0.6), eq(0.05));
when(player.getLocation()).thenReturn(new Location(f.world, 18.01, 64, 0));
f.controller.run();
f.controller.run();
verify(f.spawned.get(0)).remove();
verify(f.world, times(2)).spawnParticle(eq(org.bukkit.Particle.PORTAL), any(Location.class),
eq(60), eq(0.6), eq(1.0), eq(0.6), eq(0.05));
}
@Test
void waitsForChunkEntitiesWithoutLoadingAnAbsentChunk() {
Fixture f = new Fixture();
f.player(15, 64, 0);
when(f.world.isChunkLoaded(0, 0)).thenReturn(false);
f.controller.run();
verify(f.world, never()).getChunkAt(0, 0);
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any());
when(f.world.isChunkLoaded(0, 0)).thenReturn(true);
when(f.chunk.isEntitiesLoaded()).thenReturn(false);
f.controller.run();
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any());
when(f.chunk.isEntitiesLoaded()).thenReturn(true);
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
}
@Test
void transientServerLookupMissDoesNotDuplicateOrOrphanOwnedBoss() {
Fixture f = new Fixture();
f.player(15, 64, 0);
f.controller.run();
when(f.server.getEntity(any(UUID.class))).thenReturn(null);
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
f.players.clear();
f.controller.run();
verify(f.spawned.get(0)).remove();
}
@Test
void startupAndLateEntityLoadsRemoveOnlyLegacyTaggedBosses() {
Fixture f = new Fixture();
Zombie legacy = f.taggedBoss();
Zombie ordinary = mock(Zombie.class);
when(ordinary.getUniqueId()).thenReturn(UUID.randomUUID());
f.entities.put(ordinary.getUniqueId(), ordinary);
f.controller.run();
verify(legacy).remove();
verify(ordinary, never()).remove();
f.player(15, 64, 0);
f.controller.run();
Zombie current = f.spawned.get(0);
Zombie lateLegacy = f.taggedBoss();
f.fire(new org.bukkit.event.world.EntitiesLoadEvent(f.chunk, List.of(lateLegacy, current, ordinary)));
verify(lateLegacy).remove();
verify(current, never()).remove();
verify(ordinary, never()).remove();
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
}
@Test
void entityUnloadRetiresOwnedBossAndLateOldEventsDoNotRetireReplacement() {
Fixture f = new Fixture();
f.player(15, 64, 0);
f.controller.run();
Zombie old = f.spawned.get(0);
f.fire(new org.bukkit.event.world.EntitiesUnloadEvent(f.chunk, List.of(old)));
verify(old).remove();
when(f.chunk.isEntitiesLoaded()).thenReturn(false);
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
when(f.chunk.isEntitiesLoaded()).thenReturn(true);
f.controller.run();
Zombie replacement = f.spawned.get(1);
f.fire(new org.bukkit.event.world.EntitiesUnloadEvent(f.chunk, List.of(old)));
f.controller.run();
verify(replacement, never()).remove();
org.junit.jupiter.api.Assertions.assertEquals(2, f.spawned.size());
}
@Test
void successfulBossIsNonPersistentAndCancelledSpawnHasNoArrivalBurst() {
Fixture f = new Fixture();
f.player(15, 64, 0);
Zombie rejected = f.taggedBoss();
f.entities.remove(rejected.getUniqueId());
when(f.world.spawn(any(Location.class), eq(Zombie.class), any())).thenReturn(rejected);
f.controller.run();
verify(rejected).remove();
verify(f.world, never()).spawnParticle(eq(org.bukkit.Particle.PORTAL), any(Location.class),
eq(60), anyDouble(), anyDouble(), anyDouble(), anyDouble());
f.controller.close();
verify(rejected).remove();
Fixture good = new Fixture();
good.player(15, 64, 0);
good.controller.run();
verify(good.spawned.get(0), times(2)).setPersistent(false);
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"walk", "fly", "teleport", "world", "logout", "death"})
void lastPlayerDepartureByAnyMechanismDespawnsOnce(String mechanism) {
Fixture f = new Fixture();
Player player = f.player(15, 64, 0);
f.controller.run();
f.depart(player, mechanism);
f.controller.run();
f.controller.run();
verify(f.spawned.get(0)).remove();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"walk", "fly", "teleport", "world", "logout", "death"})
void departingChallengerResetsFightButAnotherNearbyPlayerKeepsBoss(String mechanism) {
Fixture f = new Fixture();
Player challenger = f.player(0, 64, 0);
f.player(15, 64, 0);
f.controller.run();
Zombie boss = f.spawned.get(0);
verify(boss).setTarget(challenger);
clearInvocations(boss);
f.depart(challenger, mechanism);
f.controller.run();
verify(boss, never()).remove();
verify(boss).setAI(false);
verify(boss).setTarget(null);
verify(boss, never()).setTarget(challenger);
org.bukkit.event.block.BlockBreakEvent blockBreak = mock(org.bukkit.event.block.BlockBreakEvent.class);
org.bukkit.block.Block block = mock(org.bukkit.block.Block.class);
when(block.getLocation()).thenReturn(new Location(f.world, 0, 64, 0));
when(blockBreak.getBlock()).thenReturn(block);
f.controller.onBlockBreak(blockBreak);
verify(blockBreak, never()).setCancelled(true);
}
@Test
void missingBarrierChunkCannotStartFightAndUnloadEndsActiveFight() {
Fixture f = new Fixture();
Player player = f.player(0, 64, 0);
when(f.world.isChunkLoaded(-1, -1)).thenReturn(false);
f.controller.run();
Zombie boss = f.spawned.get(0);
verify(boss, never()).setTarget(player);
when(f.world.isChunkLoaded(-1, -1)).thenReturn(true);
f.controller.run();
verify(boss).setTarget(player);
f.fire(new org.bukkit.event.world.ChunkUnloadEvent(f.chunk));
verify(boss).remove();
}
@Test
void roleChangesPauseAndRestartNeverLeaveTwoBosses() {
Fixture f = new Fixture();
f.player(15, 64, 0);
f.controller.run();
Zombie tyrant = f.spawned.get(0);
when(f.manager.game()).thenReturn(Fixture.vigilanteVacancy());
f.controller.run();
f.controller.run();
verify(tyrant).remove();
org.junit.jupiter.api.Assertions.assertEquals(1, f.entities.size());
GameState paused = new GameState(GameLifecycle.PAUSED, Optional.of(UUID.randomUUID()),
Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(java.time.Instant.EPOCH),
Duration.ZERO, 0, 0, Set.of());
when(f.manager.game()).thenReturn(paused);
f.controller.run();
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(0, f.entities.size());
when(f.manager.game()).thenReturn(Fixture.vacancy());
f.controller.run();
Zombie beforeRestart = f.spawned.get(2);
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(f.center));
RoleArenaController restarted = new RoleArenaController(f.manager, f.server, locations,
PluginSettings.from(Map.of()), (boss, settings) -> { });
f.eventController = restarted;
restarted.run();
restarted.run();
verify(beforeRestart).remove();
org.junit.jupiter.api.Assertions.assertEquals(1, f.entities.size());
restarted.close();
restarted.close();
org.junit.jupiter.api.Assertions.assertEquals(0, f.entities.size());
}
@Test
void failedSpawnLeavesNoOwnershipAndBacksOffBeforeRetry() {
Fixture f = new Fixture();
f.player(15, 64, 0);
when(f.world.spawn(any(Location.class), eq(Zombie.class), any()))
.thenThrow(new IllegalStateException("spawn rejected"));
org.junit.jupiter.api.Assertions.assertDoesNotThrow(f.controller::run);
for (int i = 0; i < 9; i++) {
f.controller.run();
}
verify(f.world).spawn(any(Location.class), eq(Zombie.class), any());
org.junit.jupiter.api.Assertions.assertEquals(0, f.entities.size());
f.controller.close();
}
@Test
void activeFightStillExcludesNonChallengersAboveTheRing() {
Fixture f = new Fixture();
f.player(0, 64, 0);
f.controller.run();
Player intruder = f.player(0, 83, 0);
f.controller.run();
verify(intruder).teleport(any(Location.class),
eq(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN));
}
@Test
void initializerTagsBeforeAdmissionAndExemptsOnlyItsOwnCandidate() {
Fixture f = new Fixture();
f.player(15, 64, 0);
f.duringSpawn = boss -> {
verify(boss).setPersistent(false);
org.junit.jupiter.api.Assertions.assertTrue(boss.getScoreboardTags()
.contains("spigottyrant-role-arena-boss"));
Zombie unrelated = f.newZombie();
var nested = new org.bukkit.event.entity.CreatureSpawnEvent(unrelated,
org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason.CUSTOM);
f.fire(nested);
org.junit.jupiter.api.Assertions.assertTrue(nested.isCancelled());
};
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.entities.size());
verify(f.spawned.get(0), never()).remove();
}
@Test
void exceptionAfterCandidateInitializationRemovesThatCandidate() {
Fixture f = new Fixture();
f.player(15, 64, 0);
f.duringSpawn = boss -> { throw new IllegalStateException("spawn listener failed"); };
org.junit.jupiter.api.Assertions.assertDoesNotThrow(f.controller::run);
verify(f.spawned.get(0)).remove();
org.junit.jupiter.api.Assertions.assertEquals(0, f.entities.size());
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
}
@Test
void unloadingAWallOnlyChunkRestoresWallsAndEndsFight() {
Fixture f = new Fixture();
org.bukkit.block.Block left = mock(org.bukkit.block.Block.class);
org.bukkit.block.Block right = mock(org.bukkit.block.Block.class);
org.bukkit.block.data.BlockData original = mock(org.bukkit.block.data.BlockData.class);
when(original.clone()).thenReturn(original);
for (org.bukkit.block.Block block : List.of(left, right)) {
when(block.getType()).thenReturn(org.bukkit.Material.AIR);
when(block.getBlockData()).thenReturn(original);
}
when(left.getLocation()).thenReturn(new Location(f.world, -1, 64, 0));
when(right.getLocation()).thenReturn(new Location(f.world, 1, 64, 0));
when(f.world.getBlockAt(anyInt(), anyInt(), anyInt()))
.thenAnswer(call -> (int) call.getArgument(0) < 0 ? left : right);
when(f.world.getBlockAt(any(Location.class)))
.thenAnswer(call -> ((Location) call.getArgument(0)).getX() < 0 ? left : right);
f.player(0, 64, 0);
f.controller.run();
verify(left).setType(org.bukkit.Material.BARRIER, false);
verify(right).setType(org.bukkit.Material.BARRIER, false);
Chunk wallChunk = mock(Chunk.class);
when(wallChunk.getWorld()).thenReturn(f.world);
when(wallChunk.getX()).thenReturn(-1);
when(wallChunk.getZ()).thenReturn(0);
f.fire(new org.bukkit.event.world.ChunkUnloadEvent(wallChunk));
verify(left).setBlockData(original, false);
verify(right).setBlockData(original, false);
verify(f.spawned.get(0)).remove();
}
@Test
void eligibleTeleportArrivalWaitsForCommittedPositionBeforeSummoning() {
Fixture f = new Fixture();
Player player = f.player(100, 64, 0);
f.controller.run();
var arrival = new org.bukkit.event.player.PlayerTeleportEvent(player,
player.getLocation(), new Location(f.world, 0, 64, 0));
f.controller.onTeleport(arrival);
org.junit.jupiter.api.Assertions.assertFalse(arrival.isCancelled());
// Another plugin may still cancel: no boss until the player's location actually changes.
arrival.setCancelled(true);
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(0, f.spawned.size());
when(player.getLocation()).thenReturn(new Location(f.world, 0, 64, 0));
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
verify(f.spawned.get(0)).setTarget(player);
}
static final class Fixture {
final World world = mock(World.class);
final World otherWorld = mock(World.class);
final Server server = mock(Server.class);
final Chunk chunk = mock(Chunk.class);
final TyrantStateManager manager = mock(TyrantStateManager.class);
final ArenaLocation center = new ArenaLocation("world", 0, 64, 0, 0, 0);
final List<Player> players = new ArrayList<>();
final List<Zombie> spawned = new ArrayList<>();
final Map<UUID, Entity> entities = new HashMap<>();
final RoleArenaController controller;
RoleArenaController eventController;
java.util.function.Consumer<Zombie> duringSpawn = boss -> { };
Fixture() {
when(world.getName()).thenReturn("world");
when(otherWorld.getName()).thenReturn("other");
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
org.bukkit.block.Block ground = mock(org.bukkit.block.Block.class);
when(ground.getType()).thenReturn(org.bukkit.Material.STONE);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(ground);
when(world.getPlayers()).thenAnswer(call -> List.copyOf(players));
when(world.getEntities()).thenAnswer(call -> List.copyOf(entities.values()));
when(world.getNearbyEntities(any(Location.class), anyDouble(), anyDouble(), anyDouble()))
.thenReturn(List.of());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(world.getChunkAt(0, 0)).thenReturn(chunk);
when(chunk.isEntitiesLoaded()).thenReturn(true);
when(chunk.getWorld()).thenReturn(world);
when(chunk.getEntities()).thenAnswer(call -> entities.values().toArray(Entity[]::new));
when(server.getWorld("world")).thenReturn(world);
when(server.getLogger()).thenReturn(mock(java.util.logging.Logger.class));
when(server.getWorlds()).thenReturn(List.of(world));
when(server.getEntity(any(UUID.class))).thenAnswer(call -> entities.get(call.getArgument(0)));
when(server.getOnlinePlayers()).thenAnswer(call -> List.copyOf(players));
when(server.getPlayer(any(UUID.class))).thenAnswer(call -> players.stream()
.filter(player -> player.getUniqueId().equals(call.getArgument(0)))
.findFirst().orElse(null));
when(manager.game()).thenReturn(vacancy());
when(manager.players()).thenAnswer(call -> {
Map<UUID, PlayerState> states = new HashMap<>();
players.forEach(player -> states.put(player.getUniqueId(),
PlayerState.newPlayer(player.getUniqueId(), "Visitor")));
return states;
});
when(world.spawn(any(Location.class), eq(Zombie.class), any())).thenAnswer(call -> {
Zombie boss = newZombie();
spawned.add(boss);
java.util.function.Consumer<Zombie> initializer = call.getArgument(2);
initializer.accept(boss);
duringSpawn.accept(boss);
var event = new org.bukkit.event.entity.CreatureSpawnEvent(boss,
org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason.CUSTOM);
fire(event);
if (!event.isCancelled()) {
entities.put(boss.getUniqueId(), boss);
}
return boss;
});
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(center));
controller = new RoleArenaController(manager, server, locations, PluginSettings.from(Map.of()),
(boss, settings) -> { });
eventController = controller;
}
void depart(Player player, String mechanism) {
switch (mechanism) {
case "walk" -> when(player.getLocation()).thenReturn(new Location(world, 18.01, 64, 0));
case "fly" -> when(player.getLocation()).thenReturn(new Location(world, 0, 82.01, 0));
case "teleport" -> when(player.getLocation()).thenReturn(new Location(world, 1000, 64, 0));
case "world" -> when(player.getLocation()).thenReturn(new Location(otherWorld, 0, 64, 0));
case "logout" -> when(player.isOnline()).thenReturn(false);
case "death" -> when(player.isDead()).thenReturn(true);
default -> throw new IllegalArgumentException(mechanism);
}
}
void fire(org.bukkit.event.Event event) {
for (var method : RoleArenaController.class.getMethods()) {
if (method.isAnnotationPresent(org.bukkit.event.EventHandler.class)
&& method.getParameterCount() == 1
&& method.getParameterTypes()[0].isInstance(event)) {
try {
method.invoke(eventController, event);
} catch (ReflectiveOperationException exception) {
throw new AssertionError(exception);
}
}
}
}
Player player(double x, double y, double z) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getName()).thenReturn("Visitor");
when(player.isOnline()).thenReturn(true);
when(player.getLocation()).thenReturn(new Location(world, x, y, z));
players.add(player);
return player;
}
Zombie taggedBoss() {
Zombie boss = newZombie();
boss.addScoreboardTag("spigottyrant-role-arena-boss");
entities.put(boss.getUniqueId(), boss);
return boss;
}
Zombie newZombie() {
Zombie boss = mock(Zombie.class);
UUID id = UUID.randomUUID();
when(boss.getUniqueId()).thenReturn(id);
when(boss.getWorld()).thenReturn(world);
when(boss.getLocation()).thenReturn(new Location(world, 0, 64, 0));
Set<String> tags = new java.util.HashSet<>();
when(boss.getScoreboardTags()).thenReturn(tags);
when(boss.addScoreboardTag(anyString())).thenAnswer(call -> tags.add(call.getArgument(0)));
when(boss.isValid()).thenAnswer(call -> entities.containsKey(id));
doAnswer(call -> { entities.remove(id); return null; }).when(boss).remove();
return boss;
}
static GameState vigilanteVacancy() {
return new GameState(GameLifecycle.RUNNING, Optional.of(UUID.randomUUID()), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of());
}
static GameState vacancy() {
return new GameState(GameLifecycle.RUNNING, Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of());
}
}
}