feat(tyrant): summon arena bosses only near players
This commit is contained in:
@@ -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);
|
||||||
|
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;
|
ArenaRole role = bossRole;
|
||||||
if (challengerId == null && bossId != null && role != null
|
if (challengerId == null && bossId != null && role != null && withinBossRange(arena, to)
|
||||||
&& RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
|
&& RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
|
||||||
challengerId = player.getUniqueId();
|
|
||||||
World world = server.getWorld(arena.worldName());
|
World world = server.getWorld(arena.worldName());
|
||||||
if (world != null) {
|
Zombie boss = world == null ? null : boss(world);
|
||||||
barrier.build(
|
if (boss == null || !barrier.build(
|
||||||
world, arena, settings.vigilanteArenaRadiusBlocks(),
|
world, arena, settings.vigilanteArenaRadiusBlocks(),
|
||||||
settings.arenaBarrierHeightBlocks()
|
settings.arenaBarrierHeightBlocks())) {
|
||||||
);
|
return false;
|
||||||
Zombie boss = boss(world);
|
}
|
||||||
if (boss != null) {
|
challengerId = player.getUniqueId();
|
||||||
boss.setHealth(settings.vigilanteBossHealth());
|
boss.setHealth(settings.vigilanteBossHealth());
|
||||||
boss.teleport(toBukkit(world, arena));
|
boss.teleport(toBukkit(world, arena));
|
||||||
ArenaBossState.engage(boss, player);
|
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) {
|
|
||||||
entity.remove();
|
|
||||||
}
|
|
||||||
bossId = null;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user