diff --git a/src/main/java/net/tfminecraft/infestations/cache/Cache.java b/src/main/java/net/tfminecraft/infestations/cache/Cache.java index 542f994..b760e56 100644 --- a/src/main/java/net/tfminecraft/infestations/cache/Cache.java +++ b/src/main/java/net/tfminecraft/infestations/cache/Cache.java @@ -23,6 +23,7 @@ public final class Cache { public static String lureItem = "ia.tfmc:lure"; public static int joinSeconds = 20; public static int lureSpawnRadius = 48; + public static int minPlayerDistance = 16; public static int logoutGraceSeconds = 300; public static double deserterDamage = 2.0; public static double hologramViewRange = 96; diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java index d040ad9..b178448 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java @@ -21,6 +21,7 @@ public class InfestationData { public int enemiesAlive; public int ambientAlive; public long lureActivatedAt; + public int lureReleased; public List committed = new ArrayList<>(); public Map logoutGraceUntil = new HashMap<>(); public List deathOnLogin = new ArrayList<>(); diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java index a72cbed..bc24e55 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java @@ -95,10 +95,12 @@ private static Infestation fromData(InfestationData row) { infestation.setLureZ(row.lureZ); infestation.setJoinEndsAt(row.joinEndsAt); infestation.setLureRemaining(row.lureRemaining); - infestation.setPendingSpawns(row.pendingSpawns); + // Scheduled spawns do not survive a restart or reload, so pendingSpawns starts at zero. infestation.setEnemiesAlive(row.enemiesAlive); infestation.setAmbientAlive(row.ambientAlive); infestation.setLureActivatedAt(row.lureActivatedAt); + // Pending spawns were counted as released when scheduled but never ran. + infestation.setLureReleased(row.lureReleased - row.pendingSpawns); if (row.committed != null) { for (String id : row.committed) { try { @@ -142,6 +144,7 @@ private static InfestationData toData(Infestation infestation) { row.enemiesAlive = infestation.getEnemiesAlive(); row.ambientAlive = infestation.getAmbientAlive(); row.lureActivatedAt = infestation.getLureActivatedAt(); + row.lureReleased = infestation.getLureReleased(); for (UUID id : infestation.getCommitted()) { row.committed.add(id.toString()); } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java index c570b82..2749449 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java @@ -28,6 +28,8 @@ public final class Infestation { private int ambientAlive; private int waveRetryAtTick; private long lureActivatedAt; + private int lureReleased; + private int victoryAtTick; private final Set committed = new HashSet<>(); private final Map logoutGraceUntil = new HashMap<>(); private final Set deathOnLogin = new HashSet<>(); @@ -117,6 +119,8 @@ public void placeLure(Block block, long joinEndsAt, int budget) { this.deathOnLogin.clear(); this.waveRetryAtTick = 0; this.lureActivatedAt = 0; + this.lureReleased = 0; + this.victoryAtTick = 0; } public void clearLure() { @@ -134,6 +138,8 @@ public void clearLure() { deathOnLogin.clear(); waveRetryAtTick = 0; lureActivatedAt = 0; + lureReleased = 0; + victoryAtTick = 0; } public String getWorldName() { @@ -236,6 +242,26 @@ public void setLureActivatedAt(long lureActivatedAt) { this.lureActivatedAt = Math.max(0, lureActivatedAt); } + /** + * Mobs the lure has put in the field against lure-count: its own spawns plus ambient mobs it took over. + * Summoned mobs are not counted, so they never delay the configured spawns. + */ + public int getLureReleased() { + return lureReleased; + } + + public void setLureReleased(int lureReleased) { + this.lureReleased = Math.max(0, lureReleased); + } + + public int getVictoryAtTick() { + return victoryAtTick; + } + + public void setVictoryAtTick(int victoryAtTick) { + this.victoryAtTick = Math.max(0, victoryAtTick); + } + public int ambientCap() { return net.tfminecraft.infestations.loader.GroupLoader.tune(groupId, severity).ambientCap(); } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index d69a36d..878f3ba 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -18,6 +18,7 @@ import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.World; +import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -36,12 +37,16 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.event.world.ChunkUnloadEvent; +import org.bukkit.event.world.EntitiesLoadEvent; +import org.bukkit.event.world.EntitiesUnloadEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; import org.bukkit.scheduler.BukkitTask; +import io.lumine.mythic.bukkit.events.MythicMobSpawnEvent; +import io.lumine.mythic.core.mobs.ActiveMob; import net.md_5.bungee.api.ChatMessageType; import net.md_5.bungee.api.chat.TextComponent; @@ -70,8 +75,16 @@ public final class InfestationManager implements Listener, AmbientSpawnService.CollectionInfestations { + /** Lure mobs are recounted this often, which also takes over ambient mobs loaded mid-lure. */ + private static final int LURE_RECOUNT_TICKS = 100; + /** Wait after the last kill so death summons (parasitic worms after 20 ticks) can join the count. */ + private static final int VICTORY_DELAY_TICKS = 40; + /** How long a dead tagged mob can still pass its tags to mobs it summons. */ + private static final int DEATH_SUMMON_TICKS = 100; + private final Infestations plugin; private final Map byProvince = new HashMap<>(); + private final Map recentDeaths = new HashMap<>(); private final AmbientSpawnService ambient = new AmbientSpawnService(); private boolean pluginFurnitureRemove; private BukkitTask task; @@ -187,6 +200,12 @@ private void tick() { } } if (infestation.getPhase() == LurePhase.ACTIVE) { + if (tickVictory(infestation)) { + continue; + } + if (tick % LURE_RECOUNT_TICKS == 0) { + recountLure(infestation); + } warnNonJoiners(infestation); spawnLureWave(infestation); checkWipe(infestation); @@ -199,6 +218,10 @@ private void tick() { tickSpread(); + if (tick % 20 == 0 && !recentDeaths.isEmpty()) { + recentDeaths.values().removeIf(death -> tick - death.tick() > DEATH_SUMMON_TICKS); + } + if (tick % 100 == 0) { save(); } @@ -348,10 +371,69 @@ private void sweepLoadedChunks() { private void activate(Infestation infestation) { infestation.setPhase(LurePhase.ACTIVE); infestation.setLureActivatedAt(System.currentTimeMillis()); + recountLure(infestation); + infestation.setLureReleased(infestation.getEnemiesAlive()); spawnLureWave(infestation); save(); } + /** + * Count the province's loaded lure mobs and take over its ambient mobs, so mobs already roaming + * count toward the lure. The remaining count never drops below the mobs still in the field. + */ + private void recountLure(Infestation infestation) { + Location origin = infestation.lureLocation(); + if (origin == null || origin.getWorld() == null) { + return; + } + int provinceId = infestation.getProvinceId(); + int alive = 0; + int adopted = 0; + for (LivingEntity entity : origin.getWorld().getLivingEntities()) { + if (entity instanceof Player || entity.isDead()) { + continue; + } + Integer tagged = Keys.infestationId(entity.getPersistentDataContainer()); + if (tagged == null || tagged != provinceId) { + continue; + } + String kind = Keys.kind(entity.getPersistentDataContainer()); + if (Keys.KIND_AMBIENT.equals(kind)) { + Keys.tagMob(entity.getPersistentDataContainer(), provinceId, Keys.KIND_LURE); + adopted++; + } else if (!Keys.KIND_LURE.equals(kind)) { + continue; + } + alive++; + } + infestation.setEnemiesAlive(alive); + infestation.setLureReleased(infestation.getLureReleased() + adopted); + int inField = alive + infestation.getPendingSpawns(); + if (infestation.getLureRemaining() < inField) { + infestation.setLureRemaining(inField); + } + if (adopted > 0) { + SpawnLog.line(infestation, "-", "adopted " + adopted + " alive=" + alive + + " remaining=" + infestation.getLureRemaining()); + } + } + + private boolean tickVictory(Infestation infestation) { + if (infestation.getLureRemaining() > 0) { + infestation.setVictoryAtTick(0); + return false; + } + if (infestation.getVictoryAtTick() == 0) { + infestation.setVictoryAtTick(tick + VICTORY_DELAY_TICKS); + return false; + } + if (tick < infestation.getVictoryAtTick()) { + return false; + } + victory(infestation); + return true; + } + private void spawnLureWave(Infestation infestation) { if (tick < infestation.getWaveRetryAtTick()) { return; @@ -375,21 +457,24 @@ private void spawnLureWave(Infestation infestation) { long elapsed = Math.max(0, System.currentTimeMillis() - started); long durationMs = tune.lureDurationSeconds() * 1000L; int lureCount = tune.lureCount(); - int allowed = durationMs <= 0 - ? lureCount - : (int) Math.min(lureCount, elapsed * lureCount / durationMs); - int killsDone = Math.max(0, lureCount - infestation.getLureRemaining()); - int need = allowed - infestation.getPendingSpawns() - infestation.getEnemiesAlive() - killsDone; + // Everything left to kill should be in the field; lost mobs are replaced. + int need = infestation.getLureRemaining() - infestation.getEnemiesAlive() - infestation.getPendingSpawns(); + if (durationMs > 0 && elapsed < durationMs) { + int allowed = (int) (elapsed * lureCount / durationMs); + need = Math.min(need, allowed - infestation.getLureReleased()); + } if (need <= 0) { return; } + int ringMin = Math.max(4, Cache.minPlayerDistance); List spots = SpawnPlanner.find( origin, - 4, - Cache.lureSpawnRadius, + ringMin, + Math.max(Cache.lureSpawnRadius, ringMin + 8), need, - loc -> Provinces.at(loc) == infestation.getProvinceId() - && GroupLoader.allowsY(infestation.getGroupId(), loc.getBlockY())); + loc -> GroupLoader.allowsY(infestation.getGroupId(), loc.getBlockY()) + && SpawnPlanner.clearOfPlayers(loc, Cache.minPlayerDistance) + && Provinces.at(loc) == infestation.getProvinceId()); if (spots.isEmpty()) { infestation.setWaveRetryAtTick(tick + 40); SpawnLog.line(infestation, "-", "no-spot"); @@ -398,6 +483,7 @@ private void spawnLureWave(Infestation infestation) { infestation.setWaveRetryAtTick(0); for (Location spot : spots) { infestation.setPendingSpawns(infestation.getPendingSpawns() + 1); + infestation.setLureReleased(infestation.getLureReleased() + 1); int delay = ThreadLocalRandom.current().nextInt(20, 61); Bukkit.getScheduler().runTaskLater(plugin, () -> { if (infestation.getPhase() != LurePhase.ACTIVE) { @@ -406,9 +492,16 @@ private void spawnLureWave(Infestation infestation) { } if (GroupLoader.blockedByNight(infestation.getGroupId(), spot.getWorld())) { infestation.setPendingSpawns(Math.max(0, infestation.getPendingSpawns() - 1)); + infestation.setLureReleased(infestation.getLureReleased() - 1); SpawnLog.line(infestation, "-", "night"); return; } + if (!SpawnPlanner.clearOfPlayers(spot, Cache.minPlayerDistance)) { + infestation.setPendingSpawns(Math.max(0, infestation.getPendingSpawns() - 1)); + infestation.setLureReleased(infestation.getLureReleased() - 1); + SpawnLog.line(infestation, "-", "too-close"); + return; + } LivingEntity spawned = MythicSpawner.spawn( spot, infestation.getGroupId(), infestation.getProvinceId(), Keys.KIND_LURE); infestation.setPendingSpawns(Math.max(0, infestation.getPendingSpawns() - 1)); @@ -421,6 +514,7 @@ private void spawnLureWave(Infestation infestation) { infestation.getEnemiesAlive(), lureCount); } else { + infestation.setLureReleased(infestation.getLureReleased() - 1); SpawnLog.line(infestation, "-", "unknown-mythic"); } }, delay); @@ -840,6 +934,7 @@ public void onDeath(EntityDeathEvent event) { if (infestation == null) { return; } + recentDeaths.put(entity.getUniqueId(), new RecentDeath(provinceId, kind, tick)); if (Keys.KIND_AMBIENT.equals(kind)) { if (infestation.getAmbientAlive() > 0) { infestation.setAmbientAlive(infestation.getAmbientAlive() - 1); @@ -851,12 +946,49 @@ public void onDeath(EntityDeathEvent event) { infestation.setEnemiesAlive(infestation.getEnemiesAlive() - 1); } infestation.setLureRemaining(infestation.getLureRemaining() - 1); - if (infestation.getLureRemaining() <= 0) { - victory(infestation); - } } } + /** + * Mobs summoned by a dying infestation mob (parasitic worms) join its infestation. During a lure they + * add to the remaining count. + */ + @EventHandler(ignoreCancelled = true) + public void onMythicSpawn(MythicMobSpawnEvent event) { + if (recentDeaths.isEmpty()) { + return; + } + ActiveMob mob = event.getMob(); + // MythicMobs sets the summoner as parent after the spawn event. + Bukkit.getScheduler().runTask(plugin, () -> adoptSummon(mob)); + } + + private void adoptSummon(ActiveMob mob) { + UUID parentId = mob.getParentUUID().orElse(null); + RecentDeath death = parentId != null ? recentDeaths.get(parentId) : null; + if (death == null || mob.getEntity() == null) { + return; + } + if (!(mob.getEntity().getBukkitEntity() instanceof LivingEntity living) || living.isDead()) { + return; + } + Infestation infestation = get(death.provinceId()); + if (infestation == null || Keys.infestationId(living.getPersistentDataContainer()) != null) { + return; + } + if (infestation.getPhase() == LurePhase.ACTIVE && Keys.KIND_LURE.equals(death.kind())) { + Keys.tagMob(living.getPersistentDataContainer(), death.provinceId(), Keys.KIND_LURE); + infestation.setEnemiesAlive(infestation.getEnemiesAlive() + 1); + infestation.setLureRemaining(infestation.getLureRemaining() + 1); + SpawnLog.line(infestation, "-", "summoned remaining=" + infestation.getLureRemaining()); + } else if (infestation.getPhase() == LurePhase.NONE && Keys.KIND_AMBIENT.equals(death.kind())) { + Keys.tagMob(living.getPersistentDataContainer(), death.provinceId(), Keys.KIND_AMBIENT); + infestation.setAmbientAlive(infestation.getAmbientAlive() + 1); + } + } + + private record RecentDeath(int provinceId, String kind, int tick) {} + private void onPlayerDeath(Player player) { for (Infestation infestation : List.copyOf(byProvince.values())) { if (!infestation.isCommitted(player.getUniqueId())) { @@ -945,7 +1077,6 @@ public void onChunkLoad(ChunkLoadEvent event) { pluginFurnitureRemove = false; } LureHologram.removeOrphans(chunk); - ambient.onChunkLoaded(chunk, this); for (Infestation infestation : byProvince.values()) { if (!infestation.hasLure()) { continue; @@ -958,6 +1089,30 @@ public void onChunkLoad(ChunkLoadEvent event) { }); } + @EventHandler + public void onEntitiesLoad(EntitiesLoadEvent event) { + if (hasTagged(event.getEntities())) { + ambient.markDirty(); + } + } + + @EventHandler + public void onEntitiesUnload(EntitiesUnloadEvent event) { + if (hasTagged(event.getEntities())) { + ambient.markDirty(); + } + } + + private static boolean hasTagged(List entities) { + for (Entity entity : entities) { + if (entity instanceof LivingEntity && !(entity instanceof Player) + && Keys.infestationId(entity.getPersistentDataContainer()) != null) { + return true; + } + } + return false; + } + private static void actionBar(Player player, String message) { player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(message)); } diff --git a/src/main/java/net/tfminecraft/infestations/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/infestations/loader/ConfigLoader.java index c810ccd..fcb20be 100644 --- a/src/main/java/net/tfminecraft/infestations/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/infestations/loader/ConfigLoader.java @@ -43,6 +43,7 @@ public boolean loadSafe(File configFile) { Cache.lureItem = config.getString("lure-item", "ia.tfmc:lure"); Cache.joinSeconds = Math.max(1, config.getInt("join-seconds", 20)); Cache.lureSpawnRadius = Math.max(4, config.getInt("lure-spawn-radius", 48)); + Cache.minPlayerDistance = Math.max(0, config.getInt("min-player-distance", 16)); Cache.logoutGraceSeconds = Math.max(1, config.getInt("logout-grace-seconds", 300)); Cache.deserterDamage = Math.max(0.5, config.getDouble("deserter-damage", 2.0)); Cache.hologramViewRange = Math.max(16, config.getDouble("hologram-view-range", 96)); diff --git a/src/main/java/net/tfminecraft/infestations/spawn/AmbientSpawnService.java b/src/main/java/net/tfminecraft/infestations/spawn/AmbientSpawnService.java index e482e5c..7cc85f6 100644 --- a/src/main/java/net/tfminecraft/infestations/spawn/AmbientSpawnService.java +++ b/src/main/java/net/tfminecraft/infestations/spawn/AmbientSpawnService.java @@ -7,15 +7,14 @@ import java.util.concurrent.ThreadLocalRandom; import org.bukkit.Bukkit; -import org.bukkit.Chunk; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.World; -import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import net.tfminecraft.infestations.Infestations; +import net.tfminecraft.infestations.cache.Cache; import net.tfminecraft.infestations.infestation.Infestation; import net.tfminecraft.infestations.infestation.LurePhase; import net.tfminecraft.infestations.loader.GroupLoader; @@ -24,9 +23,18 @@ public final class AmbientSpawnService { + /** Minimum ticks between recounts triggered by tagged mobs loading or unloading. */ + private static final int RECOUNT_INTERVAL_TICKS = 100; + private final Map lastSpawnTick = new HashMap<>(); + private boolean recountDirty; + private int lastRecountTick; public void tick(CollectionInfestations infestations, int tick) { + if (recountDirty && tick - lastRecountTick >= RECOUNT_INTERVAL_TICKS) { + recountAmbient(infestations); + lastRecountTick = tick; + } for (Player player : Bukkit.getOnlinePlayers()) { if (player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR) { continue; @@ -65,13 +73,16 @@ public void spawnAround(Player player, Infestation infestation, int amount) { UUID playerId = player.getUniqueId(); String playerName = player.getName(); int provinceId = infestation.getProvinceId(); + int ringMin = Math.max(tune.ambientRingMin(), Cache.minPlayerDistance); + int ringMax = Math.max(tune.ambientRingMax(), ringMin + 8); List spots = SpawnPlanner.find( player.getLocation(), - tune.ambientRingMin(), - tune.ambientRingMax(), + ringMin, + ringMax, want, - loc -> Provinces.at(loc) == provinceId - && GroupLoader.allowsY(infestation.getGroupId(), loc.getBlockY())); + loc -> GroupLoader.allowsY(infestation.getGroupId(), loc.getBlockY()) + && SpawnPlanner.clearOfPlayers(loc, Cache.minPlayerDistance) + && Provinces.at(loc) == provinceId); if (spots.isEmpty()) { SpawnLog.line(infestation, playerName, "no-spot"); return; @@ -104,6 +115,10 @@ public void spawnAround(Player player, Infestation infestation, int amount) { SpawnLog.line(infestation, playerName, "no-spot"); return; } + if (!SpawnPlanner.clearOfPlayers(spot, Cache.minPlayerDistance)) { + SpawnLog.line(infestation, playerName, "too-close"); + return; + } LivingEntity spawned = MythicSpawner.spawn( spot, infestation.getGroupId(), infestation.getProvinceId(), Keys.KIND_AMBIENT); if (spawned != null) { @@ -117,49 +132,36 @@ public void spawnAround(Player player, Infestation infestation, int amount) { } /** - * Set ambientAlive from loaded chunks. Does not despawn anything. + * Set ambientAlive from loaded entities. Does not despawn anything. */ public void reconcileLoaded(CollectionInfestations infestations) { recountAmbient(infestations); } - public void onChunkLoaded(Chunk chunk, CollectionInfestations infestations) { - if (hasTagged(chunk)) { - recountAmbient(infestations); - } - } - - private boolean hasTagged(Chunk chunk) { - for (Entity entity : chunk.getEntities()) { - if (!(entity instanceof LivingEntity living) || living instanceof Player) { - continue; - } - if (Keys.infestationId(living.getPersistentDataContainer()) != null) { - return true; - } - } - return false; + /** + * Tagged mobs loaded or unloaded with their chunk; recount on a later tick, at most every few seconds. + */ + public void markDirty() { + recountDirty = true; } private void recountAmbient(CollectionInfestations infestations) { + recountDirty = false; Map counts = new HashMap<>(); for (World world : Bukkit.getWorlds()) { - for (Chunk chunk : world.getLoadedChunks()) { - for (Entity entity : chunk.getEntities()) { - if (!(entity instanceof LivingEntity living) || living instanceof Player) { - continue; - } - Integer id = Keys.infestationId(living.getPersistentDataContainer()); - String kind = Keys.kind(living.getPersistentDataContainer()); - if (id == null || !Keys.KIND_AMBIENT.equals(kind)) { - continue; - } - Infestation infestation = infestations.get(id); - if (infestation == null || infestation.getPhase() != LurePhase.NONE) { - continue; - } - counts.merge(id, 1, Integer::sum); + for (LivingEntity living : world.getLivingEntities()) { + if (living instanceof Player || living.isDead()) { + continue; + } + Integer id = Keys.infestationId(living.getPersistentDataContainer()); + if (id == null || !Keys.KIND_AMBIENT.equals(Keys.kind(living.getPersistentDataContainer()))) { + continue; + } + Infestation infestation = infestations.get(id); + if (infestation == null || infestation.getPhase() != LurePhase.NONE) { + continue; } + counts.merge(id, 1, Integer::sum); } } for (Infestation infestation : infestations.all()) { diff --git a/src/main/java/net/tfminecraft/infestations/spawn/SpawnPlanner.java b/src/main/java/net/tfminecraft/infestations/spawn/SpawnPlanner.java index 4e9218f..5a5e54e 100644 --- a/src/main/java/net/tfminecraft/infestations/spawn/SpawnPlanner.java +++ b/src/main/java/net/tfminecraft/infestations/spawn/SpawnPlanner.java @@ -1,26 +1,32 @@ package net.tfminecraft.infestations.spawn; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Predicate; +import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.entity.Player; /** - * TrialRooms SpawnPlanner: shuffled disk, 3x3x3 air, 3x3 solid floor. + * Random spots in a ring: 3x3x3 air over a 3x3 solid floor. Sampling is capped + * per spot and never touches unloaded chunks, so a failed search stays cheap. */ public final class SpawnPlanner { + private static final int ATTEMPTS_PER_SPOT = 24; + private static final int MIN_ATTEMPTS = 48; + private static final double MIN_SPOT_GAP_SQ = 4.0; + private SpawnPlanner() {} public static List find(Location origin, int ringMin, int ringMax, int max, Predicate extra) { - List results = new ArrayList<>(max); - if (origin == null) { + List results = new ArrayList<>(Math.max(0, max)); + if (origin == null || max <= 0) { return results; } World world = origin.getWorld(); @@ -30,42 +36,72 @@ public static List find(Location origin, int ringMin, int ringMax, int int min = Math.max(0, Math.min(ringMin, ringMax)); int maxR = Math.max(min, Math.max(ringMin, ringMax)); - int minSq = min * min; - int maxSq = maxR * maxR; + double minSq = (double) min * min; + double maxSq = (double) maxR * maxR; + int baseX = origin.getBlockX(); int baseY = origin.getBlockY(); + int baseZ = origin.getBlockZ(); + ThreadLocalRandom random = ThreadLocalRandom.current(); - List candidates = new ArrayList<>(); - for (int dx = -maxR; dx <= maxR; dx++) { - for (int dz = -maxR; dz <= maxR; dz++) { - int d2 = dx * dx + dz * dz; - if (d2 >= minSq && d2 <= maxSq) { - candidates.add(new int[] { dx, dz }); - } + int attempts = Math.max(MIN_ATTEMPTS, max * ATTEMPTS_PER_SPOT); + for (int i = 0; i < attempts && results.size() < max; i++) { + double angle = random.nextDouble() * Math.PI * 2; + double radius = Math.sqrt(minSq + random.nextDouble() * (maxSq - minSq)); + int x = baseX + (int) Math.round(Math.cos(angle) * radius); + int z = baseZ + (int) Math.round(Math.sin(angle) * radius); + if (!areaLoaded(world, x, z)) { + continue; } - } - Collections.shuffle(candidates, ThreadLocalRandom.current()); - - for (int[] off : candidates) { - int x = origin.getBlockX() + off[0]; - int z = origin.getBlockZ() + off[1]; for (int y = baseY - 2; y <= baseY + 2; y++) { if (!is3x3x3ClearAir(world, x, y, z) || !is3x3FloorSolid(world, x, y - 1, z)) { continue; } Location loc = new Location(world, x + 0.5, y, z + 0.5); - if (extra != null && !extra.test(loc)) { + if (tooCloseToChosen(results, loc) || (extra != null && !extra.test(loc))) { continue; } results.add(loc); break; } - if (results.size() >= max) { - break; - } } return results; } + /** + * True when no player who can see the world (anything but spectator) is within {@code minDistance}. + */ + public static boolean clearOfPlayers(Location loc, double minDistance) { + if (minDistance <= 0 || loc == null || loc.getWorld() == null) { + return true; + } + double minSq = minDistance * minDistance; + for (Player player : loc.getWorld().getPlayers()) { + if (player.getGameMode() == GameMode.SPECTATOR) { + continue; + } + if (player.getLocation().distanceSquared(loc) < minSq) { + return false; + } + } + return true; + } + + private static boolean areaLoaded(World world, int x, int z) { + return world.isChunkLoaded((x - 1) >> 4, (z - 1) >> 4) + && world.isChunkLoaded((x + 1) >> 4, (z - 1) >> 4) + && world.isChunkLoaded((x - 1) >> 4, (z + 1) >> 4) + && world.isChunkLoaded((x + 1) >> 4, (z + 1) >> 4); + } + + private static boolean tooCloseToChosen(List chosen, Location loc) { + for (Location other : chosen) { + if (other.distanceSquared(loc) < MIN_SPOT_GAP_SQ) { + return true; + } + } + return false; + } + private static boolean is3x3x3ClearAir(World world, int x, int y, int z) { for (int oy = 0; oy < 3; oy++) { for (int ox = -1; ox <= 1; ox++) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 392ea9e..994075c 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -26,6 +26,9 @@ spread-chance-sea: lure-item: ia.tfmc:lure join-seconds: 20 lure-spawn-radius: 48 +# No ambient or lure mob spawns closer than this to any player. Raises the +# ambient ring minimum and the lure ring minimum when they are smaller. +min-player-distance: 16 logout-grace-seconds: 300 deserter-damage: 2 hologram-view-range: 96