From 70562ccd6d2fa0547c5cc8c54453a1ad9c5e398b Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:42:55 +0000 Subject: [PATCH 1/2] fix: keep spawns away from players and count roaming mobs in lures Ambient and lure mobs no longer spawn within min-player-distance (default 16) of any non-spectator player, checked both when a spot is picked and when the delayed spawn fires. Spot search samples a capped number of random points and skips unloaded chunks, so it never loads chunks or scans the whole ring. When a lure activates, the province's loaded ambient mobs become lure mobs and count toward lure-count. Mobs summoned by a dying infestation mob (parasitic worms) inherit its tags; during a lure each one adds to the remaining count. Victory waits two seconds after the last kill so those summons can land. The remaining count is mobs left to kill, and lost lure mobs are replaced once pacing allows. Ambient recounts now run at most every five seconds after tagged mobs load or unload, instead of scanning every loaded chunk on each chunk load. Pending spawns reset on load because scheduled spawns do not survive a restart. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tfminecraft/infestations/cache/Cache.java | 1 + .../database/InfestationData.java | 1 + .../database/InfestationDatabase.java | 4 +- .../infestations/infestation/Infestation.java | 26 +++ .../infestation/InfestationManager.java | 181 ++++++++++++++++-- .../infestations/loader/ConfigLoader.java | 1 + .../spawn/AmbientSpawnService.java | 78 ++++---- .../infestations/spawn/SpawnPlanner.java | 82 +++++--- src/main/resources/config.yml | 3 + 9 files changed, 302 insertions(+), 75 deletions(-) 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..0cc5c43 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java @@ -95,10 +95,11 @@ 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); + infestation.setLureReleased(row.lureReleased); if (row.committed != null) { for (String id : row.committed) { try { @@ -142,6 +143,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 From 2dcaae44166210e40c760aa4c17ae26e0bda881b Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:54:10 +0000 Subject: [PATCH 2/2] fix: drop lost pending spawns from restored lure release count Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tfminecraft/infestations/database/InfestationDatabase.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java index 0cc5c43..bc24e55 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java @@ -99,7 +99,8 @@ private static Infestation fromData(InfestationData row) { infestation.setEnemiesAlive(row.enemiesAlive); infestation.setAmbientAlive(row.ambientAlive); infestation.setLureActivatedAt(row.lureActivatedAt); - infestation.setLureReleased(row.lureReleased); + // 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 {