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 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<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();
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() {
@@ -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<Zombie, PluginSettings> 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<UUID> 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<Zombie, PluginSettings> 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<ArenaRole> openRole = RoleArenaPolicy.openRole(stateManager.game());
Optional<ArenaLocation> 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<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)
public void onCreatureSpawn(CreatureSpawnEvent event) {
Optional<ArenaLocation> 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<ArenaLocation> 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<ArenaRole> 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<ArenaLocation> 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);