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
2026-10-02 16:55:52 -04:00
parent 4ef6fc7cc6
commit a765e76aa3
31 changed files with 1712 additions and 87 deletions
@@ -0,0 +1,35 @@
plugins {
id 'java'
}
version = '1.0.0'
repositories {
mavenCentral()
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
}
dependencies {
compileOnly files(providers.gradleProperty('irisJar').get())
compileOnly files(providers.gradleProperty('irisGsonJar').get())
compileOnly 'io.papermc.paper:paper-api:26.1.2.build.74-stable'
}
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}
sourceSets {
main {
java.srcDirs = ['src']
resources.srcDirs = ['resources']
}
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs += '-parameters'
}
tasks.named('jar') {
archiveBaseName = 'IrisNaturalSpawningFixture'
}
@@ -0,0 +1,12 @@
name: IrisNaturalSpawningFixture
version: 1.0.0
main: art.arcane.iris.fixture.NaturalSpawningFixture
api-version: '26.1.2'
folia-supported: true
depend: [Iris]
commands:
irisspawnqa:
permission: irisspawnqa.run
permissions:
irisspawnqa.run:
default: op
@@ -0,0 +1 @@
rootProject.name = 'iris-natural-spawning-fixture'
@@ -0,0 +1,472 @@
package art.arcane.iris.fixture;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.BukkitEntityType;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Difficulty;
import org.bukkit.GameMode;
import org.bukkit.HeightMap;
import org.bukkit.GameRules;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.plugin.java.JavaPlugin;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public final class NaturalSpawningFixture extends JavaPlugin implements Listener {
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "spawn_qa");
private static final long WORLD_SEED = 78264193L;
private static final int FROG_CAPACITY = 16;
private static final int ZOMBIE_CAPACITY = 2;
private static final int BIOME_READY_ATTEMPTS = 60;
private static final long BIOME_RETRY_TICKS = 10L;
private boolean previousAmbient;
private boolean previousMarkers;
private boolean previousForcedPersistence;
private volatile boolean ready;
private int firstZombieBatch;
private int ambientEvents;
private int cancelledAmbientEvents;
private final ThreadLocal<Boolean> ambientInvocation = ThreadLocal.withInitial(() -> false);
@Override
public void onEnable() {
Bukkit.getPluginManager().registerEvents(this, this);
previousAmbient = IrisSettings.get().getWorld().isAmbientEntitySpawningSystem();
previousMarkers = IrisSettings.get().getWorld().isMarkerEntitySpawningSystem();
previousForcedPersistence = IrisSettings.get().getWorld().isForcePersistEntities();
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(false);
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(false);
IrisSettings.get().getWorld().setForcePersistEntities(false);
getLogger().info("Natural spawning fixture ready; commands use the Iris ambient path on the owning region.");
}
@Override
public void onDisable() {
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(previousAmbient);
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(previousMarkers);
IrisSettings.get().getWorld().setForcePersistEntities(previousForcedPersistence);
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) {
notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|surface|cave");
return true;
}
try {
switch (arguments[0]) {
case "setup" -> setup(sender);
case "frogs" -> runRegion(sender, () -> spawnFrogs(sender));
case "zombies" -> runRegion(sender, () -> spawnZombies(sender));
case "stress" -> runRegion(sender, () -> stress(sender));
case "check" -> runRegion(sender, () -> check(sender));
case "surface" -> view(sender, 67D);
case "cave" -> view(sender, -46D);
default -> throw new IllegalArgumentException("Unknown spawning operation");
}
} catch (Throwable failure) {
fail(sender, failure);
}
return true;
}
@EventHandler(priority = EventPriority.HIGHEST)
public void isolateSpawns(CreatureSpawnEvent event) {
if (!WORLD_KEY.equals(event.getLocation().getWorld().getKey())) {
return;
}
if (!ambientInvocation.get()) {
event.setCancelled(true);
return;
}
ambientEvents++;
if (event.isCancelled()) {
cancelledAmbientEvents++;
}
}
private void setup(CommandSender sender) {
if (!(sender instanceof Player)) {
throw new IllegalArgumentException("Setup requires a player");
}
World world = Bukkit.getWorld(WORLD_KEY);
if (world != null) {
require(IrisToolbelt.isIrisWorld(world) && world.getSeed() == WORLD_SEED,
"Existing fixture key is not the owned Iris world with the expected seed");
ready = false;
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
return;
}
ready = false;
Bukkit.getAsyncScheduler().runNow(this, task -> createWorld(sender));
}
private void createWorld(CommandSender sender) {
try {
World world = new IrisCreator().name(WORLD_KEY.toString()).dimension("spawn-qa")
.seed(WORLD_SEED).create();
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
} catch (Throwable failure) {
fail(sender, failure);
}
}
private void configureWorld(CommandSender sender, World world) {
try {
world.setDifficulty(Difficulty.NORMAL);
world.setGameRule(GameRules.SPAWN_MOBS, true);
List<CompletableFuture<Void>> chunks = new ArrayList<>();
for (int chunkX = -1; chunkX <= 1; chunkX++) {
for (int chunkZ = -1; chunkZ <= 1; chunkZ++) {
chunks.add(retainChunk(world, chunkX, chunkZ));
}
}
CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> {
if (failure != null) {
fail(sender, failure);
} else {
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> prepareChunk(sender, world));
}
});
} catch (Throwable failure) {
fail(sender, failure);
}
}
private CompletableFuture<Void> retainChunk(World world, int chunkX, int chunkZ) {
CompletableFuture<Void> retained = new CompletableFuture<>();
world.getChunkAtAsync(chunkX, chunkZ, true).whenComplete((chunk, failure) -> {
if (failure != null) {
retained.completeExceptionally(failure);
return;
}
Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> {
try {
chunk.addPluginChunkTicket(this);
retained.complete(null);
} catch (Throwable error) {
retained.completeExceptionally(error);
}
});
});
return retained;
}
private void prepareChunk(CommandSender sender, World world) {
try {
require(WORLD_KEY.equals(world.getKey()) && IrisToolbelt.isIrisWorld(world)
&& world.getSeed() == WORLD_SEED, "Fixture world ownership changed");
Chunk chunk = world.getChunkAt(0, 0);
int removed = 0;
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
entity.remove();
removed++;
}
}
for (Entity entity : chunk.getEntities()) {
require(!(entity instanceof LivingEntity) || entity instanceof Player || !entity.isValid(),
"Fixture target chunk retained a living mob after cleanup: " + entity.getType());
}
getLogger().info("SPAWN_QA_CLEANUP removed=" + removed + " chunk=0,0");
firstZombieBatch = 0;
ambientEvents = 0;
cancelledAmbientEvents = 0;
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
chunk.getBlock(x, 64, z).setType(Material.GRASS_BLOCK, false);
for (int y = 65; y <= 68; y++) {
chunk.getBlock(x, y, z).setType(Material.AIR, false);
}
for (int y = -48; y <= -42; y++) {
boolean shell = y == -48 || y == -42 || x == 0 || x == 15 || z == 0 || z == 15;
chunk.getBlock(x, y, z).setType(shell ? Material.STONE : Material.CAVE_AIR, false);
}
}
}
if (!IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED) {
throw new IllegalStateException("Fixture is not the requested Iris world");
}
awaitBiomeEnvironment(sender, world, 0);
} catch (Throwable failure) {
fail(sender, failure);
}
}
private void awaitBiomeEnvironment(CommandSender sender, World world, int attempt) {
try {
Engine engine = IrisToolbelt.access(world).getEngine();
engine.getSurfaceBiomeEnvironment(8, 8);
ready = true;
teleport(sender, world, 67D, "SPAWN_QA_SETUP world=" + WORLD_KEY + " seed=" + WORLD_SEED);
} catch (SavedBiomeUnavailableException unavailable) {
if (!unavailable.isLoading() || attempt >= BIOME_READY_ATTEMPTS) {
fail(sender, unavailable);
return;
}
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0,
task -> awaitBiomeEnvironment(sender, world, attempt + 1), BIOME_RETRY_TICKS);
} catch (Throwable failure) {
fail(sender, failure);
}
}
private void spawnFrogs(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0);
RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs");
getLogger().info(spawnDiagnostics(spawn, chunk));
for (int attempt = 0; attempt < 12; attempt++) {
invokeAmbient(spawn, chunk);
require(count(chunk, EntityType.FROG) <= FROG_CAPACITY, "Frog batch exceeded its category capacity");
}
require(count(chunk, EntityType.FROG) == FROG_CAPACITY,
"Frog ambient category did not fill: " + spawnDiagnostics(spawn, chunk));
require(count(chunk, EntityType.ZOMBIE) == 0, "Unexpected zombie before cave phase");
verifyPersistence(chunk);
evidence(sender, "SPAWN_QA_FROGS frogs=" + count(chunk, EntityType.FROG)
+ " persistent=true removable=true ambient=true");
}
private void spawnZombies(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0);
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Frogs must fill their category before cave spawning");
require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran");
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
getLogger().info(spawnDiagnostics(spawn, chunk));
invokeAmbient(spawn, chunk);
firstZombieBatch = count(chunk, EntityType.ZOMBIE);
require(firstZombieBatch == ZOMBIE_CAPACITY,
"Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk));
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Cave spawning changed the passive population");
verifyPersistence(chunk);
evidence(sender, "SPAWN_QA_ZOMBIES frogs=" + count(chunk, EntityType.FROG)
+ " zombies=" + firstZombieBatch + " firstBatch=" + firstZombieBatch + " ambient=true");
}
private void stress(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0);
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
for (int attempt = 0; attempt < 50; attempt++) {
invokeAmbient(spawn, chunk);
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity");
}
evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE));
}
private void check(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0);
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Passive population differs");
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Hostile population differs");
require(firstZombieBatch == ZOMBIE_CAPACITY, "First hostile batch was not pinned");
require(chunk.getBlock(8, -47, 8).getType() == Material.CAVE_AIR, "Cave is not real CAVE_AIR");
require(chunk.getBlock(8, -47, 8).getLightLevel() == 0, "Cave is not dark");
for (Entity entity : chunk.getEntities()) {
if (entity.getType() == EntityType.ZOMBIE) {
require(entity.getLocation().getY() < -42, "Hostile spawned outside the cave");
}
}
verifyPersistence(chunk);
evidence(sender, "SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0");
}
private RuntimeSpawn runtimeSpawn(Chunk chunk, String biomeKey) {
try {
Engine engine = IrisToolbelt.access(chunk.getWorld()).getEngine();
BiomeEnvironment surface = engine.getSurfaceBiomeEnvironment(8, 8);
IrisBiome biome = surface.data().getBiomeLoader().load(biomeKey);
require(biome != null, "Synthetic biome was not loaded");
BiomeEnvironment selected = new BiomeEnvironment(surface.activationId(), biome, surface.region(),
surface.dimension(), surface.data());
Object manager = engine.getWorldManager();
Field field = manager.getClass().getDeclaredField("entitySpawner");
field.setAccessible(true);
Object spawner = field.get(manager);
Method method = spawner.getClass().getDeclaredMethod("spawnAmbient", Chunk.class, boolean.class, BiomeEnvironment.class);
method.setAccessible(true);
return new RuntimeSpawn(engine, spawner, method, selected);
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not access Iris ambient spawning", failure);
}
}
private String spawnDiagnostics(RuntimeSpawn spawn, Chunk chunk) {
World world = chunk.getWorld();
int floor = world.getHighestBlockYAt(8, 8, HeightMap.OCEAN_FLOOR);
int top = world.getHighestBlockYAt(8, 8, HeightMap.WORLD_SURFACE);
Map<String, Integer> categories = new TreeMap<>();
Map<String, Integer> types = new TreeMap<>();
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
categories.merge(BukkitEntityType.of(entity.getType()).spawnCategory(), 1, Integer::sum);
types.merge(entity.getType().name(), 1, Integer::sum);
}
}
StringBuilder diagnostics = new StringBuilder("SPAWN_QA_DIAGNOSTICS frogs=")
.append(count(chunk, EntityType.FROG)).append(" zombies=").append(count(chunk, EntityType.ZOMBIE))
.append(" categories=").append(categories).append(" types=").append(types)
.append(" loadLevel=").append(chunk.getLoadLevel())
.append(" spawnMobs=").append(world.getGameRuleValue(GameRules.SPAWN_MOBS))
.append(" animals=").append(world.getAllowAnimals()).append(" monsters=").append(world.getAllowMonsters())
.append(" floor=").append(floor).append(" top=").append(top)
.append(" support=").append(world.getBlockAt(8, top, 8).getType())
.append(" above=").append(world.getBlockAt(8, top + 1, 8).getType())
.append(" light=").append(world.getBlockAt(8, top + 1, 8).getLightLevel())
.append(" events=").append(ambientEvents).append(" cancelled=").append(cancelledAmbientEvents);
for (String key : spawn.environment().biome().getEntitySpawners()) {
IrisSpawner spawner = spawn.environment().data().getSpawnerLoader().load(key);
if (spawner == null) {
diagnostics.append(" spawner=").append(key).append(":missing");
continue;
}
diagnostics.append(" spawner=").append(key).append(" canSpawn=")
.append(spawner.canSpawn(spawn.engine(), chunk.getX(), chunk.getZ()))
.append(" excluded=").append(spawner.isCompatExcluded())
.append(" group=").append(spawner.getGroup()).append(" capacity=").append(spawner.getMaxEntitiesPerChunk());
for (IrisEntitySpawn entry : spawner.getSpawns()) {
IrisEntity entity = entry.getRealEntity(spawn.engine());
diagnostics.append(" entity=").append(entry.getEntity());
if (entity == null) {
diagnostics.append(":missing");
continue;
}
diagnostics.append(" type=").append(entity.getBukkitType()).append(" category=").append(entity.spawnCategory())
.append(" surface=").append(entity.getSurface())
.append(" supportMatches=").append(entity.getSurface().matches(world.getBlockAt(8, top, 8)))
.append(" bounds=").append(BukkitPlatform.entityBoundingBox(entity.getBukkitType()));
}
}
return diagnostics.toString();
}
private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk) {
ambientInvocation.set(true);
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
spawn.method().invoke(spawn.spawner(), chunk, false, spawn.environment());
} catch (InvocationTargetException failure) {
throw new IllegalStateException("Iris ambient spawning failed", failure.getCause());
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not invoke Iris ambient spawning", failure);
} finally {
ambientInvocation.remove();
}
}
private int count(Chunk chunk, EntityType type) {
int count = 0;
for (Entity entity : chunk.getEntities()) {
if (entity.getType() == type) {
count++;
}
}
return count;
}
private void verifyPersistence(Chunk chunk) {
for (Entity entity : chunk.getEntities()) {
if (entity.getType() == EntityType.FROG || entity.getType() == EntityType.ZOMBIE) {
require(entity.isPersistent(), "Ambient entity stopped participating in chunk saves");
require(entity instanceof LivingEntity living && living.getRemoveWhenFarAway(),
"Ambient entity has distance despawning disabled");
}
}
}
private void view(CommandSender sender, double y) {
teleport(sender, requireWorld(), y, "SPAWN_QA_VIEW y=" + (int) y);
}
private void teleport(CommandSender sender, World world, double y, String message) {
if (!(sender instanceof Player player)) {
throw new IllegalArgumentException("Viewing requires a player");
}
player.getScheduler().run(this, task -> {
player.setGameMode(GameMode.CREATIVE);
player.setAllowFlight(true);
player.setFlying(true);
player.teleportAsync(new Location(world, 40.5D, y, 8.5D)).whenComplete((success, failure) -> {
if (failure != null || !Boolean.TRUE.equals(success)) {
fail(sender, failure == null ? new IllegalStateException("Fixture teleport failed") : failure);
} else {
notifySender(sender, message);
}
});
}, null);
}
private World requireWorld() {
World world = Bukkit.getWorld(WORLD_KEY);
require(ready && world != null, "Run setup before a spawning phase");
return world;
}
private void runRegion(CommandSender sender, Runnable operation) {
World world = requireWorld();
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> {
try {
operation.run();
} catch (Throwable failure) {
fail(sender, failure);
}
});
}
private void evidence(CommandSender sender, String message) {
getLogger().info(message);
notifySender(sender, message);
}
private void notifySender(CommandSender sender, String message) {
if (sender instanceof Player player) {
player.getScheduler().run(this, task -> player.sendMessage(message), null);
} else {
sender.sendMessage(message);
}
}
private void fail(CommandSender sender, Throwable failure) {
getLogger().log(Level.SEVERE, "Spawning acceptance failed", failure);
notifySender(sender, "SPAWN_QA_FAILED " + failure.getMessage());
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private record RuntimeSpawn(Engine engine, Object spawner, Method method, BiomeEnvironment environment) {
}
}
@@ -0,0 +1,26 @@
{
"name": "Cave Zombies",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:grass_block"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"entitySpawners": [
"qa/cave-zombies"
]
}
@@ -0,0 +1,26 @@
{
"name": "Frogs",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:grass_block"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"entitySpawners": [
"qa/frogs"
]
}
@@ -0,0 +1,28 @@
{
"name": "Spawn QA",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "frogs",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:stone"
}
]
}
}
@@ -0,0 +1,8 @@
{
"type": "minecraft:frog",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false
}
@@ -0,0 +1,8 @@
{
"type": "minecraft:zombie",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"frogs"
],
"seaBiomes": [
"frogs"
],
"shoreBiomes": [
"frogs"
]
}
@@ -0,0 +1,16 @@
{
"group": "CAVE",
"maxEntitiesPerChunk": 2,
"allowedLightLevels": {
"min": 0,
"max": 0
},
"spawns": [
{
"entity": "qa/zombie",
"rarity": 1,
"minSpawns": 7,
"maxSpawns": 7
}
]
}
@@ -0,0 +1,12 @@
{
"group": "NORMAL",
"maxEntitiesPerChunk": 16,
"spawns": [
{
"entity": "qa/frog",
"rarity": 1,
"minSpawns": 7,
"maxSpawns": 7
}
]
}
@@ -0,0 +1,41 @@
export default {
name: 'iris-natural-spawning',
description: 'Observe category-limited Iris ambient frogs and cave zombies on Folia.',
async run(context) {
const wait = (predicate, label, timeoutMs = 30000) => context.waitUntil(predicate, { label, timeoutMs, intervalMs: 100 })
const command = async (operation, expected, timeoutMs = 30000) => {
const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs)
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
return response
}
const entities = name => Object.values(context.bot.entities).filter(entity => entity.name === name)
await context.step('prepare the synthetic Iris world and dark cave', async () => {
await command('setup', 'SPAWN_QA_SETUP world=iris:spawn_qa seed=78264193', 180000)
await wait(() => Math.abs(context.bot.entity.position.y - 67) < 1, 'Fixture world teleport')
})
await context.step('fill the passive category through real Iris ambient spawning', async () => {
await command('frogs', 'SPAWN_QA_FROGS frogs=16 persistent=true removable=true ambient=true', 60000)
await wait(() => entities('frog').length === 16, 'All sixteen ambient frogs became visible')
context.report.irisNaturalSpawning = { visibleFrogs: entities('frog').length }
})
await context.step('spawn an authored seven-zombie batch with capacity two despite existing frogs', async () => {
await command('zombies', 'SPAWN_QA_ZOMBIES frogs=16 zombies=2 firstBatch=2 ambient=true', 60000)
await command('cave', 'SPAWN_QA_VIEW y=-46')
await wait(() => Math.abs(context.bot.entity.position.y + 46) < 1, 'Underground fixture teleport')
await wait(() => context.bot.blockAt(context.bot.entity.position.clone().set(8, -47, 8))?.name === 'cave_air', 'Live cave_air block reached the client')
await wait(() => entities('zombie').length === 2, 'Both ambient cave zombies became visible')
context.expect(entities('zombie').every(entity => entity.position.y < -42), 'Zombies were not inside the underground chamber')
context.report.irisNaturalSpawning.visibleZombies = entities('zombie').length
context.report.irisNaturalSpawning.caveAir = true
})
await context.step('repeat fifty ambient attempts without exceeding the hostile capacity', async () => {
await command('stress', 'SPAWN_QA_STRESS attempts=50 zombies=2', 60000)
await command('check', 'SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0', 30000)
context.expect(entities('zombie').length === 2, 'Client observed a hostile capacity overflow')
context.report.irisNaturalSpawning.saturatedAttempts = 50
context.report.irisNaturalSpawning.firstHostileBatch = 2
context.report.irisNaturalSpawning.removeWhenFarAwayAllowed = true
context.report.irisNaturalSpawning.chunkSaving = true
})
}
}
@@ -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;
}
}
@@ -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()) {
@@ -0,0 +1,87 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.testsupport.BukkitTestServer;
import io.papermc.paper.InternalAPIBridge;
import io.papermc.paper.registry.RegistryAccess;
import org.bukkit.Chunk;
import org.bukkit.GameRule;
import org.bukkit.GameRules;
import org.bukkit.Registry;
import org.bukkit.World;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Proxy;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class WorldEntitySpawnerAdmissionTest {
@BeforeClass
public static void initializeGameRules() throws Exception {
BukkitTestServer.install();
AtomicReference<Object> registered = new AtomicReference<>();
RegistryAccess access = mock(RegistryAccess.class, invocation -> {
if (invocation.getMethod().getName().equals("getRegistry")) {
if (registered.get() == null) {
registered.set(Proxy.newProxyInstance(Registry.class.getClassLoader(),
new Class<?>[]{Registry.class},
(proxy, method, arguments) -> method.getName().equals("getOrThrow")
? mock(GameRule.class) : null));
}
return registered.get();
}
return RETURNS_DEFAULTS.answer(invocation);
});
InternalAPIBridge bridge = mock(InternalAPIBridge.class);
try (MockedStatic<RegistryAccess> registries = mockStatic(RegistryAccess.class);
MockedStatic<InternalAPIBridge> internals = mockStatic(InternalAPIBridge.class)) {
registries.when(RegistryAccess::registryAccess).thenReturn(access);
internals.when(InternalAPIBridge::get).thenReturn(bridge);
Class.forName(GameRules.class.getName());
}
}
@Test
public void gameruleDisablesInitialAndRecurringAmbientSpawns() {
Chunk chunk = mock(Chunk.class);
World world = mock(World.class);
when(chunk.getWorld()).thenReturn(world);
when(chunk.getLoadLevel()).thenReturn(Chunk.LoadLevel.ENTITY_TICKING);
when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(false);
assertFalse(WorldEntitySpawner.ambientAllowed(chunk, false));
assertFalse(WorldEntitySpawner.ambientAllowed(chunk, true));
}
@Test
public void recurringSpawnsRequireEntityTickingChunks() {
Chunk chunk = mock(Chunk.class);
World world = mock(World.class);
when(chunk.getWorld()).thenReturn(world);
when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true);
for (Chunk.LoadLevel loadLevel : Chunk.LoadLevel.values()) {
when(chunk.getLoadLevel()).thenReturn(loadLevel);
if (loadLevel == Chunk.LoadLevel.ENTITY_TICKING) {
assertTrue(WorldEntitySpawner.ambientAllowed(chunk, false));
} else {
assertFalse(WorldEntitySpawner.ambientAllowed(chunk, false));
}
}
}
@Test
public void initialAmbientSpawnsRetainChunkPopulationAdmission() {
Chunk chunk = mock(Chunk.class);
World world = mock(World.class);
when(chunk.getWorld()).thenReturn(world);
when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true);
when(chunk.getLoadLevel()).thenReturn(Chunk.LoadLevel.TICKING);
assertTrue(WorldEntitySpawner.ambientAllowed(chunk, true));
}
}
@@ -0,0 +1,66 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.platform.bukkit.BukkitEntityType;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisSpawner;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class WorldEntitySpawnerPopulationTest {
@Test
public void frogsAndPlayersDoNotConsumeMonsterCapacity() {
Engine engine = mock(Engine.class);
LivingEntity frog = mock(LivingEntity.class);
LivingEntity zombie = mock(LivingEntity.class);
Player player = mock(Player.class);
when(frog.getType()).thenReturn(EntityType.FROG);
when(zombie.getType()).thenReturn(EntityType.ZOMBIE);
BukkitEntityType creatureType = mock(BukkitEntityType.class);
BukkitEntityType monsterType = mock(BukkitEntityType.class);
when(creatureType.spawnCategory()).thenReturn("creature");
when(monsterType.spawnCategory()).thenReturn("monster");
IrisEntitySpawn entry = mock(IrisEntitySpawn.class);
IrisEntity definition = mock(IrisEntity.class);
when(definition.spawnCategory()).thenReturn("monster");
when(entry.getRealEntity(engine)).thenReturn(definition);
when(entry.getReferenceSpawner()).thenReturn(new IrisSpawner().setMaxEntitiesPerChunk(2));
try (MockedStatic<BukkitEntityType> types = mockStatic(BukkitEntityType.class)) {
types.when(() -> BukkitEntityType.of(EntityType.FROG)).thenReturn(creatureType);
types.when(() -> BukkitEntityType.of(EntityType.ZOMBIE)).thenReturn(monsterType);
WorldEntitySpawner.ChunkCounter counter = new WorldEntitySpawner.ChunkCounter(
new Entity[]{frog, frog, frog, zombie, player});
assertEquals(1, counter.remainingCapacity(entry, engine));
when(definition.spawnCategory()).thenReturn("creature");
assertEquals(0, counter.remainingCapacity(entry, engine));
}
}
@Test
public void ambientAndWaterPopulationsHaveIndependentBudgets() {
IrisSpawner spawner = new IrisSpawner().setMaxEntitiesPerChunk(3);
IrisEntity entity = mock(IrisEntity.class);
Map<String, Integer> counts = Map.of("monster", 8, "ambient", 2, "water_creature", 3);
when(entity.spawnCategory()).thenReturn("ambient");
assertEquals(1, spawner.remainingCapacity(entity, counts));
when(entity.spawnCategory()).thenReturn("water_creature");
assertEquals(0, spawner.remainingCapacity(entity, counts));
when(entity.spawnCategory()).thenReturn("creature");
assertEquals(3, spawner.remainingCapacity(entity, counts));
when(entity.spawnCategory()).thenReturn("misc");
assertEquals(0, spawner.remainingCapacity(entity, counts));
assertEquals(0, spawner.remainingCapacity(null, counts));
assertEquals(0, spawner.setMaxEntitiesPerChunk(0).remainingCapacity(entity, counts));
}
}
@@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final String EXPECTED_SCHEMA_DIGEST = "4d37a4cf8bb02c6e334545b549433a1b75ff5b8aae0bc91cd8b21cd8f62307c0";
private static final String EXPECTED_SCHEMA_DIGEST = "d77cc4ea8255cbfdc4f8c49e04a3c422ca3e094399a514799ec703217f7797da";
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
IrisDimension.class,
@@ -0,0 +1,63 @@
package art.arcane.iris.world.entity;
import art.arcane.iris.configuration.IrisSettings;
import com.google.gson.Gson;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
public class IrisEntityPersistenceTest {
@Test
public void defaultAmbientMobsSaveAndAllowDistanceDespawn() {
IrisEntity options = new IrisEntity();
LivingEntity entity = mock(LivingEntity.class);
assertTrue(options.isRemovable());
assertFalse(new IrisSettings.IrisSettingsWorld().isForcePersistEntities());
options.applyPersistence(entity, false);
verify(entity).setPersistent(true);
verify(entity).setRemoveWhenFarAway(true);
}
@Test
public void explicitRetentionOverridesDistanceRemoval() {
for (boolean keep : new boolean[]{false, true}) {
for (boolean force : new boolean[]{false, true}) {
for (boolean removable : new boolean[]{false, true}) {
IrisEntity options = new IrisEntity().setKeepEntity(keep).setRemovable(removable);
LivingEntity entity = mock(LivingEntity.class);
options.applyPersistence(entity, force);
verify(entity).setPersistent(true);
verify(entity).setRemoveWhenFarAway(removable && !keep && !force);
}
}
}
}
@Test
public void nonMobEntitiesStillSaveNormally() {
Entity entity = mock(Entity.class);
new IrisEntity().applyPersistence(entity, false);
verify(entity).setPersistent(true);
}
@Test
public void authoredRetentionChoicesSurviveChangedDefaults() {
Gson gson = new Gson();
IrisEntity retained = gson.fromJson("{\"removable\":false,\"keepEntity\":true}", IrisEntity.class);
IrisSettings.IrisSettingsWorld settings = gson.fromJson("{\"forcePersistEntities\":true}", IrisSettings.IrisSettingsWorld.class);
assertFalse(retained.isRemovable());
assertTrue(retained.isKeepEntity());
assertTrue(settings.isForcePersistEntities());
}
}
@@ -0,0 +1,69 @@
package art.arcane.iris.world.entity;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.volmlib.nativelib.entity.NativeEntityType;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class IrisEntitySpawnCategoryTest {
private IrisPlatform previous;
@Before
public void saveBinding() {
previous = IrisPlatforms.getOrNull();
IrisPlatforms.unbind();
}
@After
public void restoreBinding() {
IrisPlatforms.unbind();
if (previous != null) {
IrisPlatforms.bind(previous);
}
}
@Test
public void unavailableAndUnknownTypesUseMisc() {
IrisEntity entity = new IrisEntity().setType("minecraft:zombie");
assertEquals("misc", entity.spawnCategory());
IrisPlatform platform = mock(IrisPlatform.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(platform.registries()).thenReturn(registries);
IrisPlatforms.bind(platform);
assertEquals("misc", entity.spawnCategory());
assertEquals("misc", new IrisEntity().setType(null).spawnCategory());
}
@Test
public void currentRegistryCategoryIsNeverStuckAcrossPlatformRebinding() {
IrisEntity entity = new IrisEntity().setType("minecraft:zombie");
bind("monster");
assertEquals("monster", entity.spawnCategory());
IrisPlatforms.unbind();
bind("creature");
assertEquals("creature", entity.spawnCategory());
}
@Test
public void acceptedMixedCaseAndWhitespaceTypesKeepNativeCategory() {
bind("monster");
assertEquals("monster", new IrisEntity().setType(" MINECRAFT:ZoMbIe ").spawnCategory());
}
private static void bind(String category) {
IrisPlatform platform = mock(IrisPlatform.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
NativeEntityType type = mock(NativeEntityType.class);
when(platform.registries()).thenReturn(registries);
when(registries.entity("minecraft:zombie")).thenReturn(type);
when(type.spawnCategory()).thenReturn(category);
IrisPlatforms.bind(platform);
}
}
@@ -5,11 +5,14 @@ import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.SeedManager;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.math.Vector3d;
import art.arcane.volmlib.util.matter.slices.MarkerMatter;
import org.bukkit.Chunk;
import org.bukkit.HeightMap;
import org.bukkit.Location;
@@ -36,6 +39,10 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.atMost;
public class IrisEntitySpawnTest {
@Test
@@ -43,6 +50,235 @@ public class IrisEntitySpawnTest {
assertNull(IrisEntitySpawn.selectCaveSpawnLocation(new KList<>(), null, new RNG(1L)));
}
@Test
public void caveFloorMarkerSpawnsAtFirstAirBlockInNegativeWorldHeight() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, -50, -50));
MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(J::isFolia).thenReturn(false);
when(fixture.world.getMinHeight()).thenReturn(-64);
EngineMantle mantle = mock(EngineMantle.class);
when(fixture.engine.getMantle()).thenReturn(mantle);
when(mantle.findMarkers(0, 0, MarkerMatter.CAVE_FLOOR))
.thenReturn(new KList<>(new IrisPosition(7, -49, 9)));
fixture.platform.when(() -> BukkitPlatform.toLocation(any(IrisPosition.class), eq(fixture.world)))
.thenAnswer(invocation -> {
IrisPosition position = invocation.getArgument(0);
return new Location(fixture.world, position.getX(), position.getY(), position.getZ());
});
assertEquals(3, fixture.spawn(IrisSpawnGroup.CAVE));
assertEquals(List.of(new Location(fixture.world, 7.5, -49, 9.5),
new Location(fixture.world, 7.5, -49, 9.5),
new Location(fixture.world, 7.5, -49, 9.5)), fixture.positions);
}
}
@Test
public void foliaCaveSpawnUsesOwnedLiveBlocksAndNegativeWorldHeights() {
Engine engine = mock(Engine.class);
World world = mock(World.class);
Chunk chunk = mock(Chunk.class);
when(chunk.getWorld()).thenReturn(world);
when(chunk.getX()).thenReturn(-2);
when(chunk.getZ()).thenReturn(3);
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(0);
when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
int y = invocation.getArgument(1);
Block block = mock(Block.class);
when(block.getType()).thenReturn(y == -49 || y == -48 ? Material.CAVE_AIR : Material.STONE);
when(block.isSolid()).thenReturn(y != -49 && y != -48);
return block;
});
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(J::isFolia).thenReturn(true);
scheduler.when(() -> J.isOwnedByCurrentRegion(world, -2, 3)).thenReturn(true);
Location location = IrisEntitySpawn.findCaveSpawnLocation(engine, chunk, new RNG(1L), IrisSurface.LAND);
assertEquals(-49, location.getBlockY());
assertEquals(-2, location.getBlockX() >> 4);
assertEquals(3, location.getBlockZ() >> 4);
verify(engine, never()).getMantle();
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
}
}
@Test
public void foliaCaveGroupSpawnsEntitiesInsideLiveCaveAir() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78));
MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(J::isFolia).thenReturn(true);
scheduler.when(() -> J.isOwnedByCurrentRegion(fixture.world, 0, 0)).thenReturn(true);
when(fixture.world.getMinHeight()).thenReturn(-64);
when(fixture.world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(0);
when(fixture.world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
int y = invocation.getArgument(1);
Block block = mock(Block.class);
boolean air = y == -49 || y == -48;
when(block.getType()).thenReturn(air ? Material.CAVE_AIR : Material.STONE);
when(block.isSolid()).thenReturn(!air);
return block;
});
when(fixture.chunk.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
fixture.world.getBlockAt(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)));
fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE))
.thenReturn(new Vector3d(0.6, 1.95, 0.6));
assertEquals(3, fixture.spawn(IrisSpawnGroup.CAVE));
assertTrue(fixture.positions.stream().allMatch(position -> position.getY() == -49D));
verify(fixture.engine, never()).getMantle();
}
}
@Test
public void foliaCaveScanDoesNotReadUnownedChunks() {
World world = mock(World.class);
Chunk chunk = mock(Chunk.class);
when(chunk.getWorld()).thenReturn(world);
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(J::isFolia).thenReturn(true);
assertNull(IrisEntitySpawn.findCaveSpawnLocation(mock(Engine.class), chunk, new RNG(1L), IrisSurface.LAND));
verify(chunk, never()).getBlock(anyInt(), anyInt(), anyInt());
}
}
@Test
public void caveScanIsBoundedWhenNoSafeFloorExists() {
World world = mock(World.class);
Chunk chunk = mock(Chunk.class);
when(chunk.getWorld()).thenReturn(world);
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(4096);
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(4000);
Block solid = mock(Block.class);
when(solid.getType()).thenReturn(Material.STONE);
when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(solid);
assertNull(IrisEntitySpawn.findLiveCaveSpawnLocation(chunk, new RNG(1L), IrisSurface.LAND));
verify(chunk, times(1024)).getBlock(anyInt(), anyInt(), anyInt());
verify(world, atMost(8)).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR));
}
@Test
public void foliaFluidCaveGroupsSpawnInsideMatchingNegativeHeightFluidWithoutSolidSupport() {
for (IrisSurface surface : List.of(IrisSurface.WATER, IrisSurface.LAVA)) {
Material fluid = surface == IrisSurface.WATER ? Material.WATER : Material.LAVA;
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(surface, fluid, -65, -1));
MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(J::isFolia).thenReturn(true);
scheduler.when(() -> J.isOwnedByCurrentRegion(fixture.world, 0, 0)).thenReturn(true);
when(fixture.world.getMinHeight()).thenReturn(-64);
when(fixture.world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR)))
.thenReturn(0);
when(fixture.chunk.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
fixture.world.getBlockAt(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)));
assertEquals(surface.name(), 3, fixture.spawn(IrisSpawnGroup.CAVE));
for (Location position : fixture.positions) {
assertTrue(position.getY() < 0);
assertEquals(fluid, position.getBlock().getType());
assertEquals(fluid, position.clone().subtract(0, 1, 0).getBlock().getType());
}
verify(fixture.engine, never()).getMantle();
}
}
}
@Test
public void fluidCaveScannerRejectsOtherFluidsAndDryFloorScannerRejectsFluid() {
World world = mock(World.class);
Chunk chunk = mock(Chunk.class);
when(chunk.getWorld()).thenReturn(world);
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(0);
Block water = mock(Block.class);
when(water.getType()).thenReturn(Material.WATER);
when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(water);
assertNull(IrisEntitySpawn.findLiveCaveSpawnLocation(chunk, new RNG(1L), IrisSurface.LAVA));
assertNull(IrisEntitySpawn.findLiveCaveSpawnLocation(chunk, new RNG(1L), IrisSurface.LAND));
}
@Test
public void landEntityBodyRejectsCeilingAndPartialWidthObstructions() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE))
.thenReturn(new Vector3d(1.2, 1.95, 0.6));
Block stone = mock(Block.class);
when(stone.getType()).thenReturn(Material.STONE);
when(stone.isSolid()).thenReturn(true);
when(fixture.world.getBlockAt(-1, 79, 0)).thenReturn(stone);
assertNull(fixture.entry.spawn(fixture.engine, new Location(fixture.world, 0, 79, 0)));
assertTrue(fixture.positions.isEmpty());
}
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE))
.thenReturn(new Vector3d(0.6, 2.01, 0.6));
Block stone = mock(Block.class);
when(stone.getType()).thenReturn(Material.STONE);
when(fixture.world.getBlockAt(0, 81, 0)).thenReturn(stone);
assertNull(fixture.entry.spawn(fixture.engine, new Location(fixture.world, 0, 79, 0)));
assertTrue(fixture.positions.isEmpty());
}
}
@Test
public void ambientBatchCannotExceedRemainingCategoryCapacity() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
fixture.remainingCapacity = 1;
assertEquals(1, fixture.spawn(IrisSpawnGroup.NORMAL));
assertEquals(1, fixture.positions.size());
}
}
@Test
public void saturatedAmbientCategorySkipsEverySpawnAttempt() {
for (int capacity : new int[]{0, -1}) {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
fixture.remainingCapacity = capacity;
assertEquals(0, fixture.spawn(IrisSpawnGroup.NORMAL));
assertTrue(fixture.positions.isEmpty());
verify(fixture.world, never()).getHighestBlockYAt(anyInt(), anyInt(), any(HeightMap.class));
}
}
}
@Test
public void landEntityBodiesAcceptEveryAirMaterial() {
for (Material material : List.of(Material.AIR, Material.CAVE_AIR, Material.VOID_AIR)) {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
fixture.airMaterial = material;
assertEquals(material.name(), 3, fixture.spawn(IrisSpawnGroup.NORMAL));
}
}
}
@Test
public void foliaWideEntityRejectsUnownedBoundingChunksBeforeReadingThem() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78));
MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(J::isFolia).thenReturn(true);
scheduler.when(() -> J.isOwnedByCurrentRegion(fixture.world, 0, 0)).thenReturn(true);
fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE))
.thenReturn(new Vector3d(4D, 1D, 4D));
assertNull(fixture.entry.spawn(fixture.engine, new Location(fixture.world, 0, 79, 0)));
verify(fixture.world, never()).getBlockAt(eq(-2), anyInt(), anyInt());
assertTrue(fixture.positions.isEmpty());
}
}
@Test
public void mixedNormalSpawnerPlacesEveryTadpoleInsideOneBlockDeepWater() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.WATER, Material.WATER, 77, 78))) {
@@ -81,7 +317,7 @@ public class IrisEntitySpawnTest {
public void mixedNormalSpawnerRetainsLandEntityPlacement() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
assertEquals(3, fixture.spawn(IrisSpawnGroup.NORMAL));
assertTrue(fixture.positions.stream().allMatch(position -> position.getY() == 79.5));
assertTrue(fixture.positions.stream().allMatch(position -> position.getY() == 79));
}
}
@@ -158,8 +394,8 @@ public class IrisEntitySpawnTest {
fixture.entry.spawn(fixture.engine, location);
fixture.entry.spawn(fixture.engine, location);
assertEquals(new Location(fixture.world, 0, 79, 0), location);
assertEquals(List.of(new Location(fixture.world, 0.5, 79.5, 0.5),
new Location(fixture.world, 0.5, 79.5, 0.5)), fixture.positions);
assertEquals(List.of(new Location(fixture.world, 0.5, 79, 0.5),
new Location(fixture.world, 0.5, 79, 0.5)), fixture.positions);
}
}
@@ -173,6 +409,8 @@ public class IrisEntitySpawnTest {
private final IrisEntitySpawn entry = spy(new IrisEntitySpawn());
private final List<Location> positions = new ArrayList<>();
private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
private Material airMaterial = Material.AIR;
private int remainingCapacity = Integer.MAX_VALUE;
private SpawnFixture(SpawnOptions options) {
IrisSurface surface = options.surface();
@@ -184,12 +422,13 @@ public class IrisEntitySpawnTest {
when(definition.getBukkitType()).thenReturn(EntityType.TADPOLE);
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
when(chunk.getWorld()).thenReturn(world);
when(world.getMaxHeight()).thenReturn(256);
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(floor);
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.WORLD_SURFACE))).thenReturn(top);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
int y = invocation.getArgument(1);
Block block = mock(Block.class);
when(block.getType()).thenReturn(y <= floor ? Material.STONE : y <= top ? fluid : Material.AIR);
when(block.getType()).thenReturn(y <= floor ? Material.STONE : y <= top ? fluid : airMaterial);
when(block.isSolid()).thenReturn(y <= floor);
return block;
});
@@ -213,7 +452,7 @@ public class IrisEntitySpawnTest {
private int spawn(IrisSpawnGroup group) {
entry.setReferenceSpawner(new IrisSpawner().setGroup(group));
return entry.spawn(engine, chunk, new RNG(1337L));
return entry.spawn(engine, chunk, new RNG(1337L), remainingCapacity);
}
@Override