From ce0fd739a48ec2bb097a6d46f1e68e33d7594e85 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:26:37 +0000 Subject: [PATCH] fix: spawn lure mobs whenever enemies remain A release tally ahead of the live field, or a ring with no clear spot, could leave the hologram counting enemies that never appeared. Pace off the clock and the mobs actually out, and fall back to the lure when the ring cannot take them. Co-authored-by: Cursor --- pom.xml | 11 ++ .../infestations/infestation/Infestation.java | 6 +- .../infestation/InfestationManager.java | 103 ++++++++++++++---- .../infestations/spawn/LureSpawnQuota.java | 47 ++++++++ .../infestations/spawn/SpawnPlanner.java | 97 ++++++++++++++++- .../spawn/LureSpawnQuotaTest.java | 57 ++++++++++ 6 files changed, 294 insertions(+), 27 deletions(-) create mode 100644 src/main/java/net/tfminecraft/infestations/spawn/LureSpawnQuota.java create mode 100644 src/test/java/net/tfminecraft/infestations/spawn/LureSpawnQuotaTest.java diff --git a/pom.xml b/pom.xml index c2a6bf7..93550c4 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,12 @@ + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + @@ -101,6 +107,11 @@ maven-compiler-plugin 3.14.1 + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + diff --git a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java index 2749449..d46e814 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java @@ -243,8 +243,10 @@ public void setLureActivatedAt(long 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. + * Mobs the lure has put in the field: its own spawns plus ambient mobs it took over. + * Summoned mobs are not counted, so they never delay the configured spawns. The pace waits on + * this tally before introducing more, but a tally ahead of the mobs actually alive or pending + * cannot leave the lure empty while enemies remain. */ public int getLureReleased() { return lureReleased; diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index c5fdf8b..36aa71a 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -1,5 +1,6 @@ package net.tfminecraft.infestations.infestation; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; @@ -68,6 +69,7 @@ import net.tfminecraft.infestations.map.InfestationMapExport; import net.tfminecraft.infestations.spawn.AmbientSpawnService; import net.tfminecraft.infestations.spawn.Keys; +import net.tfminecraft.infestations.spawn.LureSpawnQuota; import net.tfminecraft.infestations.spawn.MythicSpawner; import net.tfminecraft.infestations.spawn.SpawnLog; import net.tfminecraft.infestations.spawn.SpawnPlanner; @@ -457,31 +459,40 @@ private void spawnLureWave(Infestation infestation) { long elapsed = Math.max(0, System.currentTimeMillis() - started); long durationMs = tune.lureDurationSeconds() * 1000L; int lureCount = tune.lureCount(); - // 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()); - } + // Headcount follows the clock. Earlier release attempts cannot freeze a lure that still owes mobs. + int need = LureSpawnQuota.toSpawn( + infestation.getLureRemaining(), + infestation.getEnemiesAlive(), + infestation.getPendingSpawns(), + infestation.getLureReleased(), + elapsed, + durationMs, + lureCount); if (need <= 0) { return; } - int ringMin = Math.max(4, Cache.minPlayerDistance); - List spots = SpawnPlanner.find( - origin, - ringMin, - Math.max(Cache.lureSpawnRadius, ringMin + 8), - need, - loc -> GroupLoader.allowsY(infestation.getGroupId(), loc.getBlockY()) - && SpawnPlanner.clearOfPlayers(loc, Cache.minPlayerDistance) - && Provinces.at(loc) == infestation.getProvinceId()); + List spots = planLureSpots(infestation, origin, need); if (spots.isEmpty()) { infestation.setWaveRetryAtTick(tick + 40); SpawnLog.line(infestation, "-", "no-spot"); return; } infestation.setWaveRetryAtTick(0); - for (Location spot : spots) { + int loose = 0; + int anchored = 0; + for (LureSpot spot : spots) { + if (spot.anchor()) { + anchored++; + } else if (spot.forced()) { + loose++; + } + } + if (anchored > 0) { + SpawnLog.line(infestation, "-", "anchor " + anchored); + } else if (loose > 0) { + SpawnLog.line(infestation, "-", "loose " + loose); + } + for (LureSpot planned : spots) { infestation.setPendingSpawns(infestation.getPendingSpawns() + 1); infestation.setLureReleased(infestation.getLureReleased() + 1); int delay = ThreadLocalRandom.current().nextInt(20, 61); @@ -490,17 +501,23 @@ private void spawnLureWave(Infestation infestation) { infestation.setPendingSpawns(Math.max(0, infestation.getPendingSpawns() - 1)); return; } + Location spot = planned.location(); 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; + if (!planned.forced() && !SpawnPlanner.clearOfPlayers(spot, Cache.minPlayerDistance)) { + Location anchor = SpawnPlanner.anchor(origin); + if (anchor == null) { + infestation.setPendingSpawns(Math.max(0, infestation.getPendingSpawns() - 1)); + infestation.setLureReleased(infestation.getLureReleased() - 1); + SpawnLog.line(infestation, "-", "too-close"); + return; + } + spot = anchor; + SpawnLog.line(infestation, "-", "too-close-anchor"); } LivingEntity spawned = MythicSpawner.spawn( spot, infestation.getGroupId(), infestation.getProvinceId(), Keys.KIND_LURE); @@ -521,6 +538,50 @@ private void spawnLureWave(Infestation infestation) { } } + /** + * Prefer a clear ring away from players. When that cannot hold every owed mob, loosen the space, + * then stand the rest on the lure so a failed search cannot leave the remaining count unpaid. + */ + private List planLureSpots(Infestation infestation, Location origin, int need) { + int ringMin = Math.max(4, Cache.minPlayerDistance); + int ringMax = Math.max(Cache.lureSpawnRadius, ringMin + 8); + int provinceId = infestation.getProvinceId(); + List planned = new ArrayList<>(); + for (Location spot : SpawnPlanner.find( + origin, + ringMin, + ringMax, + need, + loc -> GroupLoader.allowsY(infestation.getGroupId(), loc.getBlockY()) + && SpawnPlanner.clearOfPlayers(loc, Cache.minPlayerDistance) + && Provinces.at(loc) == provinceId)) { + planned.add(new LureSpot(spot, false, false)); + } + if (planned.size() < need) { + int missing = need - planned.size(); + for (Location spot : SpawnPlanner.findLoose( + origin, + 4, + ringMax, + missing, + loc -> GroupLoader.allowsY(infestation.getGroupId(), loc.getBlockY()) + && Provinces.at(loc) == provinceId)) { + planned.add(new LureSpot(spot, true, false)); + } + } + if (planned.size() < need) { + Location anchor = SpawnPlanner.anchor(origin); + if (anchor != null) { + while (planned.size() < need) { + planned.add(new LureSpot(anchor.clone(), true, true)); + } + } + } + return planned; + } + + private record LureSpot(Location location, boolean forced, boolean anchor) {} + private void warnJoining(Infestation infestation) { if (tick % 20 != 0) { return; diff --git a/src/main/java/net/tfminecraft/infestations/spawn/LureSpawnQuota.java b/src/main/java/net/tfminecraft/infestations/spawn/LureSpawnQuota.java new file mode 100644 index 0000000..ae5d55a --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/spawn/LureSpawnQuota.java @@ -0,0 +1,47 @@ +package net.tfminecraft.infestations.spawn; + +/** + * How many lure mobs to put in the field on this attempt. + * + *

The duration spreads {@code lureCount} as introductions: mobs already sent out wait for the clock + * before the next ones. A tally that has run ahead of both the clock and the mobs actually alive or + * pending cannot freeze the lure while enemies remain. + */ +public final class LureSpawnQuota { + + private LureSpawnQuota() {} + + public static int toSpawn( + int remaining, + int alive, + int pending, + int released, + long elapsedMs, + long durationMs, + int lureCount) { + int inField = Math.max(0, alive) + Math.max(0, pending); + int shortfall = Math.max(0, remaining) - inField; + if (shortfall <= 0) { + return 0; + } + if (lureCount <= 0 || durationMs <= 0 || elapsedMs >= durationMs) { + return shortfall; + } + long allowed = Math.max(0, elapsedMs) * (long) lureCount / durationMs; + if (allowed < 1) { + allowed = 1; + } + if (allowed > Integer.MAX_VALUE) { + allowed = Integer.MAX_VALUE; + } + int paceRoom = (int) allowed - Math.max(0, released); + int clockTarget = (int) Math.min(Math.max(0, remaining), allowed); + if (paceRoom < 0 && inField < clockTarget) { + paceRoom = clockTarget - inField; + } + if (paceRoom <= 0) { + return 0; + } + return Math.min(shortfall, paceRoom); + } +} diff --git a/src/main/java/net/tfminecraft/infestations/spawn/SpawnPlanner.java b/src/main/java/net/tfminecraft/infestations/spawn/SpawnPlanner.java index 5a5e54e..7c76772 100644 --- a/src/main/java/net/tfminecraft/infestations/spawn/SpawnPlanner.java +++ b/src/main/java/net/tfminecraft/infestations/spawn/SpawnPlanner.java @@ -12,19 +12,68 @@ import org.bukkit.entity.Player; /** - * 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. + * Random spots in a ring. The strict search wants 3x3x3 air over a 3x3 solid floor; the loose search + * wants two air blocks over one solid block and a taller column. Sampling is capped per spot and never + * touches unloaded chunks, so a failed search stays cheap. {@link #anchor} always returns a spot at the + * origin when a lure still owes mobs and the ring has nowhere to put them. */ 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 static final int STRICT_VERTICAL = 2; + private static final int LOOSE_VERTICAL = 12; private SpawnPlanner() {} public static List find(Location origin, int ringMin, int ringMax, int max, Predicate extra) { + return sample(origin, ringMin, ringMax, max, extra, STRICT_VERTICAL, true); + } + + /** + * Same ring, but a single column of air over a solid block and a wider height band. + */ + public static List findLoose(Location origin, int ringMin, int ringMax, int max, + Predicate extra) { + return sample(origin, ringMin, ringMax, max, extra, LOOSE_VERTICAL, false); + } + + /** + * A place to stand at the origin. Used when the ring cannot take the mobs a lure still owes. + * Prefers two air blocks over a solid floor near the origin, and still returns the block above + * the origin when nothing around it is clear. + */ + public static Location anchor(Location origin) { + if (origin == null || origin.getWorld() == null) { + return null; + } + World world = origin.getWorld(); + int x = origin.getBlockX(); + int y = origin.getBlockY(); + int z = origin.getBlockZ(); + if (world.isChunkLoaded(x >> 4, z >> 4)) { + for (int radius = 0; radius <= 3; radius++) { + for (int dy = 1; dy <= 6; dy++) { + Location found = standInSquare(world, x, y + dy, z, radius); + if (found != null) { + return found; + } + } + for (int dy = 0; dy >= -4; dy--) { + Location found = standInSquare(world, x, y + dy, z, radius); + if (found != null) { + return found; + } + } + } + } + return origin.clone().add(0, 1, 0); + } + + private static List sample(Location origin, int ringMin, int ringMax, int max, + Predicate extra, int vertical, boolean roomy) { List results = new ArrayList<>(Math.max(0, max)); if (origin == null || max <= 0) { return results; @@ -52,8 +101,8 @@ public static List find(Location origin, int ringMin, int ringMax, int if (!areaLoaded(world, x, z)) { continue; } - for (int y = baseY - 2; y <= baseY + 2; y++) { - if (!is3x3x3ClearAir(world, x, y, z) || !is3x3FloorSolid(world, x, y - 1, z)) { + for (int y = baseY - vertical; y <= baseY + vertical; y++) { + if (!clear(world, x, y, z, roomy)) { continue; } Location loc = new Location(world, x + 0.5, y, z + 0.5); @@ -67,6 +116,36 @@ public static List find(Location origin, int ringMin, int ringMax, int return results; } + private static Location standInSquare(World world, int x, int y, int z, int radius) { + for (int ox = -radius; ox <= radius; ox++) { + for (int oz = -radius; oz <= radius; oz++) { + if (Math.max(Math.abs(ox), Math.abs(oz)) != radius) { + continue; + } + if (!areaLoaded(world, x + ox, z + oz) || !isStandable(world, x + ox, y, z + oz)) { + continue; + } + return new Location(world, x + ox + 0.5, y, z + oz + 0.5); + } + } + return null; + } + + private static boolean clear(World world, int x, int y, int z, boolean roomy) { + if (!columnInWorld(world, y, roomy)) { + return false; + } + if (roomy) { + return is3x3x3ClearAir(world, x, y, z) && is3x3FloorSolid(world, x, y - 1, z); + } + return isStandable(world, x, y, z); + } + + private static boolean columnInWorld(World world, int y, boolean roomy) { + int top = y + (roomy ? 2 : 1); + return y - 1 >= world.getMinHeight() && top < world.getMaxHeight(); + } + /** * True when no player who can see the world (anything but spectator) is within {@code minDistance}. */ @@ -102,6 +181,16 @@ private static boolean tooCloseToChosen(List chosen, Location loc) { return false; } + private static boolean isStandable(World world, int x, int y, int z) { + if (!columnInWorld(world, y, false)) { + return false; + } + Block feet = world.getBlockAt(x, y, z); + Block head = world.getBlockAt(x, y + 1, z); + Block floor = world.getBlockAt(x, y - 1, z); + return feet.getType().isAir() && head.getType().isAir() && floor.getType().isSolid(); + } + 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/test/java/net/tfminecraft/infestations/spawn/LureSpawnQuotaTest.java b/src/test/java/net/tfminecraft/infestations/spawn/LureSpawnQuotaTest.java new file mode 100644 index 0000000..78d8142 --- /dev/null +++ b/src/test/java/net/tfminecraft/infestations/spawn/LureSpawnQuotaTest.java @@ -0,0 +1,57 @@ +package net.tfminecraft.infestations.spawn; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class LureSpawnQuotaTest { + + @Test + void emptyLureSpawnsImmediatelyWhileEnemiesRemain() { + assertEquals(1, LureSpawnQuota.toSpawn(20, 0, 0, 0, 0, 120_000, 20)); + } + + @Test + void clockReleasesOnlyWhatHasNotAlreadyBeenSent() { + // 60s of a 120s, 20-mob lure allows 10. Seven have not been sent yet. + assertEquals(7, LureSpawnQuota.toSpawn(20, 3, 0, 3, 60_000, 120_000, 20)); + assertEquals(0, LureSpawnQuota.toSpawn(20, 10, 0, 10, 60_000, 120_000, 20)); + assertEquals(0, LureSpawnQuota.toSpawn(20, 8, 2, 10, 60_000, 120_000, 20)); + } + + @Test + void killsWaitForTheNextPacedIntroduction() { + // Ten were sent and all killed. The clock has not allowed an eleventh yet. + assertEquals(0, LureSpawnQuota.toSpawn(10, 0, 0, 10, 60_000, 120_000, 20)); + } + + @Test + void aTallyAheadOfTheFieldCannotLeaveTheLureEmpty() { + // Twenty marked released, none alive, and the clock has only reached 10. + assertEquals(10, LureSpawnQuota.toSpawn(20, 0, 0, 20, 60_000, 120_000, 20)); + assertEquals(4, LureSpawnQuota.toSpawn(20, 4, 2, 20, 60_000, 120_000, 20)); + } + + @Test + void adoptedMobsAboveTheClockBlockExtraSpawns() { + assertEquals(0, LureSpawnQuota.toSpawn(20, 12, 0, 12, 30_000, 120_000, 20)); + } + + @Test + void afterTheDurationEveryMissingMobIsSpawned() { + assertEquals(15, LureSpawnQuota.toSpawn(15, 0, 0, 40, 120_000, 120_000, 20)); + assertEquals(4, LureSpawnQuota.toSpawn(6, 1, 1, 6, 180_000, 120_000, 20)); + } + + @Test + void noPaceLimitReleasesTheWholeShortfall() { + assertEquals(8, LureSpawnQuota.toSpawn(8, 0, 0, 0, 0, 0, 20)); + assertEquals(8, LureSpawnQuota.toSpawn(8, 0, 0, 8, 0, 120_000, 0)); + } + + @Test + void nothingOwedWhenTheFieldAlreadyCoversTheRemainder() { + assertEquals(0, LureSpawnQuota.toSpawn(4, 3, 1, 4, 0, 120_000, 20)); + assertEquals(0, LureSpawnQuota.toSpawn(0, 0, 0, 10, 10_000, 120_000, 20)); + } +}