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