From a765e76aa392163407545b28845de53179fcfe67 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 2 Oct 2026 16:55:52 -0400 Subject: [PATCH] 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. --- .../natural-spawning-fixture/build.gradle | 35 ++ .../resources/plugin.yml | 12 + .../natural-spawning-fixture/settings.gradle | 1 + .../iris/fixture/NaturalSpawningFixture.java | 472 ++++++++++++++++++ .../biomes/cave-zombies.json | 26 + .../natural-spawning-pack/biomes/frogs.json | 26 + .../dimensions/spawn-qa.json | 28 ++ .../entities/qa/frog.json | 8 + .../entities/qa/zombie.json | 8 + .../generators/flat.json | 15 + .../natural-spawning-pack/regions/flat.json | 12 + .../spawners/qa/cave-zombies.json | 16 + .../spawners/qa/frogs.json | 12 + .../src/test/gameplay/natural-spawning.mjs | 41 ++ .../iris/modded/ModdedEntitySpawner.java | 12 +- .../iris/modded/ModdedWorldManager.java | 33 +- .../modded/ModdedEntityPersistenceTest.java | 79 ++- .../modded/ModdedEntitySpawnerParityTest.java | 32 +- .../modded/ModdedWaterEntitySpawnTest.java | 98 +++- .../modded/NativeSpawnPopulationTest.java | 86 ++++ .../iris/configuration/IrisSettings.java | 2 +- .../runtime/WorldEntitySpawner.java | 49 +- .../arcane/iris/world/entity/IrisEntity.java | 31 +- .../iris/world/entity/IrisEntitySpawn.java | 111 +++- .../arcane/iris/world/entity/IrisSpawner.java | 18 +- .../WorldEntitySpawnerAdmissionTest.java | 87 ++++ .../WorldEntitySpawnerPopulationTest.java | 66 +++ .../schema/SchemaBuilderIdentityTest.java | 2 +- .../entity/IrisEntityPersistenceTest.java | 63 +++ .../entity/IrisEntitySpawnCategoryTest.java | 69 +++ .../world/entity/IrisEntitySpawnTest.java | 249 ++++++++- 31 files changed, 1712 insertions(+), 87 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/build.gradle create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/resources/plugin.yml create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/settings.gradle create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/src/art/arcane/iris/fixture/NaturalSpawningFixture.java create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/cave-zombies.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/frogs.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/dimensions/spawn-qa.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/frog.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/zombie.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/generators/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/regions/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/cave-zombies.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/frogs.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/natural-spawning.mjs create mode 100644 adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnPopulationTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerAdmissionTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerPopulationTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/entity/IrisEntityPersistenceTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/entity/IrisEntitySpawnCategoryTest.java diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/build.gradle b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/build.gradle new file mode 100644 index 000000000..4341ce98f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/build.gradle @@ -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' +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/resources/plugin.yml b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/resources/plugin.yml new file mode 100644 index 000000000..ed227ba5d --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/resources/plugin.yml @@ -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 diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/settings.gradle b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/settings.gradle new file mode 100644 index 000000000..38fdedbaf --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'iris-natural-spawning-fixture' diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/src/art/arcane/iris/fixture/NaturalSpawningFixture.java b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/src/art/arcane/iris/fixture/NaturalSpawningFixture.java new file mode 100644 index 000000000..ef0e7e285 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-fixture/src/art/arcane/iris/fixture/NaturalSpawningFixture.java @@ -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 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> 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 retainChunk(World world, int chunkX, int chunkZ) { + CompletableFuture 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 categories = new TreeMap<>(); + Map 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) { + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/cave-zombies.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/cave-zombies.json new file mode 100644 index 000000000..2931bd455 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/cave-zombies.json @@ -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" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/frogs.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/frogs.json new file mode 100644 index 000000000..e78dfd0f9 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/frogs.json @@ -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" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/dimensions/spawn-qa.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/dimensions/spawn-qa.json new file mode 100644 index 000000000..aa25e658f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/dimensions/spawn-qa.json @@ -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" + } + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/frog.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/frog.json new file mode 100644 index 000000000..b7e7f65e7 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/frog.json @@ -0,0 +1,8 @@ +{ + "type": "minecraft:frog", + "reason": "NATURAL", + "surface": "LAND", + "ai": false, + "aware": false, + "gravity": false +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/zombie.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/zombie.json new file mode 100644 index 000000000..2fe37095f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/zombie.json @@ -0,0 +1,8 @@ +{ + "type": "minecraft:zombie", + "reason": "NATURAL", + "surface": "LAND", + "ai": false, + "aware": false, + "gravity": false +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/generators/flat.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/generators/flat.json new file mode 100644 index 000000000..7cd98b811 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/generators/flat.json @@ -0,0 +1,15 @@ +{ + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "seed": 310, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/regions/flat.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/regions/flat.json new file mode 100644 index 000000000..0f4206c62 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/regions/flat.json @@ -0,0 +1,12 @@ +{ + "name": "Flat", + "landBiomes": [ + "frogs" + ], + "seaBiomes": [ + "frogs" + ], + "shoreBiomes": [ + "frogs" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/cave-zombies.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/cave-zombies.json new file mode 100644 index 000000000..7d992b8a5 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/cave-zombies.json @@ -0,0 +1,16 @@ +{ + "group": "CAVE", + "maxEntitiesPerChunk": 2, + "allowedLightLevels": { + "min": 0, + "max": 0 + }, + "spawns": [ + { + "entity": "qa/zombie", + "rarity": 1, + "minSpawns": 7, + "maxSpawns": 7 + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/frogs.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/frogs.json new file mode 100644 index 000000000..54f83ce05 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/frogs.json @@ -0,0 +1,12 @@ +{ + "group": "NORMAL", + "maxEntitiesPerChunk": 16, + "spawns": [ + { + "entity": "qa/frog", + "rarity": 1, + "minSpawns": 7, + "maxSpawns": 7 + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning.mjs b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning.mjs new file mode 100644 index 000000000..473031dcf --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning.mjs @@ -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 + }) + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java index 27c61977b..90d9a0483 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java @@ -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 + "'"); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java index 632055c63..4c9757d00 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java @@ -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 chunkMobs = NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ); KList 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 pool, KList spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, int chunkMobs, boolean initial) { + private void collectSpawns(KList pool, KList spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map 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 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 diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityPersistenceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityPersistenceTest.java index 9dc246c4a..e2d892e1a 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityPersistenceTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityPersistenceTest.java @@ -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 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 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 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 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; + } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnerParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnerParityTest.java index 515fd1e65..545ef8bbf 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnerParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnerParityTest.java @@ -21,7 +21,7 @@ import static org.junit.Assert.assertTrue; public class ModdedEntitySpawnerParityTest { @Test - public void collisionCheckUsesPaperInclusiveIntegerDimensions() { + public void landBodyFitsTwoBlockCaveAndRejectsSecondBlockCeiling() { Set 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 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 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 diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java index 8578a381c..6fb82bdf3 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java @@ -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 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(); + } + } } diff --git a/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnPopulationTest.java b/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnPopulationTest.java new file mode 100644 index 000000000..ce4b94dff --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnPopulationTest.java @@ -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 entities = List.of(frog, frog, frog, zombie, neighbor, player); + when(level.getEntities(isNull(Entity.class), any(AABB.class), any())) + .thenAnswer(invocation -> { + Predicate 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; + } +} diff --git a/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java b/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java index ed692a86a..ab140edab 100644 --- a/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java @@ -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; diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/WorldEntitySpawner.java b/core/src/main/java/art/arcane/iris/generation/runtime/WorldEntitySpawner.java index bfa49f5bd..4180d97f3 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/WorldEntitySpawner.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/WorldEntitySpawner.java @@ -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 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 { - private final Entity[] entities; - private transient int index = 0; - private transient int count = 0; + static final class ChunkCounter { + private final Map 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); } } } diff --git a/core/src/main/java/art/arcane/iris/world/entity/IrisEntity.java b/core/src/main/java/art/arcane/iris/world/entity/IrisEntity.java index 6baff17b4..93ed7823d 100644 --- a/core/src/main/java/art/arcane/iris/world/entity/IrisEntity.java +++ b/core/src/main/java/art/arcane/iris/world/entity/IrisEntity.java @@ -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 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)); } diff --git a/core/src/main/java/art/arcane/iris/world/entity/IrisEntitySpawn.java b/core/src/main/java/art/arcane/iris/world/entity/IrisEntitySpawn.java index f6ed7a04b..d898e7c3e 100644 --- a/core/src/main/java/art/arcane/iris/world/entity/IrisEntitySpawn.java +++ b/core/src/main/java/art/arcane/iris/world/entity/IrisEntitySpawn.java @@ -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 = new AtomicCache<>(); private final transient AtomicCache 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 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 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; } } diff --git a/core/src/main/java/art/arcane/iris/world/entity/IrisSpawner.java b/core/src/main/java/art/arcane/iris/world/entity/IrisSpawner.java index adb767637..bd2535069 100644 --- a/core/src/main/java/art/arcane/iris/world/entity/IrisSpawner.java +++ b/core/src/main/java/art/arcane/iris/world/entity/IrisSpawner.java @@ -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 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 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()) { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerAdmissionTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerAdmissionTest.java new file mode 100644 index 000000000..7edd9bd1a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerAdmissionTest.java @@ -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 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 registries = mockStatic(RegistryAccess.class); + MockedStatic 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)); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerPopulationTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerPopulationTest.java new file mode 100644 index 000000000..17b3b9247 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerPopulationTest.java @@ -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 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 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)); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index e06d2568a..1760076d1 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -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> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/world/entity/IrisEntityPersistenceTest.java b/core/src/test/java/art/arcane/iris/world/entity/IrisEntityPersistenceTest.java new file mode 100644 index 000000000..f8abeeedf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/entity/IrisEntityPersistenceTest.java @@ -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()); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/entity/IrisEntitySpawnCategoryTest.java b/core/src/test/java/art/arcane/iris/world/entity/IrisEntitySpawnCategoryTest.java new file mode 100644 index 000000000..4bd219c10 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/entity/IrisEntitySpawnCategoryTest.java @@ -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); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/entity/IrisEntitySpawnTest.java b/core/src/test/java/art/arcane/iris/world/entity/IrisEntitySpawnTest.java index dec397689..5e0f76665 100644 --- a/core/src/test/java/art/arcane/iris/world/entity/IrisEntitySpawnTest.java +++ b/core/src/test/java/art/arcane/iris/world/entity/IrisEntitySpawnTest.java @@ -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 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 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 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 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 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 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 positions = new ArrayList<>(); private final MockedStatic 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