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)); + } +}