Restore natural mob spawning

- Restore cave spawning and correct floor placement across platforms.
- Enforce category population limits and clamp ambient spawn batches.
- Honor mob-spawning rules and entity-ticking chunk admission.
- Separate chunk saving from configurable distance-despawn retention.
- Add spawning regressions and Folia gameplay acceptance coverage.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-02 16:55:52 -04:00
1 parent 4ef6fc7cc6
commit a765e76aa3
31 files changed
+1712 -87

No files matched your search

@@ -197,7 +197,7 @@ public class IrisSettings {
@Data
public static class IrisSettingsWorld {
public boolean postLoadBlockUpdates = true;
public boolean forcePersistEntities = true;
public boolean forcePersistEntities = false;
public boolean ambientEntitySpawningSystem = true;
public long asyncTickIntervalMS = 700;
public double targetSpawnEntitiesPerChunk = 0.95;
@@ -28,6 +28,8 @@ import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.platform.bukkit.BukkitEntityType;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.platform.bukkit.plugin.Chunks;
import art.arcane.iris.world.task.J;
@@ -37,13 +39,16 @@ import art.arcane.volmlib.util.format.Form;
import art.arcane.volmlib.util.math.PowerOfTwoCoordinates;
import art.arcane.volmlib.util.math.Position2;
import art.arcane.volmlib.util.math.RNG;
import lombok.Data;
import org.bukkit.Chunk;
import org.bukkit.GameRules;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import art.arcane.iris.generation.runtime.MarkerSpawnScanner.PreparedMarkerSpawn;
import java.util.concurrent.CompletableFuture;
@@ -381,6 +386,9 @@ final class WorldEntitySpawner {
}
private void spawnAmbient(Chunk c, boolean initial, BiomeEnvironment environment) {
if (!ambientAllowed(c, initial)) {
return;
}
//@builder
// Excluded spawners cannot spawn anything on this Minecraft version, so they never enter the selection pool.
Predicate<IrisSpawner> filter = i -> !i.isCompatExcluded() && i.canSpawn(manager.getEngine(), c.getX(), c.getZ());
@@ -404,20 +412,25 @@ final class WorldEntitySpawner {
.shuffleCopy(RNG.r)
.stream()
.filter(filter)))
.filter(counter)
.flatMap((i) -> stream(i, initial))
.filter(entry -> counter.remainingCapacity(entry, manager.getEngine()) > 0)
.collect(Collectors.toList()))
.getRandom();
//@done
if (v == null || v.getReferenceSpawner() == null)
return;
spawn(c, v);
spawn(c, v, counter.remainingCapacity(v, manager.getEngine()));
}
private void spawn(Chunk c, IrisEntitySpawn i) {
static boolean ambientAllowed(Chunk chunk, boolean initial) {
return Boolean.TRUE.equals(chunk.getWorld().getGameRuleValue(GameRules.SPAWN_MOBS))
&& (initial || chunk.getLoadLevel() == Chunk.LoadLevel.ENTITY_TICKING);
}
private void spawn(Chunk c, IrisEntitySpawn i, int remainingCapacity) {
IrisSpawner ref = i.getReferenceSpawner();
int s = i.spawn(manager.getEngine(), c, RNG.r);
int s = i.spawn(manager.getEngine(), c, RNG.r, remainingCapacity);
actuallySpawned.addAndGet(s);
if (s > 0) {
ref.spawn(manager.getEngine(), c.getX(), c.getZ());
@@ -469,26 +482,20 @@ final class WorldEntitySpawner {
return Rarity.expandWeighted(types);
}
@Data
private static class ChunkCounter implements Predicate<IrisSpawner> {
private final Entity[] entities;
private transient int index = 0;
private transient int count = 0;
static final class ChunkCounter {
private final Map<String, Integer> counts = new HashMap<>();
@Override
public boolean test(IrisSpawner spawner) {
int max = spawner.getMaxEntitiesPerChunk();
if (max <= count)
return false;
while (index < entities.length) {
if (entities[index++] instanceof LivingEntity) {
if (++count >= max)
return false;
ChunkCounter(Entity[] entities) {
for (Entity entity : entities) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
counts.merge(BukkitEntityType.of(entity.getType()).spawnCategory(), 1, Integer::sum);
}
}
}
return true;
int remainingCapacity(IrisEntitySpawn entry, Engine engine) {
IrisEntity entity = entry.getRealEntity(engine);
return entry.getReferenceSpawner().remainingCapacity(entity, counts);
}
}
}
@@ -19,6 +19,10 @@
package art.arcane.iris.world.entity;
import art.arcane.volmlib.nativelib.entity.NativeEntityOptions;
import art.arcane.volmlib.nativelib.entity.NativeEntityType;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.command.IrisCommand;
import art.arcane.iris.generation.decoration.IrisSurface;
@@ -130,8 +134,8 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
@Description("Should this entity be allowed to pickup items")
private boolean pickupItems = false;
@Description("Should this entity be removed when far away")
private boolean removable = false;
@Description("Allow normal distance despawning. keepEntity and world.forcePersistEntities override this setting.")
private boolean removable = true;
@Description("Entity helmet equipment")
private IrisLoot helmet = null;
@@ -197,6 +201,25 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
@Description("Run raw commands when this entity is spawned. Use {x}, {y}, and {z} for location. /summon pig {x} {y} {z}")
private KList<IrisCommand> rawCommands = new KList<>();
public String spawnCategory() {
IrisPlatform platform = IrisPlatforms.getOrNull();
if (platform == null || type == null || type.isBlank()) {
return "misc";
}
PlatformRegistries registries = platform.registries();
NativeEntityType resolved = registries == null ? null : registries.entity(type.trim().toLowerCase(Locale.ROOT));
String category = resolved == null ? null : resolved.spawnCategory();
return category == null || category.isBlank() ? "misc" : category;
}
void applyPersistence(Entity entity, boolean forcePersistence) {
boolean persistent = keepEntity || forcePersistence;
entity.setPersistent(true);
if (entity instanceof LivingEntity living) {
living.setRemoveWhenFarAway(removable && !persistent);
}
}
public EntityType getBukkitType() {
if (type == null || type.isBlank()) {
return EntityType.UNKNOWN;
@@ -292,7 +315,7 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
e.setGravity(isGravity());
e.setInvulnerable(isInvulnerable());
e.setSilent(isSilent());
e.setPersistent(isKeepEntity() || IrisSettings.get().getWorld().isForcePersistEntities());
applyPersistence(e, IrisSettings.get().getWorld().isForcePersistEntities());
int gg = 0;
for (IrisEntity i : passengers) {
@@ -333,8 +356,6 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
l.setLeashHolder(getLeashHolder().spawn(gen, at, rng.nextParallelRNG(234548)));
}
l.setRemoveWhenFarAway(isRemovable());
if (getHelmet() != null && LootResolver.oneIn(rng, getHelmet().getRarity())) {
l.getEquipment().setHelmet(getHelmet().get(gen.isStudio(), rng));
}
@@ -51,6 +51,7 @@ import org.bukkit.HeightMap;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Entity;
@Snippet("entity-spawn")
@@ -60,6 +61,8 @@ import org.bukkit.entity.Entity;
@Description("Represents an entity spawn during initial chunk generation")
@Data
public class IrisEntitySpawn implements Rarity {
private static final int CAVE_COLUMN_ATTEMPTS = 8;
private static final int CAVE_VERTICAL_ATTEMPTS = 128;
private final transient AtomicCache<RNG> rng = new AtomicCache<>();
private final transient AtomicCache<IrisEntity> ent = new AtomicCache<>();
@RegistryListResource(IrisEntity.class)
@@ -78,25 +81,32 @@ public class IrisEntitySpawn implements Rarity {
private transient IrisSpawner referenceSpawner;
private transient IrisMarker referenceMarker;
public int spawn(Engine gen, Chunk c, RNG rng) {
public int spawn(Engine gen, Chunk c, RNG rng, int remainingCapacity) {
if (remainingCapacity <= 0) {
return 0;
}
IrisEntity definition = getRealEntity(gen);
if (definition == null) {
return 0;
}
int spawns = LootResolver.inclusive(rng, minSpawns, maxSpawns);
int spawns = Math.min(remainingCapacity, LootResolver.inclusive(rng, minSpawns, maxSpawns));
int s = 0;
if (spawns > 0) {
for (int id = 0; id < spawns; id++) {
int x = (c.getX() * 16) + rng.i(16);
int z = (c.getZ() * 16) + rng.i(16);
World world = c.getWorld();
int h = world.getHighestBlockYAt(x, z, HeightMap.OCEAN_FLOOR);
int hf = world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE);
IrisSpawnGroup group = getReferenceSpawner().getGroup();
Integer y = selectSurfaceSpawnY(group, definition.getSurface(), h, hf, rng);
Location l = group == IrisSpawnGroup.CAVE ? findCaveSpawnLocation(gen, c, rng)
: y == null ? null : new Location(world, x, y, z);
Location l;
if (group == IrisSpawnGroup.CAVE) {
l = findCaveSpawnLocation(gen, c, rng, definition.getSurface());
} else {
int x = (c.getX() << 4) + rng.i(16);
int z = (c.getZ() << 4) + rng.i(16);
World world = c.getWorld();
int h = world.getHighestBlockYAt(x, z, HeightMap.OCEAN_FLOOR);
int hf = world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE);
Integer y = selectSurfaceSpawnY(group, definition.getSurface(), h, hf, rng);
l = y == null ? null : new Location(world, x, y, z);
}
if (l != null) {
if (referenceSpawner.getAllowedLightLevels().getMin() > 0 || referenceSpawner.getAllowedLightLevels().getMax() < 15) {
@@ -193,16 +203,50 @@ public class IrisEntitySpawn implements Rarity {
return null;
}
static Location findCaveSpawnLocation(Engine engine, Chunk chunk, RNG rng) {
static Location findCaveSpawnLocation(Engine engine, Chunk chunk, RNG rng, IrisSurface surface) {
if (J.isFolia()) {
return null;
if (!J.isOwnedByCurrentRegion(chunk.getWorld(), chunk.getX(), chunk.getZ())) {
return null;
}
return findLiveCaveSpawnLocation(chunk, rng, surface);
}
KList<IrisPosition> markers = engine.getMantle().findMarkers(chunk.getX(), chunk.getZ(), MarkerMatter.CAVE_FLOOR);
return selectCaveSpawnLocation(markers, chunk.getWorld(), rng);
}
static Location findLiveCaveSpawnLocation(Chunk chunk, RNG rng, IrisSurface surface) {
boolean fluid = surface.isFluid();
World world = chunk.getWorld();
int minimumY = world.getMinHeight() + 1;
for (int attempt = 0; attempt < CAVE_COLUMN_ATTEMPTS; attempt++) {
int localX = rng.i(1, 15);
int localZ = rng.i(1, 15);
int worldX = (chunk.getX() << 4) + localX;
int worldZ = (chunk.getZ() << 4) + localZ;
int maximumY = Math.min(world.getMaxHeight() - 3,
world.getHighestBlockYAt(worldX, worldZ, HeightMap.OCEAN_FLOOR) - 1);
if (maximumY < minimumY) {
continue;
}
int span = maximumY - minimumY + 1;
int startY = LootResolver.inclusive(rng, minimumY, maximumY);
int samples = Math.min(span, CAVE_VERTICAL_ATTEMPTS);
for (int sample = 0; sample < samples; sample++) {
int y = minimumY + Math.floorMod(startY - minimumY - sample, span);
Block block = chunk.getBlock(localX, y, localZ);
if (fluid ? surface.matches(block) : isAir(block.getType())
&& chunk.getBlock(localX, y - 1, localZ).isSolid()
&& isAir(chunk.getBlock(localX, y + 1, localZ).getType())) {
return new Location(world, worldX, y, worldZ);
}
}
}
return null;
}
static Location selectCaveSpawnLocation(KList<IrisPosition> markers, World world, RNG rng) {
return markers.convert((marker) -> BukkitPlatform.toLocation(marker, world).add(0, 1, 0)).getRandom(rng);
IrisPosition marker = markers.getRandom(rng);
return marker == null ? null : BukkitPlatform.toLocation(marker, world);
}
private Entity spawn100(Engine g, Location at) {
@@ -217,6 +261,9 @@ public class IrisEntitySpawn implements Rarity {
return null;
}
if (J.isFolia() && !J.isOwnedByCurrentRegion(at.getWorld(), at.getBlockX() >> 4, at.getBlockZ() >> 4)) {
return null;
}
IrisSurface surface = irisEntity.getSurface();
boolean checkPosition = !ignoreSurfaces || surface.isFluid();
if (checkPosition && !surface.matches(at.clone().subtract(0, surface.isFluid() ? 0 : 1, 0).getBlock())) {
@@ -231,7 +278,7 @@ public class IrisEntitySpawn implements Rarity {
}
}
Entity e = irisEntity.spawn(g, at.clone().add(0.5, 0.5, 0.5), rng.aquire(() -> new RNG(g.getSeedManager().getEntity())));
Entity e = irisEntity.spawn(g, at.clone().add(0.5, surface.isFluid() ? 0.5 : 0, 0.5), rng.aquire(() -> new RNG(g.getSeedManager().getEntity())));
if (e != null) {
IrisLogging.debug("Spawned " + C.DARK_AQUA + "Entity<" + getEntity() + "> " + C.GREEN + e.getType() + C.LIGHT_PURPLE + " @ " + C.GRAY + e.getLocation().getX() + ", " + e.getLocation().getY() + ", " + e.getLocation().getZ());
}
@@ -244,26 +291,38 @@ public class IrisEntitySpawn implements Rarity {
}
}
private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR;
}
private boolean isAreaClearForSpawn(Location center, Vector3d boundingBox, IrisSurface surface) {
World world = center.getWorld();
boolean fluid = surface.isFluid();
int startX = fluid ? (int) Math.floor(center.getX() + 0.5 - boundingBox.x / 2)
: center.getBlockX() - (int) (boundingBox.x / 2);
int endX = fluid ? (int) Math.floor(Math.nextDown(center.getX() + 0.5 + boundingBox.x / 2))
: center.getBlockX() + (int) (boundingBox.x / 2);
int startY = fluid ? (int) Math.floor(center.getY() + 0.5) : center.getBlockY();
int endY = fluid ? (int) Math.floor(Math.nextDown(center.getY() + 0.5 + boundingBox.y))
: center.getBlockY() + (int) boundingBox.y;
int startZ = fluid ? (int) Math.floor(center.getZ() + 0.5 - boundingBox.z / 2)
: center.getBlockZ() - (int) (boundingBox.z / 2);
int endZ = fluid ? (int) Math.floor(Math.nextDown(center.getZ() + 0.5 + boundingBox.z / 2))
: center.getBlockZ() + (int) (boundingBox.z / 2);
int startX = (int) Math.floor(center.getX() + 0.5 - boundingBox.x / 2);
int endX = (int) Math.floor(Math.nextDown(center.getX() + 0.5 + boundingBox.x / 2));
double baseY = center.getY() + (fluid ? 0.5 : 0);
int startY = (int) Math.floor(baseY);
int endY = (int) Math.floor(Math.nextDown(baseY + boundingBox.y));
int startZ = (int) Math.floor(center.getZ() + 0.5 - boundingBox.z / 2);
int endZ = (int) Math.floor(Math.nextDown(center.getZ() + 0.5 + boundingBox.z / 2));
if (startY < world.getMinHeight() || endY >= world.getMaxHeight()) {
return false;
}
if (J.isFolia()) {
for (int chunkX = startX >> 4; chunkX <= endX >> 4; chunkX++) {
for (int chunkZ = startZ >> 4; chunkZ <= endZ >> 4; chunkZ++) {
if (!J.isOwnedByCurrentRegion(world, chunkX, chunkZ)) {
return false;
}
}
}
}
for (int x = startX; x <= endX; x++) {
for (int y = startY; y <= endY; y++) {
for (int z = startZ; z <= endZ; z++) {
if (fluid ? !surface.matches(world.getBlockAt(x, y, z))
: world.getBlockAt(x, y, z).getType() != Material.AIR) {
: !isAir(world.getBlockAt(x, y, z).getType())) {
return false;
}
}
@@ -43,6 +43,7 @@ import lombok.experimental.Accessors;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@@ -62,7 +63,7 @@ public class IrisSpawner extends IrisRegistrant {
@Description("The entity spawns to add initially. EXECUTES PER CHUNK!")
private KList<IrisEntitySpawn> initialSpawns = new KList<>();
@Description("This spawner will not spawn in a given chunk if that chunk has more than the defined amount of living entities.")
@Description("Maximum ambient entities of the selected entity's native spawn category in one chunk. Spawn groups are limited to the remaining capacity.")
private int maxEntitiesPerChunk = 1;
@Description("The block of 24 hour time to contain this spawn in.")
@@ -83,6 +84,21 @@ public class IrisSpawner extends IrisRegistrant {
@Description("Where should these spawns be placed")
private IrisSpawnGroup group = IrisSpawnGroup.NORMAL;
public int remainingCapacity(IrisEntity entity, Map<String, Integer> counts) {
if (entity == null) {
return 0;
}
String category = entity.spawnCategory();
int count = counts.getOrDefault(category, 0);
if ("misc".equals(category)) {
count = 0;
for (int population : counts.values()) {
count += population;
}
}
return Math.max(0, maxEntitiesPerChunk - count);
}
public boolean isValid(IrisBiome biome) {
return switch (group) {
case NORMAL -> switch (biome.getInferredType()) {