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

@@ -61,11 +61,12 @@ public final class ModdedEntitySpawner {
}
double x = blockX + 0.5;
double y = blockY + 0.5;
double verticalOffset = irisEntity.getSurface().isFluid() ? 0.5 : 0;
double y = blockY + verticalOffset;
double z = blockZ + 0.5;
boolean riseEffectActive = irisEntity.isSpawnEffectRiseOutOfGround() && level.hasPlayersNearby(new NativeEntityRuntime.Position(x, y, z), PLAYER_EFFECT_RADIUS);
int spawnBlockY = riseEffectActive ? blockY - 5 : blockY;
double spawnY = spawnBlockY + 0.5;
double spawnY = spawnBlockY + verticalOffset;
NativeSpawnedEntity created = create(irisEntity, level, x, spawnY, z);
if (created == null) {
@@ -98,8 +99,7 @@ public final class ModdedEntitySpawner {
String customName = irisEntity.getCustomName();
entity.applyBase(irisEntity, customName == null ? null : colorize(customName));
boolean persistent = irisEntity.isKeepEntity() || forcePersist();
applyPersistence(entity, persistent);
applyPersistence(irisEntity, entity, forcePersist());
applyPassengers(engine, irisEntity, entity, level, blockX, blockY, blockZ, rng);
@@ -273,8 +273,8 @@ public final class ModdedEntitySpawner {
return IrisSettings.get().getWorld().isForcePersistEntities();
}
private static void applyPersistence(NativeSpawnedEntity entity, boolean persistent) {
if (!entity.persistence(persistent)) {
static void applyPersistence(IrisEntity options, NativeSpawnedEntity entity, boolean forcePersistence) {
if (!entity.persistence(options.isKeepEntity() || forcePersistence)) {
String type = entity.typeKey();
if (WARNED_PERSISTENCE_TYPES.add(type)) {
IrisLogging.warn("Iris entity: vanilla cannot persist non-serializable entity type '" + type + "'");
@@ -55,6 +55,7 @@ import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterMarker;
import art.arcane.volmlib.util.matter.slices.MarkerMatter;
import java.util.Map;
import java.util.HashSet;
import java.util.List;
import java.util.ArrayList;
@@ -511,13 +512,16 @@ public final class ModdedWorldManager implements EngineWorldManager {
}
private void spawnAmbient(NativeWorld level, int chunkX, int chunkZ, boolean initial, BiomeEnvironment environment) {
if (!NativeSpawnQueries.ambientAllowed(level, chunkX, chunkZ, initial)) {
return;
}
IrisComplex complex = engine.getComplex();
if (complex == null) {
return;
}
IrisBiome biome = environment.biome();
int chunkMobs = countChunkLivingEntities(level, chunkX, chunkZ);
Map<String, Integer> chunkMobs = NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ);
KList<IrisEntitySpawn> pool = new KList<>();
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial);
@@ -535,20 +539,18 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (!canSpawn(spawner, chunkX, chunkZ)) {
return;
}
int spawned = spawnEntry(level, chosen, spawner, chunkX, chunkZ);
int spawned = spawnEntry(level, chosen, spawner, chunkX, chunkZ,
remainingCapacity(chosen, spawner, chunkMobs));
if (spawned > 0) {
spawner.spawn(engine, chunkX, chunkZ);
}
}
private void collectSpawns(KList<IrisEntitySpawn> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, int chunkMobs, boolean initial) {
private void collectSpawns(KList<IrisEntitySpawn> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map<String, Integer> chunkMobs, boolean initial) {
for (IrisSpawner spawner : spawners) {
if (spawner == null) {
continue;
}
if (spawner.getMaxEntitiesPerChunk() <= chunkMobs) {
continue;
}
if (biomeFilter != null && !spawner.isValid(biomeFilter)) {
continue;
}
@@ -559,12 +561,19 @@ public final class ModdedWorldManager implements EngineWorldManager {
for (IrisEntitySpawn entry : spawns) {
entry.setReferenceSpawner(spawner);
entry.setReferenceMarker(spawner.getReferenceMarker());
pool.add(entry);
if (remainingCapacity(entry, spawner, chunkMobs) > 0) {
pool.add(entry);
}
}
}
}
private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, int chunkX, int chunkZ) {
private int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Map<String, Integer> counts) {
IrisEntity entity = entry.getRealEntity(engine);
return spawner.remainingCapacity(entity, counts);
}
private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, int chunkX, int chunkZ, int remainingCapacity) {
IrisEntity irisEntity = entry.getRealEntity(engine);
if (irisEntity == null) {
return 0;
@@ -572,7 +581,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
int min = entry.getMinSpawns();
int max = entry.getMaxSpawns();
int count = LootResolver.inclusive(RNG.r, min, max);
int count = Math.min(Math.max(0, remainingCapacity), LootResolver.inclusive(RNG.r, min, max));
if (count <= 0) {
return 0;
}
@@ -593,7 +602,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
}
IrisPosition caveFloor = caveFloors.getRandom(RNG.r);
worldX = caveFloor.getX();
worldY = caveFloor.getY() + 1;
worldY = caveFloor.getY();
worldZ = caveFloor.getZ();
} else {
worldX = (chunkX << 4) + RNG.r.i(16);
@@ -708,10 +717,6 @@ public final class ModdedWorldManager implements EngineWorldManager {
|| NativeSpawnQueries.solid(level.getBlock(worldX, worldY + 2, worldZ));
}
private int countChunkLivingEntities(NativeWorld level, int chunkX, int chunkZ) {
return NativeSpawnQueries.livingEntities(level, chunkX, chunkZ);
}
/**
* ServerChunkCache.tickChunks rebuilds NaturalSpawner.SpawnState every tick from every entity in the
* level, so read that instead of walking all entities again on an Iris timer. The count is the natural
@@ -2,6 +2,13 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityBehavior;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.entity.EntityType;
import org.junit.BeforeClass;
import net.minecraft.world.entity.Mob;
import org.junit.Test;
import org.junit.Before;
import art.arcane.volmlib.nativelib.modded.EntityBehaviorTags;
@@ -11,18 +18,31 @@ import java.util.Set;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedEntityPersistenceTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Before
public void configureEntityTags() {
NativeEntityBehavior.bind(new EntityBehaviorTags("iris_non_persistent", "iris_unaware"));
}
@Test
public void generatedNonPersistentEntityIsExcludedFromVanillaSaves() {
public void explicitlyExcludedEntityIsExcludedFromVanillaSaves() {
Set<String> tags = new HashSet<>();
NativeEntityBehavior.configurePersistenceTags(tags, false);
NativeEntityBehavior.configureSavingTags(tags, false);
assertFalse(NativeEntityBehavior.shouldSave(tags, true));
}
@@ -30,9 +50,9 @@ public class ModdedEntityPersistenceTest {
@Test
public void positivePersistenceRemovesTheSaveExclusion() {
Set<String> tags = new HashSet<>();
NativeEntityBehavior.configurePersistenceTags(tags, false);
NativeEntityBehavior.configureSavingTags(tags, false);
NativeEntityBehavior.configurePersistenceTags(tags, true);
NativeEntityBehavior.configureSavingTags(tags, true);
assertTrue(NativeEntityBehavior.shouldSave(tags, true));
}
@@ -41,8 +61,57 @@ public class ModdedEntityPersistenceTest {
public void interceptionNeverOverridesVanillaSaveRejection() {
Set<String> tags = new HashSet<>();
NativeEntityBehavior.configurePersistenceTags(tags, true);
NativeEntityBehavior.configureSavingTags(tags, true);
assertFalse(NativeEntityBehavior.shouldSave(tags, false));
}
@Test
public void defaultAmbientMobSavesWithoutBecomingPermanent() {
Mob mob = mob();
NativeSpawnedEntity entity = new NativeSpawnedEntity(mob);
IrisEntity options = new IrisEntity();
ModdedEntitySpawner.applyPersistence(options, entity, false);
entity.configureMob(options);
assertTrue(NativeEntityBehavior.shouldSave(mob, true));
verify(mob, never()).setPersistenceRequired();
}
@Test
public void explicitKeepForceAndRemovalFlagsControlNativeDespawn() {
for (boolean keep : new boolean[]{false, true}) {
for (boolean force : new boolean[]{false, true}) {
for (boolean removable : new boolean[]{false, true}) {
Mob mob = mob();
NativeSpawnedEntity entity = new NativeSpawnedEntity(mob);
IrisEntity options = new IrisEntity().setKeepEntity(keep).setRemovable(removable);
ModdedEntitySpawner.applyPersistence(options, entity, force);
entity.configureMob(options);
assertTrue(NativeEntityBehavior.shouldSave(mob, true));
if (keep || force || !removable) {
verify(mob, atLeastOnce()).setPersistenceRequired();
} else {
verify(mob, never()).setPersistenceRequired();
}
}
}
}
}
private static Mob mob() {
Mob mob = mock(Mob.class);
EntityType<?> type = mock(EntityType.class);
Set<String> tags = new HashSet<>();
when(type.canSerialize()).thenReturn(true);
doReturn(type).when(mob).getType();
when(mob.entityTags()).thenReturn(tags);
when(mob.shouldBeSaved()).thenReturn(true);
when(mob.removeTag(anyString())).thenAnswer(invocation -> tags.remove(invocation.getArgument(0)));
when(mob.addTag(anyString())).thenAnswer(invocation -> tags.add(invocation.getArgument(0)));
return mob;
}
}
@@ -21,7 +21,7 @@ import static org.junit.Assert.assertTrue;
public class ModdedEntitySpawnerParityTest {
@Test
public void collisionCheckUsesPaperInclusiveIntegerDimensions() {
public void landBodyFitsTwoBlockCaveAndRejectsSecondBlockCeiling() {
Set<BlockPos> checked = new HashSet<>();
boolean clear = NativeEntityRuntime.isAreaClearForSpawn(
@@ -32,6 +32,36 @@ public class ModdedEntitySpawnerParityTest {
assertTrue(clear);
assertEquals(Set.of(new BlockPos(10, 64, 20), new BlockPos(10, 65, 20)), checked);
assertTrue(NativeEntityRuntime.isAreaClearForSpawn(10, 64, 20, 0.6F, 1.95F,
(x, y, z) -> y >= 64 && y < 66));
assertFalse(NativeEntityRuntime.isAreaClearForSpawn(10, 64, 20, 0.6F, 1.95F,
(x, y, z) -> y == 64));
}
@Test
public void fractionalWideLandBodiesIncludeAdjacentObstructions() {
Set<BlockPos> checked = new HashSet<>();
assertTrue(NativeEntityRuntime.isAreaClearForSpawn(-1, -49, -1, 1.3F, 1.95F,
(x, y, z) -> {
checked.add(new BlockPos(x, y, z));
return true;
}));
assertEquals(18, checked.size());
assertTrue(checked.contains(new BlockPos(-2, -48, -2)));
assertTrue(checked.contains(new BlockPos(0, -49, 0)));
assertFalse(NativeEntityRuntime.isAreaClearForSpawn(-1, -49, -1, 1.3F, 1.95F,
(x, y, z) -> !new BlockPos(x, y, z).equals(new BlockPos(0, -48, -1))));
}
@Test
public void exactLandBodyBoundariesExcludeUntouchedBlocks() {
Set<BlockPos> checked = new HashSet<>();
assertTrue(NativeEntityRuntime.isAreaClearForSpawn(0, -49, 0, 1F, 2F,
(x, y, z) -> {
checked.add(new BlockPos(x, y, z));
return true;
}));
assertEquals(Set.of(new BlockPos(0, -49, 0), new BlockPos(0, -48, 0)), checked);
}
@Test
@@ -3,12 +3,21 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.SeedManager;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.generation.decoration.IrisSurface;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.matter.slices.MarkerMatter;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -23,7 +32,9 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -33,8 +44,15 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedWaterEntitySpawnTest {
@@ -48,7 +66,8 @@ public class ModdedWaterEntitySpawnTest {
}
}
BuiltInRegistries.BLOCK.prepareTagReload(new TagLoader.LoadResult<>(Registries.BLOCK, Map.of(
BlockTags.BLOCKS_MOTION, List.of(Blocks.OAK_FENCE.builtInRegistryHolder())))).apply();
BlockTags.BLOCKS_MOTION, List.of(Blocks.OAK_FENCE.builtInRegistryHolder(),
Blocks.STONE.builtInRegistryHolder())))).apply();
}
@Test
@@ -75,6 +94,22 @@ public class ModdedWaterEntitySpawnTest {
assertFalse(ModdedEntitySpawner.isAreaClearForSpawn(new NativeEntityRuntime(level), entity, 0, 78, 0));
}
@Test
public void nativeLandClearanceAcceptsEveryAirBlockAndRejectsStone() {
ServerLevel level = mock(ServerLevel.class);
NativeEntityRuntime runtime = new NativeEntityRuntime(level);
IrisEntity entity = new IrisEntity().setType("zombie").setSurface(IrisSurface.LAND);
for (Block air : List.of(Blocks.AIR, Blocks.CAVE_AIR, Blocks.VOID_AIR)) {
when(level.getBlockState(any(BlockPos.class))).thenReturn(air.defaultBlockState());
assertTrue(ModdedEntitySpawner.isAreaClearForSpawn(runtime, entity, -1, -49, -1));
}
when(level.getBlockState(any(BlockPos.class))).thenAnswer(invocation -> {
BlockPos position = invocation.getArgument(0);
return position.getY() == -48 ? Blocks.STONE.defaultBlockState() : Blocks.CAVE_AIR.defaultBlockState();
});
assertFalse(ModdedEntitySpawner.isAreaClearForSpawn(runtime, entity, -1, -49, -1));
}
@Test
public void waterloggedSolidBlocksStillObstructWaterSpawns() {
assertTrue(ModdedWorldManager.matchesSurface(IrisSurface.WATER,
@@ -107,6 +142,29 @@ public class ModdedWaterEntitySpawnTest {
assertEquals(Set.of(new BlockPos(0, 78, 0)), checked);
}
@Test
public void spawnAndRiseStartUseFloorHeightOnLandAndCenterHeightInFluids() {
for (IrisSurface surface : List.of(IrisSurface.LAND, IrisSurface.WATER, IrisSurface.LAVA)) {
for (boolean rise : new boolean[]{false, true}) {
Engine engine = mock(Engine.class);
NativeEntityRuntime runtime = mock(NativeEntityRuntime.class);
NativeSpawnedEntity created = mock(NativeSpawnedEntity.class);
IrisEntity entity = new IrisEntity().setSpecialType("test:mob")
.setSurface(surface).setSpawnEffectRiseOutOfGround(rise);
when(runtime.chunksSafe(-1, 0)).thenReturn(true);
when(runtime.hasPlayersNearby(any(NativeEntityRuntime.Position.class), eq(32D))).thenReturn(true);
when(runtime.spawnCustom(eq("test:mob"), any(NativeEntityRuntime.Position.class), any()))
.thenReturn(created);
assertSame(created, ModdedEntitySpawner.spawn(engine, entity, runtime, -1, -49, 0, new RNG(1L)));
double targetY = -49 + (surface.isFluid() ? 0.5 : 0) - (rise ? 5 : 0);
verify(runtime).spawnCustom(eq("test:mob"),
eq(new NativeEntityRuntime.Position(-0.5, targetY, 0.5)), any());
}
}
}
@Test
public void mixedSpawnerUsesFluidDepthWithoutChangingLandMembers() {
RNG rng = new RNG(1L);
@@ -117,4 +175,42 @@ public class ModdedWaterEntitySpawnTest {
assertNull(IrisEntitySpawn.selectSurfaceSpawnY(IrisSpawnGroup.NORMAL, IrisSurface.WATER, 78, 78, rng));
assertNull(IrisEntitySpawn.selectSurfaceSpawnY(IrisSpawnGroup.UNDERWATER, IrisSurface.WATER, 78, 78, rng));
}
@Test
public void caveFloorMarkerSpawnsAboveSupportAtNegativeWorldHeight() throws ReflectiveOperationException {
Engine engine = mock(Engine.class);
EngineMantle mantle = mock(EngineMantle.class);
when(engine.getMantle()).thenReturn(mantle);
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
when(mantle.findMarkers(-2, 3, MarkerMatter.CAVE_FLOOR))
.thenReturn(new KList<>(new IrisPosition(-25, -49, 57)));
NativeWorld world = mock(NativeWorld.class);
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
when(world.nativeHandle()).thenReturn(mock(ServerLevel.class));
when(world.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
ModdedBlockState.of((int) invocation.getArgument(1) <= -50
? Blocks.STONE.defaultBlockState() : Blocks.CAVE_AIR.defaultBlockState(), null));
IrisEntity entity = new IrisEntity().setSurface(IrisSurface.LAND);
IrisEntitySpawn entry = spy(new IrisEntitySpawn().setMinSpawns(1).setMaxSpawns(1));
doReturn(entity).when(entry).getRealEntity(engine);
IrisSpawner spawner = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE);
Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnEntry", NativeWorld.class,
IrisEntitySpawn.class, IrisSpawner.class, int.class, int.class, int.class);
spawn.setAccessible(true);
ModdedWorldManager manager = new ModdedWorldManager(engine);
try (MockedStatic<ModdedEntitySpawner> entities = mockStatic(ModdedEntitySpawner.class)) {
entities.when(() -> ModdedEntitySpawner.isAreaClearForSpawn(any(NativeEntityRuntime.class),
eq(entity), eq(-25), eq(-49), eq(57))).thenReturn(true);
entities.when(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class),
eq(-25), eq(-49), eq(57), any(RNG.class))).thenReturn(mock(NativeSpawnedEntity.class));
assertEquals(1, spawn.invoke(manager, world, entry, spawner, -2, 3, 1));
entities.verify(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class),
eq(-25), eq(-49), eq(57), any(RNG.class)));
} finally {
manager.close();
}
}
}
@@ -0,0 +1,86 @@
package art.arcane.volmlib.nativelib.minecraft26_2.modded;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.MobCategory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.level.gamerules.GameRules;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class NativeSpawnPopulationTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void categoryCountsExcludePlayersAndNeighboringChunks() {
ServerLevel level = mock(ServerLevel.class);
NativeWorld world = mock(NativeWorld.class);
when(world.nativeHandle()).thenReturn(level);
when(level.getMinY()).thenReturn(-64);
when(level.getMaxY()).thenReturn(320);
LivingEntity frog = mob(MobCategory.CREATURE, -4, 7);
LivingEntity zombie = mob(MobCategory.MONSTER, -4, 7);
LivingEntity neighbor = mob(MobCategory.MONSTER, -3, 7);
Player player = mock(Player.class);
when(player.blockPosition()).thenReturn(new BlockPos(-60, 50, 120));
List<Entity> entities = List.of(frog, frog, frog, zombie, neighbor, player);
when(level.getEntities(isNull(Entity.class), any(AABB.class), any()))
.thenAnswer(invocation -> {
Predicate<? super Entity> filter = invocation.getArgument(2);
return entities.stream().filter(filter).toList();
});
assertEquals(Map.of("creature", 3, "monster", 1),
NativeSpawnQueries.livingEntityCategories(world, -4, 7));
}
@Test
public void ambientAdmissionRequiresMobSpawningAndTickingForRecurringAttempts() {
ServerLevel level = mock(ServerLevel.class);
NativeWorld world = mock(NativeWorld.class);
GameRules rules = mock(GameRules.class);
when(world.nativeHandle()).thenReturn(level);
when(level.getMinY()).thenReturn(-64);
when(level.getGameRules()).thenReturn(rules);
when(rules.get(GameRules.SPAWN_MOBS)).thenReturn(false);
assertFalse(NativeSpawnQueries.ambientAllowed(world, -4, 7, true));
assertFalse(NativeSpawnQueries.ambientAllowed(world, -4, 7, false));
when(rules.get(GameRules.SPAWN_MOBS)).thenReturn(true);
assertTrue(NativeSpawnQueries.ambientAllowed(world, -4, 7, true));
assertFalse(NativeSpawnQueries.ambientAllowed(world, -4, 7, false));
when(level.isPositionEntityTicking(new BlockPos(-56, -64, 120))).thenReturn(true);
assertTrue(NativeSpawnQueries.ambientAllowed(world, -4, 7, false));
}
private static LivingEntity mob(MobCategory category, int chunkX, int chunkZ) {
EntityType<?> type = mock(EntityType.class);
when(type.getCategory()).thenReturn(category);
LivingEntity entity = mock(LivingEntity.class);
doReturn(type).when(entity).getType();
when(entity.blockPosition()).thenReturn(new BlockPos((chunkX << 4) + 8, 32, (chunkZ << 4) + 8));
return entity;
}
}