Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>

<repositories>
Expand Down Expand Up @@ -101,6 +107,11 @@
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Location> 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<LureSpot> 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);
Expand All @@ -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);
Expand All @@ -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<LureSpot> 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<LureSpot> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package net.tfminecraft.infestations.spawn;

/**
* How many lure mobs to put in the field on this attempt.
*
* <p>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);
}
}
97 changes: 93 additions & 4 deletions src/main/java/net/tfminecraft/infestations/spawn/SpawnPlanner.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Location> find(Location origin, int ringMin, int ringMax, int max,
Predicate<Location> 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<Location> findLoose(Location origin, int ringMin, int ringMax, int max,
Predicate<Location> 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<Location> sample(Location origin, int ringMin, int ringMax, int max,
Predicate<Location> extra, int vertical, boolean roomy) {
List<Location> results = new ArrayList<>(Math.max(0, max));
if (origin == null || max <= 0) {
return results;
Expand Down Expand Up @@ -52,8 +101,8 @@ public static List<Location> 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);
Expand All @@ -67,6 +116,36 @@ public static List<Location> 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}.
*/
Expand Down Expand Up @@ -102,6 +181,16 @@ private static boolean tooCloseToChosen(List<Location> 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++) {
Expand Down
Loading