diff --git a/src/main/java/games/dmg/spigottyrant/RoleArenaBarrier.java b/src/main/java/games/dmg/spigottyrant/RoleArenaBarrier.java index 4da1c5d..64cbbf6 100644 --- a/src/main/java/games/dmg/spigottyrant/RoleArenaBarrier.java +++ b/src/main/java/games/dmg/spigottyrant/RoleArenaBarrier.java @@ -12,14 +12,29 @@ final class RoleArenaBarrier { private final Map replaced = new LinkedHashMap<>(); private boolean built; - void build( + boolean build( World world, ArenaLocation center, double radius, int wallHeight ) { 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; 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); } } + return true; } void clear() { - for (Map.Entry entry : replaced.entrySet()) { + built = false; + restorePending(); + } + + void restorePending() { + if (built) { + return; + } + var iterator = replaced.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); Location location = entry.getKey(); 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); + 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() { diff --git a/src/main/java/games/dmg/spigottyrant/RoleArenaController.java b/src/main/java/games/dmg/spigottyrant/RoleArenaController.java index 7184cd7..a6b58a9 100644 --- a/src/main/java/games/dmg/spigottyrant/RoleArenaController.java +++ b/src/main/java/games/dmg/spigottyrant/RoleArenaController.java @@ -33,6 +33,9 @@ import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerRespawnEvent; 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 { 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 ArenaLocationStore locations; private final PluginSettings settings; + private final java.util.function.BiConsumer configureBossAttributes; private final VigilanteArenaSuccessionService vigilanteSuccession = new VigilanteArenaSuccessionService(); private final TyrantArenaSuccessionService tyrantSuccession = @@ -47,27 +51,49 @@ public final class RoleArenaController implements Listener, Runnable { private final RoleArenaBarrier barrier = new RoleArenaBarrier(); private final Set relocating = new HashSet<>(); private UUID bossId; + private Zombie bossEntity; private UUID challengerId; - private boolean spawningBoss; + private Zombie spawningBoss; + private int spawnRetrySweeps; + private boolean spawnWarning; private ArenaRole previousOpenRole; private ArenaRole bossRole; private ArenaRole warnedMissingRole; private ArenaLocation observedLocation; + private boolean reconciledLoadedBosses; public RoleArenaController( TyrantStateManager stateManager, Server server, ArenaLocationStore locations, 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 configureBossAttributes ) { this.stateManager = stateManager; this.server = server; this.locations = locations; this.settings = settings; + this.configureBossAttributes = configureBossAttributes; } @Override public void run() { + if (spawnRetrySweeps > 0) { + spawnRetrySweeps--; + } + barrier.restorePending(); + if (!reconciledLoadedBosses) { + server.getWorlds().forEach(world -> removeStaleBosses(world.getEntities())); + reconciledLoadedBosses = true; + } Optional openRole = RoleArenaPolicy.openRole(stateManager.game()); Optional configured = locations.location(); if (configured.isEmpty()) { @@ -100,8 +126,15 @@ public final class RoleArenaController implements Listener, Runnable { } previousOpenRole = 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); - if (role == null) { + if (role == null || !hasNearbyPlayer(world, arena)) { closeFight(); return; } @@ -109,14 +142,21 @@ public final class RoleArenaController implements Listener, Runnable { Zombie boss = boss(world); if (boss == null || bossRole != role) { closeFight(); + if (spawnRetrySweeps > 0) { + return; + } boss = spawnBoss(world, arena, role); } + if (boss == null) { + return; + } 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() + if (challenger == null || !challenger.isOnline() || challenger.isDead() + || !withinBossRange(arena, challenger.getLocation()) || !contains(arena, challenger.getLocation()) || !RoleArenaPolicy.isEligible( 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 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) public void onCreatureSpawn(CreatureSpawnEvent event) { Optional configured = locations.location(); - if (!spawningBoss && configured.isPresent() + if (event.getEntity() != spawningBoss && configured.isPresent() && contains(configured.orElseThrow(), event.getLocation())) { event.setCancelled(true); } @@ -253,8 +328,10 @@ public final class RoleArenaController implements Listener, Runnable { return; } UUID defeatedBoss = bossId; + Zombie defeatedEntity = bossEntity; ArenaRole defeatedRole = bossRole; bossId = null; + bossEntity = null; bossRole = null; Player killer = event.getEntity().getKiller(); if (killer != null && challengerId != null && defeatedRole != null @@ -266,6 +343,7 @@ public final class RoleArenaController implements Listener, Runnable { return; } bossId = defeatedBoss; + bossEntity = defeatedEntity; bossRole = defeatedRole; resetFight(); } @@ -276,13 +354,14 @@ public final class RoleArenaController implements Listener, Runnable { private boolean enforcePosition(Player player, Location from, Location to) { Optional configured = locations.location(); - if (configured.isEmpty() || relocating.contains(player.getUniqueId())) { + if (configured.isEmpty() || !player.isOnline() || player.isDead() + || relocating.contains(player.getUniqueId())) { return false; } ArenaLocation arena = configured.orElseThrow(); boolean inside = contains(arena, to); if (player.getUniqueId().equals(challengerId)) { - if (!inside) { + if (!inside || !withinBossRange(arena, to)) { resetFight(); } return false; @@ -291,23 +370,29 @@ public final class RoleArenaController implements Listener, Runnable { return false; } PlayerState state = playerState(player); - ArenaRole role = bossRole; - if (challengerId == null && bossId != null && role != null - && RoleArenaPolicy.isEligible(stateManager.game(), role, state)) { - challengerId = player.getUniqueId(); - World world = server.getWorld(arena.worldName()); - if (world != null) { - barrier.build( - 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); - } + if (bossId == null && withinBossRange(arena, to)) { + Optional openRole = RoleArenaPolicy.openRole(stateManager.game()); + if (openRole.isPresent() && RoleArenaPolicy.isEligible( + stateManager.game(), openRole.orElseThrow(), state)) { + // Allow an eligible arrival to commit; the sweep summons/admits, not a + // proposed teleport which another listener could still cancel. + return false; } + } + 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 " + readable(role) + "!"); return false; @@ -350,25 +435,22 @@ public final class RoleArenaController implements Listener, Runnable { private void closeFight() { challengerId = null; barrier.clear(); - if (bossId != null) { - Entity entity = server.getEntity(bossId); - if (entity != null) { - entity.remove(); - } - bossId = null; - } + Zombie retiring = bossEntity; + bossEntity = null; + bossId = null; bossRole = null; + if (retiring != null) { + bossParticles(retiring.getLocation()); + retiring.remove(); + } } 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 { Zombie boss = world.spawn(toBukkit(world, arena), Zombie.class, zombie -> { + spawningBoss = zombie; + zombie.setPersistent(false); + zombie.addScoreboardTag(BOSS_TAG); zombie.setAdult(); zombie.setCustomName(role == ArenaRole.TYRANT ? ChatColor.RED + "Tyrant's Trial" @@ -377,27 +459,48 @@ public final class RoleArenaController implements Listener, Runnable { zombie.setRemoveWhenFarAway(false); zombie.setCanPickupItems(false); zombie.setAI(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()); + configureBossAttributes.accept(zombie, settings); 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(); bossRole = role; + bossParticles(boss.getLocation()); return boss; + } catch (RuntimeException exception) { + bossEntity = null; + bossId = null; + bossRole = null; + if (spawningBoss != null) { + spawningBoss.remove(); + } + spawnFailed(); + return null; } 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) { - if (bossId == null) { - return null; - } - Entity entity = server.getEntity(bossId); - return entity instanceof Zombie zombie && entity.getWorld().equals(world) - && !zombie.isDead() ? zombie : null; + return bossEntity != null && bossEntity.getWorld().equals(world) + && bossEntity.isValid() && !bossEntity.isDead() ? bossEntity : null; } 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) { Optional configured = locations.location(); World world = location.getWorld(); @@ -603,6 +726,12 @@ public final class RoleArenaController implements Listener, Runnable { 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) { if (zombie.getAttribute(attribute) != null) { zombie.getAttribute(attribute).setBaseValue(value); diff --git a/src/test/java/games/dmg/spigottyrant/RoleArenaBarrierTest.java b/src/test/java/games/dmg/spigottyrant/RoleArenaBarrierTest.java index c0c811a..0dbbc02 100644 --- a/src/test/java/games/dmg/spigottyrant/RoleArenaBarrierTest.java +++ b/src/test/java/games/dmg/spigottyrant/RoleArenaBarrierTest.java @@ -23,9 +23,45 @@ final class RoleArenaBarrierTest { "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 void doesNotReplaceExistingNonAirBlocks() { World world = mock(World.class); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); 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)); @@ -39,6 +75,7 @@ final class RoleArenaBarrierTest { @Test void restoresAirReplacedByTemporaryBarrier() { World world = mock(World.class); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); Block air = mock(Block.class); BlockData original = mock(BlockData.class); BlockData snapshot = mock(BlockData.class); @@ -60,6 +97,7 @@ final class RoleArenaBarrierTest { @Test void wallExtendsOneBelowFloorForTwelveBlocks() { World world = mock(World.class); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); 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)); diff --git a/src/test/java/games/dmg/spigottyrant/RoleArenaLifecycleTest.java b/src/test/java/games/dmg/spigottyrant/RoleArenaLifecycleTest.java new file mode 100644 index 0000000..b43ef91 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/RoleArenaLifecycleTest.java @@ -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 players = new ArrayList<>(); + final List spawned = new ArrayList<>(); + final Map entities = new HashMap<>(); + final RoleArenaController controller; + RoleArenaController eventController; + java.util.function.Consumer 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 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 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 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()); + } + } +}