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 index ef0e7e285..7c7e30478 100644 --- 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 @@ -6,6 +6,11 @@ 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.IrisMarker; +import art.arcane.iris.world.entity.EntitySpawnSeed; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.iris.pack.value.IrisPosition; +import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.world.entity.IrisEntitySpawn; import art.arcane.iris.world.entity.IrisEntity; import art.arcane.iris.platform.bukkit.BukkitPlatform; @@ -28,6 +33,7 @@ import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -38,9 +44,13 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Collections; +import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.CompletableFuture; import java.util.logging.Level; @@ -83,7 +93,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener @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"); + notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|seeded|completion|surface|cave"); return true; } try { @@ -93,6 +103,8 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener case "zombies" -> runRegion(sender, () -> spawnZombies(sender)); case "stress" -> runRegion(sender, () -> stress(sender)); case "check" -> runRegion(sender, () -> check(sender)); + case "seeded" -> seeded(sender); + case "completion" -> completion(sender); case "surface" -> view(sender, 67D); case "cave" -> view(sender, -46D); default -> throw new IllegalArgumentException("Unknown spawning operation"); @@ -147,6 +159,9 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener private void configureWorld(CommandSender sender, World world) { try { world.setDifficulty(Difficulty.NORMAL); + world.setTime(6000L); + world.setStorm(false); + world.setThundering(false); world.setGameRule(GameRules.SPAWN_MOBS, true); List> chunks = new ArrayList<>(); for (int chunkX = -1; chunkX <= 1; chunkX++) { @@ -176,6 +191,11 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> { try { chunk.addPluginChunkTicket(this); + for (Entity entity : chunk.getEntities()) { + if (entity instanceof LivingEntity && !(entity instanceof Player)) { + entity.remove(); + } + } retained.complete(null); } catch (Throwable error) { retained.completeExceptionally(error); @@ -244,12 +264,218 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener } } + private void completion(CommandSender sender) { + World world = requireWorld(); + Bukkit.getGlobalRegionScheduler().execute(this, () -> { + world.setGameRule(GameRules.SPAWN_MOBS, false); + runRegion(sender, () -> beginCompletion(sender, world)); + }); + } + + private void beginCompletion(CommandSender sender, World world) { + try { + Engine engine = IrisToolbelt.access(world).getEngine(); + Object manager = engine.getWorldManager(); + Field field = manager.getClass().getDeclaredField("chunkMaintenance"); + field.setAccessible(true); + Object maintenance = field.get(manager); + Method run = maintenance.getClass().getDeclaredMethod("runInitialSpawn", World.class, int.class, int.class, Runnable.class); + run.setAccessible(true); + Field pending = maintenance.getClass().getDeclaredField("markerFlagQueue"); + pending.setAccessible(true); + AtomicInteger callbacks = new AtomicInteger(); + Runnable callback = () -> { + require(Bukkit.isOwnedByCurrentRegion(world, 0, 0), "Initial callback ran outside its owning region"); + callbacks.incrementAndGet(); + Chunk chunk = world.getChunkAt(0, 0); + invokeAmbient(runtimeSpawn(chunk, "seeded"), chunk, true); + }; + run.invoke(maintenance, world, 0, 0, callback); + awaitSkippedInitial(sender, world, new CompletionRun(engine, maintenance, run, pending, callback, callbacks), 0); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not start initial completion test", failure); + } + } + + private void awaitSkippedInitial(CommandSender sender, World world, CompletionRun state, int attempt) { + Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> { + try { + require(state.callbacks().get() == 0, "Disabled mob spawning consumed the initial pass"); + if (!((Set) state.pending().get(state.maintenance())).isEmpty()) { + require(attempt < 100, "Skipped initial pass did not release its pending state"); + awaitSkippedInitial(sender, world, state, attempt + 1); + return; + } + Bukkit.getGlobalRegionScheduler().execute(this, () -> { + world.setGameRule(GameRules.SPAWN_MOBS, true); + runRegion(sender, () -> retryInitialCompletion(sender, world, state)); + }); + } catch (Throwable failure) { + fail(sender, failure); + } + }, 2L); + } + + private void retryInitialCompletion(CommandSender sender, World world, CompletionRun state) { + try { + state.run().invoke(state.maintenance(), world, 0, 0, state.callback()); + state.run().invoke(state.maintenance(), world, 0, 0, state.callback()); + awaitInitialCompletion(sender, world, state, 0); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not retry initial spawning", failure); + } + } + + private void awaitInitialCompletion(CommandSender sender, World world, CompletionRun state, int attempt) { + Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> { + try { + if (state.callbacks().get() == 0 || !((Set) state.pending().get(state.maintenance())).isEmpty()) { + require(attempt < 100, "Initial spawning did not complete after retry"); + awaitInitialCompletion(sender, world, state, attempt + 1); + return; + } + require(state.callbacks().get() == 1, "Concurrent initial requests spawned duplicate populations"); + state.run().invoke(state.maintenance(), world, 0, 0, state.callback()); + Bukkit.getAsyncScheduler().runNow(this, ignored -> { + try { + require(state.engine().getMantle().getMantle().hasFlag(0, 0, MantleFlag.INITIAL_SPAWNED_MARKER), "Initial completion was not persisted"); + require(state.callbacks().get() == 1, "Completed initial pass ran again"); + evidence(sender, "SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true"); + } catch (Throwable failure) { + fail(sender, failure); + } + }); + } catch (Throwable failure) { + fail(sender, failure); + } + }, 2L); + } + + private void seeded(CommandSender sender) { + requireWorld(); + List> baseline = new ArrayList<>(); + sampleInitial(0, false).thenCompose(first -> { + baseline.add(first); + return sampleInitial(1, false); + }).thenCompose(second -> { + baseline.add(second); + return sampleInitial(1, false); + }).thenCompose(repeated -> { + require(baseline.get(1).equals(repeated), "Second chunk changed after reversed generation order"); + return sampleInitial(0, false); + }).thenCompose(repeated -> { + require(baseline.get(0).equals(repeated), "First chunk changed after reversed generation order"); + return sampleInitial(0, true); + }).thenCompose(marker -> { + baseline.add(marker); + return sampleInitial(1, true); + }).thenCompose(marker -> { + baseline.add(marker); + return sampleInitial(1, true); + }).thenCompose(repeated -> { + require(baseline.get(3).equals(repeated), "Second marker changed after reversed generation order"); + return sampleInitial(0, true); + }).whenComplete((repeated, failure) -> { + if (failure != null) { + fail(sender, failure); + return; + } + try { + require(baseline.get(2).equals(repeated), "First marker changed after reversed generation order"); + evidence(sender, "SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true"); + } catch (Throwable error) { + fail(sender, error); + } + }); + } + + private CompletableFuture> sampleInitial(int chunkX, boolean marker) { + CompletableFuture> result = new CompletableFuture<>(); + World world = requireWorld(); + Bukkit.getRegionScheduler().execute(this, world, chunkX, 0, () -> { + try { + Chunk chunk = world.getChunkAt(chunkX, 0); + for (Entity entity : chunk.getEntities()) { + if (entity instanceof LivingEntity && !(entity instanceof Player)) { + entity.remove(); + } + } + 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); + } + } + } + RuntimeSpawn spawn = runtimeSpawn(chunk, "seeded"); + IrisSpawner definition = spawn.environment().data().getSpawnerLoader().load("qa/seeded"); + require(!definition.canSpawn(spawn.engine(), chunkX, 0), "Ongoing weather/time gate unexpectedly allowed spawning"); + for (int i = 0; i < 100 + chunkX; i++) { + RNG.r.nextLong(); + } + ambientInvocation.set(true); + try { + for (int i = 0; i < 5; i++) { + LivingEntity witness = (LivingEntity) world.spawnEntity(new Location(world, (chunkX << 4) + 8.5, 65, 8.5), EntityType.ZOMBIE); + witness.setCustomName("Population witness"); + witness.setAI(false); + witness.setGravity(false); + } + } finally { + ambientInvocation.remove(); + } + if (marker) { + invokeInitialMarker(spawn, definition, new IrisPosition((chunkX << 4) + 5, 64, 5)); + } else { + invokeAmbient(spawn, chunk, true); + } + List snapshot = new ArrayList<>(); + for (Entity entity : chunk.getEntities()) { + String name = entity.getCustomName(); + if (!(entity instanceof LivingEntity living) || name == null || !name.startsWith("Seeded ")) { + continue; + } + ItemStack hand = living.getEquipment().getItemInMainHand(); + ItemStack helmet = living.getEquipment().getHelmet(); + require(hand.hasItemMeta() && hand.getItemMeta().hasAttributeModifiers(), "Spawned gear has no authored attributes"); + require(helmet != null && helmet.getType() == Material.IRON_HELMET, "Spawned helmet was not authored equipment"); + Location at = entity.getLocation(); + snapshot.add(entity.getType() + ":" + at.getX() + ":" + at.getY() + ":" + at.getZ() + + ":" + HexFormat.of().formatHex(hand.serializeAsBytes()) + + ":" + HexFormat.of().formatHex(helmet.serializeAsBytes())); + } + require(snapshot.size() >= 2 && snapshot.size() <= 4, "Initial population was blocked by runtime gates or cap: " + snapshot.size()); + Collections.sort(snapshot); + result.complete(List.copyOf(snapshot)); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }); + return result; + } + + private void invokeInitialMarker(RuntimeSpawn spawn, IrisSpawner definition, IrisPosition position) { + ambientInvocation.set(true); + try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) { + Method method = spawn.spawner().getClass().getDeclaredMethod("spawnMarker", IrisPosition.class, IrisSpawner.class, IrisMarker.class, RNG.class, boolean.class); + method.setAccessible(true); + method.invoke(spawn.spawner(), position, definition, null, EntitySpawnSeed.marker(spawn.engine().getSeedManager().getEntity(), position.getX(), position.getY(), position.getZ()), true); + } catch (InvocationTargetException failure) { + throw new IllegalStateException("Initial marker spawning failed", failure.getCause()); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not invoke initial marker spawning", failure); + } finally { + ambientInvocation.remove(); + } + } + 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); + invokeAmbient(spawn, chunk, false); require(count(chunk, EntityType.FROG) <= FROG_CAPACITY, "Frog batch exceeded its category capacity"); } require(count(chunk, EntityType.FROG) == FROG_CAPACITY, @@ -266,7 +492,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran"); RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies"); getLogger().info(spawnDiagnostics(spawn, chunk)); - invokeAmbient(spawn, chunk); + invokeAmbient(spawn, chunk, false); firstZombieBatch = count(chunk, EntityType.ZOMBIE); require(firstZombieBatch == ZOMBIE_CAPACITY, "Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk)); @@ -280,7 +506,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener Chunk chunk = requireWorld().getChunkAt(0, 0); RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies"); for (int attempt = 0; attempt < 50; attempt++) { - invokeAmbient(spawn, chunk); + invokeAmbient(spawn, chunk, false); require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity"); } evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE)); @@ -371,10 +597,10 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener return diagnostics.toString(); } - private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk) { + private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk, boolean initial) { ambientInvocation.set(true); try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) { - spawn.method().invoke(spawn.spawner(), chunk, false, spawn.environment()); + spawn.method().invoke(spawn.spawner(), chunk, initial, spawn.environment()); } catch (InvocationTargetException failure) { throw new IllegalStateException("Iris ambient spawning failed", failure.getCause()); } catch (ReflectiveOperationException failure) { @@ -467,6 +693,9 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener } } + private record CompletionRun(Engine engine, Object maintenance, Method run, Field pending, Runnable callback, AtomicInteger callbacks) { + } + private record RuntimeSpawn(Engine engine, Object spawner, Method method, BiomeEnvironment environment) { } } diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/seeded.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/seeded.json new file mode 100644 index 000000000..d0b9b5b8d --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/biomes/seeded.json @@ -0,0 +1,26 @@ +{ + "name": "Seeded population", + "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/seeded" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/seeded-husk.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/seeded-husk.json new file mode 100644 index 000000000..5d1267ffd --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/seeded-husk.json @@ -0,0 +1,29 @@ +{ + "type": "minecraft:husk", + "reason": "NATURAL", + "surface": "LAND", + "ai": false, + "aware": false, + "gravity": false, + "customName": "Seeded husk", + "invulnerable": true, + "helmet": { + "type": "IRON_HELMET", + "minDurability": 0.1, + "maxDurability": 0.9 + }, + "mainHand": { + "type": "IRON_SWORD", + "minDurability": 0.1, + "maxDurability": 0.9, + "attributes": [ + { + "name": "seeded_damage", + "attribute": "minecraft:attack_damage", + "minAmount": 1.25, + "maxAmount": 6.75, + "chance": 1 + } + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/seeded-zombie.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/seeded-zombie.json new file mode 100644 index 000000000..b7ddd79c2 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/entities/qa/seeded-zombie.json @@ -0,0 +1,29 @@ +{ + "type": "minecraft:zombie", + "reason": "NATURAL", + "surface": "LAND", + "ai": false, + "aware": false, + "gravity": false, + "customName": "Seeded zombie", + "invulnerable": true, + "helmet": { + "type": "IRON_HELMET", + "minDurability": 0.1, + "maxDurability": 0.9 + }, + "mainHand": { + "type": "IRON_SWORD", + "minDurability": 0.1, + "maxDurability": 0.9, + "attributes": [ + { + "name": "seeded_damage", + "attribute": "minecraft:attack_damage", + "minAmount": 1.25, + "maxAmount": 6.75, + "chance": 1 + } + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/seeded.json b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/seeded.json new file mode 100644 index 000000000..04e77defa --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning-pack/spawners/qa/seeded.json @@ -0,0 +1,33 @@ +{ + "group": "NORMAL", + "maxEntitiesPerChunk": 4, + "allowedLightLevels": { + "min": 0, + "max": 0 + }, + "weather": "DOWNFALL", + "timeBlock": { + "startHour": 20, + "endHour": 3 + }, + "maximumRate": { + "amount": 1, + "per": { + "days": 1 + } + }, + "initialSpawns": [ + { + "entity": "qa/seeded-zombie", + "rarity": 1, + "minSpawns": 2, + "maxSpawns": 4 + }, + { + "entity": "qa/seeded-husk", + "rarity": 2, + "minSpawns": 2, + "maxSpawns": 4 + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning.mjs b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning.mjs index 473031dcf..9fbabf8fe 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/natural-spawning.mjs +++ b/adapters/bukkit/plugin/src/test/gameplay/natural-spawning.mjs @@ -8,7 +8,8 @@ export default { 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) + const entities = name => Object.values(context.bot.entities).filter(entity => entity.name === name + && entity.position.x >= 0 && entity.position.x < 16 && entity.position.z >= 0 && entity.position.z < 16) 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') diff --git a/adapters/bukkit/plugin/src/test/gameplay/seeded-entities.mjs b/adapters/bukkit/plugin/src/test/gameplay/seeded-entities.mjs new file mode 100644 index 000000000..72da6d963 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/seeded-entities.mjs @@ -0,0 +1,24 @@ +export default { + name: 'iris-seeded-entities', + description: 'Replay initial chunk and marker populations in reversed order, comparing positions and complete equipment data.', + async run(context) { + const command = async (operation, expected, timeoutMs) => { + const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs) + context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response) + } + await context.step('prepare a controlled Iris world', async () => { + await command('setup', 'SPAWN_QA_SETUP', 180000) + }) + await context.step('replay initial mobs despite unrelated randomness and live spawn restrictions', async () => { + await command('seeded', 'SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true', 60000) + await context.waitUntil(() => Object.values(context.bot.entities).some(entity => entity.name === 'zombie' || entity.name === 'husk'), { + label: 'Generated mobs reached the client', timeoutMs: 10000, intervalMs: 100 + }) + context.report.seededEntities = { chunks: 2, markers: 2, repeats: 2, positions: true, equipmentBytes: true, attributeIds: true } + }) + await context.step('retry a skipped initial pass and persist completion once', async () => { + await command('completion', 'SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true', 30000) + context.report.seededEntities.completionRetry = true + }) + } +} 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 4c9757d00..09b02654a 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 @@ -44,6 +44,8 @@ import art.arcane.iris.pack.value.IrisPosition; import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.world.entity.IrisSpawnGroup; import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.iris.world.entity.EntitySpawnSeed; +import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext; import art.arcane.iris.generation.decoration.IrisSurface; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; @@ -61,6 +63,7 @@ import java.util.List; import java.util.ArrayList; import java.util.Optional; import java.util.Set; +import java.util.Comparator; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; @@ -235,6 +238,10 @@ public final class ModdedWorldManager implements EngineWorldManager { } boolean initialSpawnChunk(NativeWorld level, int chunkX, int chunkZ) { + if (!isEntitySpawningEnabledForCurrentWorld() + || !NativeSpawnQueries.ambientAllowed(level, chunkX, chunkZ, true)) { + return false; + } if (!ModdedEntitySpawner.chunksSafe(entityRuntime(level), chunkX, chunkZ)) { return false; } @@ -249,54 +256,25 @@ public final class ModdedWorldManager implements EngineWorldManager { MantleChunk chunk = mantle.useChunk(chunkX, chunkZ); try { List markers = markerSystemEnabled() - ? prepareMarkerSpawns(level, chunkX, chunkZ, chunk) : List.of(); + ? prepareMarkerSpawns(level, chunkX, chunkZ, chunk, true) : List.of(); Optional environment = ambientSystemEnabled() ? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty(); - chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> { - spawnPreparedMarkers(level, markers, true); - if (environment.isPresent()) { - scheduleInitialFollowUp(level, chunkX, chunkZ, environment.get()); + if (ambientSystemEnabled() && engine.getComplex() == null) { + return false; + } + spawnPreparedMarkers(level, markers, true); + if (environment.isPresent()) { + try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment.get())) { + spawnAmbient(level, chunkX, chunkZ, true, environment.get()); } - }); + } + chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> {}); } finally { chunk.release(); } return true; } - private void scheduleInitialFollowUp(NativeWorld level, int chunkX, int chunkZ, BiomeEnvironment environment) { - ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); - if (scheduler == null) { - IrisLogging.error("Iris could not schedule the initial entity-spawn follow-up because the modded scheduler is unavailable."); - return; - } - scheduler.laterGlobal( - () -> EngineLifecycleTasks.run( - engine, - "modded_world_manager_initial_spawn_followup", - () -> runInitialFollowUp(level, chunkX, chunkZ, environment)), - RNG.r.i(5, 200)); - } - - private void runInitialFollowUp(NativeWorld level, int chunkX, int chunkZ, BiomeEnvironment environment) { - Mantle mantle = engine.getMantle().getMantle(); - if (closed || engine.isClosed() || mantle.isClosed() || !isEntitySpawningEnabledForCurrentWorld()) { - return; - } - if (!level.isChunkLoaded(chunkX, chunkZ) || !mantle.isChunkLoaded(chunkX, chunkZ)) { - return; - } - if (engine.getComplex() == null) { - return; - } - - if (ambientSystemEnabled()) { - try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) { - spawnAmbient(level, chunkX, chunkZ, true, environment); - } - } - } - private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) { if (closed || !mantleWarmups.add(key)) { return; @@ -380,11 +358,14 @@ public final class ModdedWorldManager implements EngineWorldManager { if (!mantle.isChunkLoaded(chunkX, chunkZ)) { return; } + if (!mantle.hasFlag(chunkX, chunkZ, INITIAL_SPAWN_COMPLETION_FLAG)) { + return; + } MantleChunk chunk = mantle.useChunk(chunkX, chunkZ); try { if (markerSystemEnabled()) { - spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk), false); + spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk, false), false); } if (ambientSystemEnabled()) { spawnAmbient(level, chunkX, chunkZ, false); @@ -394,7 +375,7 @@ public final class ModdedWorldManager implements EngineWorldManager { } } - private List prepareMarkerSpawns(NativeWorld level, int chunkX, int chunkZ, MantleChunk chunk) { + private List prepareMarkerSpawns(NativeWorld level, int chunkX, int chunkZ, MantleChunk chunk, boolean initial) { int minHeight = engine.getWorld().minHeight(); KList obstructed = new KList<>(); List prepared = new ArrayList<>(); @@ -429,29 +410,41 @@ public final class ModdedWorldManager implements EngineWorldManager { if (spawners.isEmpty()) { return; } - IrisSpawner chosen = spawners.getRandom(); + if (initial) { + spawners.sort(Comparator.comparing(IrisSpawner::getLoadKey, Comparator.nullsFirst(Comparator.naturalOrder()))); + } + RNG markerRng = initial ? EntitySpawnSeed.marker(engine.getSeedManager().getEntity(), worldX, worldY, worldZ) + : new RNG(RNG.r.nextLong()); + IrisSpawner chosen = spawners.getRandom(markerRng); if (chosen == null) { return; } - prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, environment)); + prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, resolved, markerRng, environment)); }); Mantle mantle = engine.getMantle().getMantle(); for (IrisPosition position : obstructed) { mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class); } + if (initial) { + prepared.sort(Comparator.comparingInt((PreparedMarkerSpawn marker) -> marker.position().getX()) + .thenComparingInt(marker -> marker.position().getY()) + .thenComparingInt(marker -> marker.position().getZ())); + } return List.copyOf(prepared); } private KList resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) { KList spawners = new KList<>(); + Set seen = new HashSet<>(); for (String key : marker.getSpawners()) { IrisSpawner spawner = environment.data().getSpawnerLoader().load(key); if (spawner == null) { IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName()); continue; } - spawner.setReferenceMarker(marker); - spawners.add(spawner); + if (!spawner.isCompatExcluded() && seen.add(spawner)) { + spawners.add(spawner); + } } return spawners; } @@ -459,32 +452,29 @@ public final class ModdedWorldManager implements EngineWorldManager { private void spawnPreparedMarkers(NativeWorld level, List markers, boolean initial) { for (PreparedMarkerSpawn marker : markers) { try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(marker.environment())) { - spawnFromSpawner(level, marker.position(), marker.spawner(), initial); + spawnFromSpawner(level, marker.position(), marker.rng(), new SpawnContext(marker.spawner(), marker.marker(), initial)); } } } - private void spawnFromSpawner(NativeWorld level, IrisPosition position, IrisSpawner spawner, boolean initial) { - KList spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); + private void spawnFromSpawner(NativeWorld level, IrisPosition position, RNG rng, SpawnContext context) { + IrisSpawner spawner = context.spawner(); + KList spawns = context.initial() ? spawner.getInitialSpawns() : spawner.getSpawns(); if (spawns.isEmpty()) { return; } - for (IrisEntitySpawn entry : spawns) { - entry.setReferenceSpawner(spawner); - entry.setReferenceMarker(spawner.getReferenceMarker()); - } - IrisEntitySpawn chosen = rarityPick(spawns); + IrisEntitySpawn chosen = rarityPick(spawns, rng); if (chosen == null) { return; } int chunkX = position.getX() >> 4; int chunkZ = position.getZ() >> 4; - if (!canSpawn(spawner, chunkX, chunkZ)) { + if (!canSpawn(spawner, chunkX, chunkZ, context.initial())) { return; } - int spawned = spawnEntryAt(level, chosen, spawner, position); - if (spawned > 0) { + int spawned = spawnEntryAt(level, chosen, position, rng, context); + if (spawned > 0 && !context.initial()) { spawner.spawn(engine, chunkX, chunkZ); } } @@ -521,9 +511,11 @@ public final class ModdedWorldManager implements EngineWorldManager { } IrisBiome biome = environment.biome(); - Map chunkMobs = NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ); + Map chunkMobs = initial ? Map.of() : NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ); + RNG rng = initial ? EntitySpawnSeed.chunk(engine.getSeedManager().getEntity(), chunkX, chunkZ) + : new RNG(RNG.r.nextLong()); - KList pool = new KList<>(); + KList pool = new KList<>(); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); @@ -531,22 +523,22 @@ public final class ModdedWorldManager implements EngineWorldManager { return; } - IrisEntitySpawn chosen = rarityPick(pool); - if (chosen == null || chosen.getReferenceSpawner() == null) { + SpawnCandidate chosen = rarityPick(pool, rng); + if (chosen == null) { return; } - IrisSpawner spawner = chosen.getReferenceSpawner(); - if (!canSpawn(spawner, chunkX, chunkZ)) { + IrisSpawner spawner = chosen.spawner(); + if (!canSpawn(spawner, chunkX, chunkZ, initial)) { return; } - int spawned = spawnEntry(level, chosen, spawner, chunkX, chunkZ, - remainingCapacity(chosen, spawner, chunkMobs)); - if (spawned > 0) { + int spawned = spawnEntry(level, chosen.entry(), chunkX, chunkZ, + remainingCapacity(chosen.entry(), spawner, chunkMobs), rng, new SpawnContext(spawner, null, initial)); + if (spawned > 0 && !initial) { spawner.spawn(engine, chunkX, chunkZ); } } - private void collectSpawns(KList pool, KList spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map 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; @@ -554,15 +546,13 @@ public final class ModdedWorldManager implements EngineWorldManager { if (biomeFilter != null && !spawner.isValid(biomeFilter)) { continue; } - if (!canSpawn(spawner, chunkX, chunkZ)) { + if (!canSpawn(spawner, chunkX, chunkZ, initial)) { continue; } KList spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); for (IrisEntitySpawn entry : spawns) { - entry.setReferenceSpawner(spawner); - entry.setReferenceMarker(spawner.getReferenceMarker()); if (remainingCapacity(entry, spawner, chunkMobs) > 0) { - pool.add(entry); + pool.add(new SpawnCandidate(entry, spawner)); } } } @@ -573,7 +563,8 @@ public final class ModdedWorldManager implements EngineWorldManager { return spawner.remainingCapacity(entity, counts); } - private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, int chunkX, int chunkZ, int remainingCapacity) { + private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, int chunkX, int chunkZ, + int remainingCapacity, RNG rng, SpawnContext context) { IrisEntity irisEntity = entry.getRealEntity(engine); if (irisEntity == null) { return 0; @@ -581,18 +572,24 @@ public final class ModdedWorldManager implements EngineWorldManager { int min = entry.getMinSpawns(); int max = entry.getMaxSpawns(); - int count = Math.min(Math.max(0, remainingCapacity), LootResolver.inclusive(RNG.r, min, max)); + int count = Math.min(Math.max(0, remainingCapacity), LootResolver.inclusive(rng, min, max)); if (count <= 0) { return 0; } - RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity())); + IrisSpawner spawner = context.spawner(); IrisSpawnGroup group = spawner.getGroup(); KList caveFloors = group == IrisSpawnGroup.CAVE ? engine.getMantle().findMarkers(chunkX, chunkZ, MarkerMatter.CAVE_FLOOR) : new KList<>(); + if (context.initial()) { + caveFloors.sort(Comparator.comparingInt(IrisPosition::getX) + .thenComparingInt(IrisPosition::getY).thenComparingInt(IrisPosition::getZ)); + } int spawned = 0; for (int i = 0; i < count; i++) { + RNG entityRng = EntitySpawnSeed.entity(rng.getSeed(), i); + RNG positionRng = entityRng.nextParallelRNG(0x632BE59BD9B4E019L); int worldX; int worldY; int worldZ; @@ -600,16 +597,16 @@ public final class ModdedWorldManager implements EngineWorldManager { if (caveFloors.isEmpty()) { continue; } - IrisPosition caveFloor = caveFloors.getRandom(RNG.r); + IrisPosition caveFloor = caveFloors.getRandom(positionRng); worldX = caveFloor.getX(); worldY = caveFloor.getY(); worldZ = caveFloor.getZ(); } else { - worldX = (chunkX << 4) + RNG.r.i(16); - worldZ = (chunkZ << 4) + RNG.r.i(16); + worldX = (chunkX << 4) + positionRng.i(16); + worldZ = (chunkZ << 4) + positionRng.i(16); int surfaceY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, false); int solidY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, true); - Integer selectedY = IrisEntitySpawn.selectSurfaceSpawnY(group, irisEntity.getSurface(), solidY, surfaceY, RNG.r); + Integer selectedY = IrisEntitySpawn.selectSurfaceSpawnY(group, irisEntity.getSurface(), solidY, surfaceY, positionRng); if (selectedY == null) { continue; } @@ -619,7 +616,7 @@ public final class ModdedWorldManager implements EngineWorldManager { continue; } // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). - if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { + if (!context.initial() && !lightAllowed(spawner, level, worldX, worldY, worldZ)) { continue; } if (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ)) { @@ -635,7 +632,7 @@ public final class ModdedWorldManager implements EngineWorldManager { return spawned; } - private int spawnEntryAt(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, IrisPosition position) { + private int spawnEntryAt(NativeWorld level, IrisEntitySpawn entry, IrisPosition position, RNG rng, SpawnContext context) { IrisEntity irisEntity = entry.getRealEntity(engine); if (irisEntity == null) { return 0; @@ -643,21 +640,21 @@ public final class ModdedWorldManager implements EngineWorldManager { int min = entry.getMinSpawns(); int max = entry.getMaxSpawns(); - int count = LootResolver.inclusive(RNG.r, min, max); + int count = LootResolver.inclusive(rng, min, max); if (count <= 0) { return 0; } - exhaustMarker(spawner, position); + exhaustMarker(context.marker(), position, EntitySpawnSeed.entity(rng.getSeed(), -1)); - RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity())); int worldX = position.getX(); int worldY = position.getY() + 1; int worldZ = position.getZ(); int spawned = 0; for (int i = 0; i < count; i++) { + RNG entityRng = EntitySpawnSeed.entity(rng.getSeed(), i); // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). - if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { + if (!context.initial() && !lightAllowed(context.spawner(), level, worldX, worldY, worldZ)) { continue; } if (irisEntity.getSurface().isFluid() @@ -672,16 +669,15 @@ public final class ModdedWorldManager implements EngineWorldManager { return spawned; } - private void exhaustMarker(IrisSpawner spawner, IrisPosition position) { - IrisMarker marker = spawner.getReferenceMarker(); - if (marker == null || !marker.shouldExhaust()) { + private void exhaustMarker(IrisMarker marker, IrisPosition position, RNG rng) { + if (marker == null || !marker.shouldExhaust(rng)) { return; } engine.getMantle().getMantle().remove(position.getX(), position.getY() - engine.getWorld().minHeight(), position.getZ(), MatterMarker.class); } - private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ) { - return spawner.canSpawn(engine, chunkX, chunkZ); + private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ, boolean initial) { + return !spawner.isCompatExcluded() && (initial || spawner.canSpawn(engine, chunkX, chunkZ)); } private boolean lightAllowed(IrisSpawner spawner, NativeWorld level, int worldX, int worldY, int worldZ) { @@ -796,9 +792,9 @@ public final class ModdedWorldManager implements EngineWorldManager { return !studio || studioSetting; } - private IrisEntitySpawn rarityPick(KList entries) { - KList weighted = Rarity.expandWeighted(entries); - return weighted.isEmpty() ? entries.getRandom() : weighted.getRandom(); + private static T rarityPick(KList entries, RNG rng) { + KList weighted = Rarity.expandWeighted(entries); + return weighted.isEmpty() ? entries.getRandom(rng) : weighted.getRandom(rng); } private static long pack(int x, int z) { @@ -893,7 +889,14 @@ public final class ModdedWorldManager implements EngineWorldManager { return failure; } - private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, BiomeEnvironment environment) { + private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, IrisMarker marker, RNG rng, BiomeEnvironment environment) { + } + + private record SpawnCandidate(IrisEntitySpawn entry, IrisSpawner spawner) implements Rarity { + @Override + public int getRarity() { + return entry.getRarity(); + } } private NativeEntityRuntime entityRuntime(NativeWorld level) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedInitialEntitySpawnTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedInitialEntitySpawnTest.java new file mode 100644 index 000000000..c5ed2e078 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedInitialEntitySpawnTest.java @@ -0,0 +1,317 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.mantle.EngineMantle; +import art.arcane.iris.generation.runtime.BiomeEnvironment; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.iris.generation.terrain.InferredType; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.pack.validation.CompatStatus; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.entity.IrisEntity; +import art.arcane.iris.world.entity.IrisEntitySpawn; +import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.iris.world.entity.IrisMarker; +import art.arcane.iris.world.entity.EntitySpawnSeed; +import art.arcane.iris.structure.placement.LootResolver; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnQueries; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.math.Rarity; +import art.arcane.volmlib.util.function.Consumer4; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterMarker; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.lang.reflect.Method; + +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.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ModdedInitialEntitySpawnTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void initialBatchesIgnoreChunkOrderRuntimeRandomPopulationLightAndCooldowns() { + Map> first; + try (Fixture fixture = new Fixture()) { + assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3)); + RNG.r.nextLong(); + assertTrue(fixture.manager.initialSpawnChunk(fixture.world, 4, -5)); + first = fixture.byChunk(); + fixture.verifyInitialPolicy(); + } + try (Fixture fixture = new Fixture()) { + for (int i = 0; i < 64; i++) { + RNG.r.nextLong(); + } + assertTrue(fixture.manager.initialSpawnChunk(fixture.world, 4, -5)); + assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3)); + assertEquals(first, fixture.byChunk()); + assertEquals(2, fixture.completed.size()); + for (List batch : fixture.byChunk().values()) { + assertTrue(batch.size() >= 2 && batch.size() <= 3); + } + fixture.verifyInitialPolicy(); + } + } + + @Test + public void initialCompletionFollowsOwnedSpawnPassAndDoesNotRepeatIt() { + try (Fixture fixture = new Fixture()) { + assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3)); + int count = fixture.spawned.size(); + assertTrue(count > 0); + assertEquals("complete", fixture.trace.getLast()); + assertTrue(fixture.trace.subList(0, fixture.trace.size() - 1).stream().allMatch("spawn"::equals)); + assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3)); + assertEquals(count, fixture.spawned.size()); + assertEquals(1, fixture.completed.size()); + } + } + + @Test + public void unsafeChunkAndDisabledMobSpawningLeaveCompletionForRetry() { + try (Fixture fixture = new Fixture()) { + fixture.queries.when(() -> NativeSpawnQueries.ambientAllowed(fixture.world, -2, 3, true)).thenReturn(false); + assertFalse(fixture.manager.initialSpawnChunk(fixture.world, -2, 3)); + assertTrue(fixture.completed.isEmpty()); + fixture.queries.when(() -> NativeSpawnQueries.ambientAllowed(fixture.world, -2, 3, true)).thenReturn(true); + fixture.entities.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(-2), eq(3))) + .thenReturn(false); + assertFalse(fixture.manager.initialSpawnChunk(fixture.world, -2, 3)); + assertTrue(fixture.completed.isEmpty()); + assertTrue(fixture.spawned.isEmpty()); + fixture.entities.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(-2), eq(3))) + .thenReturn(true); + assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3)); + assertTrue(fixture.completed.contains(key(-2, 3))); + } + } + + @Test + @SuppressWarnings("unchecked") + public void initialMarkerSelectionUsesOneStreamAndExcludesDuplicateAndUnsupportedSpawners() throws ReflectiveOperationException { + try (Fixture fixture = new Fixture()) { + IrisSpawner first = fixture.definitions.get(0); + IrisSpawner second = fixture.definitions.get(1); + first.setLoadKey("a"); + second.setLoadKey("b"); + IrisSpawner excluded = new IrisSpawner(); + excluded.setLoadKey("excluded"); + excluded.setCompat(CompatStatus.excludedBy(List.of())); + IrisMarker marker = new IrisMarker().setSpawners(new KList<>("b", "excluded", "a", "b")) + .setEmptyAbove(false).setExhaustionChance(0.5); + BiomeEnvironment environment = fixture.engine.getSurfaceBiomeEnvironment(0, 0); + ResourceLoader spawners = environment.data().getSpawnerLoader(); + when(spawners.load("a")).thenReturn(first); + when(spawners.load("b")).thenReturn(second); + when(spawners.load("excluded")).thenReturn(excluded); + ResourceLoader markers = mock(ResourceLoader.class); + when(environment.data().getMarkerLoader()).thenReturn(markers); + when(markers.load("test")).thenReturn(marker); + when(fixture.engine.getWorld().minHeight()).thenReturn(-64); + when(fixture.engine.getBiomeEnvironment(-25, 15, 57)).thenReturn(environment); + MantleChunk chunk = mock(MantleChunk.class); + doAnswer(invocation -> { + Consumer4 callback = invocation.getArgument(1); + callback.accept(7, 15, 9, new MatterMarker("test")); + return null; + }).when(chunk).iterate(eq(MatterMarker.class), any()); + Method resolve = ModdedWorldManager.class.getDeclaredMethod("resolveMarkerSpawners", IrisMarker.class, BiomeEnvironment.class); + resolve.setAccessible(true); + KList resolved = (KList) resolve.invoke(fixture.manager, marker, environment); + assertEquals(List.of(second, first), resolved); + Method prepare = ModdedWorldManager.class.getDeclaredMethod("prepareMarkerSpawns", NativeWorld.class, + int.class, int.class, MantleChunk.class, boolean.class); + prepare.setAccessible(true); + Object prepared = prepare.invoke(fixture.manager, fixture.world, -2, 3, chunk, true); + Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnPreparedMarkers", NativeWorld.class, List.class, boolean.class); + spawn.setAccessible(true); + spawn.invoke(fixture.manager, fixture.world, prepared, true); + + RNG rng = EntitySpawnSeed.marker(7721L, -25, -49, 57); + IrisSpawner selected = new KList<>(first, second).getRandom(rng); + IrisEntitySpawn entry = Rarity.expandWeighted(selected.getInitialSpawns()).getRandom(rng); + int count = LootResolver.inclusive(rng, entry.getMinSpawns(), entry.getMaxSpawns()); + List expected = new ArrayList<>(); + for (int ordinal = 0; ordinal < count; ordinal++) { + expected.add(new Spawned(entry.getRealEntity(fixture.engine).getType(), -25, -48, 57, + EntitySpawnSeed.entity(rng.getSeed(), ordinal).nextLong())); + } + assertEquals(expected, fixture.spawned); + Mantle mantle = fixture.engine.getMantle().getMantle(); + if (marker.shouldExhaust(EntitySpawnSeed.entity(rng.getSeed(), -1))) { + verify(mantle).remove(-25, 15, 57, MatterMarker.class); + } else { + verify(mantle, never()).remove(anyInt(), anyInt(), anyInt(), eq(MatterMarker.class)); + } + } + } + + private static long key(int x, int z) { + return (long) x & 0xFFFFFFFFL | (long) z << 32; + } + + private record Spawned(String type, int x, int y, int z, long customization) { + } + + private static final class Fixture implements AutoCloseable { + private final Engine engine = mock(Engine.class); + private final NativeWorld world = mock(NativeWorld.class); + private final ModdedWorldManager manager = new ModdedWorldManager(engine); + private final MockedStatic configured = mockStatic(IrisSettings.class); + private final MockedStatic queries = mockStatic(NativeSpawnQueries.class); + private final MockedStatic entities = mockStatic(ModdedEntitySpawner.class); + private final List spawned = new ArrayList<>(); + private final List trace = new ArrayList<>(); + private final Set completed = new HashSet<>(); + private final List definitions = new ArrayList<>(); + + @SuppressWarnings("unchecked") + private Fixture() { + IrisSettings settings = new IrisSettings(); + settings.getWorld().setAmbientEntitySpawningSystem(true); + settings.getWorld().setMarkerEntitySpawningSystem(false); + configured.when(IrisSettings::get).thenReturn(settings); + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + IrisWorld irisWorld = mock(IrisWorld.class); + SeedManager seeds = mock(SeedManager.class); + when(seeds.getEntity()).thenReturn(7721L); + when(engine.getSeedManager()).thenReturn(seeds); + when(engine.getWorld()).thenReturn(irisWorld); + when(engine.getComplex()).thenReturn(mock(IrisComplex.class)); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + when(world.nativeHandle()).thenReturn(mock(ServerLevel.class)); + when(world.minHeight()).thenReturn(-64); + when(world.maxHeight()).thenReturn(320); + NativeBlockState support = mock(NativeBlockState.class); + when(world.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(support); + queries.when(() -> NativeSpawnQueries.solid(support)).thenReturn(true); + queries.when(() -> NativeSpawnQueries.ambientAllowed(eq(world), anyInt(), anyInt(), anyBoolean())).thenReturn(true); + queries.when(() -> NativeSpawnQueries.surfaceHeight(eq(world), anyInt(), anyInt(), anyBoolean())).thenReturn(50); + queries.when(() -> NativeSpawnQueries.livingEntityCategories(eq(world), anyInt(), anyInt())) + .thenReturn(Map.of("creature", 1000)); + queries.when(() -> NativeSpawnQueries.light(eq(world), anyInt(), anyInt(), anyInt())).thenReturn(15); + entities.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), anyInt(), anyInt())).thenReturn(true); + entities.when(() -> ModdedEntitySpawner.isAreaClearForSpawn(any(NativeEntityRuntime.class), + any(IrisEntity.class), anyInt(), anyInt(), anyInt())).thenReturn(true); + entities.when(() -> ModdedEntitySpawner.spawn(eq(engine), any(IrisEntity.class), any(NativeEntityRuntime.class), + anyInt(), anyInt(), anyInt(), any(RNG.class))).thenAnswer(invocation -> { + IrisEntity entity = invocation.getArgument(1); + RNG rng = invocation.getArgument(6); + spawned.add(new Spawned(entity.getType(), invocation.getArgument(3), invocation.getArgument(4), + invocation.getArgument(5), rng.nextLong())); + trace.add("spawn"); + return mock(NativeSpawnedEntity.class); + }); + when(mantle.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(mantle.hasFlag(anyInt(), anyInt(), eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG))) + .thenAnswer(invocation -> completed.contains(key(invocation.getArgument(0), invocation.getArgument(1)))); + when(mantle.useChunk(anyInt(), anyInt())).thenAnswer(invocation -> { + long key = key(invocation.getArgument(0), invocation.getArgument(1)); + MantleChunk chunk = mock(MantleChunk.class); + doAnswer(flag -> { + completed.add(key); + trace.add("complete"); + return null; + }).when(chunk).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any()); + return chunk; + }); + IrisData data = mock(IrisData.class); + ResourceLoader loader = mock(ResourceLoader.class); + when(data.getSpawnerLoader()).thenReturn(loader); + IrisBiome biome = mock(IrisBiome.class); + when(biome.getInferredType()).thenReturn(InferredType.LAND); + when(biome.getEntitySpawners()).thenReturn(new KList<>()); + IrisRegion region = mock(IrisRegion.class); + when(region.getEntitySpawners()).thenReturn(new KList<>()); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getEntitySpawners()).thenReturn(new KList<>("land")); + for (String type : List.of("minecraft:cow", "minecraft:pig")) { + IrisEntity entity = new IrisEntity().setType(type); + IrisEntitySpawn entry = spy(new IrisEntitySpawn().setMinSpawns(2).setMaxSpawns(5)); + doReturn(entity).when(entry).getRealEntity(engine); + IrisSpawner spawner = spy(new IrisSpawner().setInitialSpawns(new KList<>(entry)) + .setMaxEntitiesPerChunk(3).setAllowedLightLevels(new IrisRange(0, 0))); + doReturn(false).when(spawner).canSpawn(eq(engine), anyInt(), anyInt()); + definitions.add(spawner); + } + when(loader.loadAll(any(KList.class))).thenAnswer(invocation -> { + KList names = invocation.getArgument(0); + return names.contains("land") ? new KList<>(definitions) : new KList<>(); + }); + BiomeEnvironment environment = new BiomeEnvironment(1L, biome, region, dimension, data); + when(engine.getSurfaceBiomeEnvironment(anyInt(), anyInt())).thenReturn(environment); + when(engine.openBiomeEnvironmentScope(environment)).thenReturn(mock(BiomeEnvironment.Scope.class)); + } + + private Map> byChunk() { + Map> output = new HashMap<>(); + for (Spawned spawn : spawned) { + output.computeIfAbsent(key(spawn.x() >> 4, spawn.z() >> 4), ignored -> new ArrayList<>()).add(spawn); + } + return output; + } + + private void verifyInitialPolicy() { + queries.verify(() -> NativeSpawnQueries.livingEntityCategories(eq(world), anyInt(), anyInt()), never()); + queries.verify(() -> NativeSpawnQueries.light(eq(world), anyInt(), anyInt(), anyInt()), never()); + for (IrisSpawner spawner : definitions) { + verify(spawner, never()).canSpawn(eq(engine), anyInt(), anyInt()); + verify(spawner, never()).spawn(eq(engine), anyInt(), anyInt()); + } + } + + @Override + public void close() { + manager.close(); + entities.close(); + queries.close(); + configured.close(); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSavedBiomeConsumersTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSavedBiomeConsumersTest.java index a336910a9..10860261b 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSavedBiomeConsumersTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSavedBiomeConsumersTest.java @@ -5,11 +5,17 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnQueries; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; import art.arcane.iris.generation.runtime.BiomeEnvironment; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.biome.IrisBiome; @@ -72,6 +78,15 @@ public class ModdedSavedBiomeConsumersTest { when(mantle.isChunkLoaded(2, -1)).thenReturn(true); when(mantle.useChunk(2, -1)).thenReturn(chunk); BiomeEnvironment environment = environment(); + ResourceLoader spawners = mock(ResourceLoader.class); + when(environment.data().getSpawnerLoader()).thenReturn(spawners); + when(spawners.loadAll(any(KList.class))).thenReturn(new KList<>()); + when(environment.dimension().getEntitySpawners()).thenReturn(new KList<>()); + when(environment.region().getEntitySpawners()).thenReturn(new KList<>()); + when(environment.biome().getEntitySpawners()).thenReturn(new KList<>()); + when(engine.getSeedManager()).thenReturn(mock(SeedManager.class)); + when(engine.getComplex()).thenReturn(mock(IrisComplex.class)); + when(engine.openBiomeEnvironmentScope(environment)).thenReturn(mock(BiomeEnvironment.Scope.class)); when(engine.getSurfaceBiomeEnvironment(40, -8)) .thenThrow(new SavedBiomeUnavailableException("Loading", true)) .thenReturn(environment); @@ -81,8 +96,10 @@ public class ModdedSavedBiomeConsumersTest { ModdedWorldManager manager = new ModdedWorldManager(engine); try (MockedStatic configured = mockStatic(IrisSettings.class); - MockedStatic spawns = mockStatic(ModdedEntitySpawner.class)) { + MockedStatic spawns = mockStatic(ModdedEntitySpawner.class); + MockedStatic queries = mockStatic(NativeSpawnQueries.class)) { configured.when(IrisSettings::get).thenReturn(settings); + queries.when(() -> NativeSpawnQueries.ambientAllowed(any(NativeWorld.class), eq(2), eq(-1), eq(true))).thenReturn(true); spawns.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(2), eq(-1))).thenReturn(true); assertThrows(SavedBiomeUnavailableException.class, () -> manager.initialSpawnChunk(new ModdedPlatformWorld(level), 2, -1)); verify(chunk, never()).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any()); 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 6fb82bdf3..0c7e183ef 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 @@ -14,6 +14,7 @@ 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.world.entity.IrisEntitySpawn.SpawnContext; import art.arcane.iris.generation.decoration.IrisSurface; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.collection.KList; @@ -196,7 +197,7 @@ public class ModdedWaterEntitySpawnTest { 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); + IrisEntitySpawn.class, int.class, int.class, int.class, RNG.class, SpawnContext.class); spawn.setAccessible(true); ModdedWorldManager manager = new ModdedWorldManager(engine); try (MockedStatic entities = mockStatic(ModdedEntitySpawner.class)) { @@ -205,7 +206,7 @@ public class ModdedWaterEntitySpawnTest { 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)); + assertEquals(1, spawn.invoke(manager, world, entry, -2, 3, 1, new RNG(1L), new SpawnContext(spawner, null, false))); entities.verify(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class), eq(-25), eq(-49), eq(57), any(RNG.class))); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/MarkerSpawnScanner.java b/core/src/main/java/art/arcane/iris/generation/runtime/MarkerSpawnScanner.java index 2f2e8b7ca..70f3e4f0d 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/MarkerSpawnScanner.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/MarkerSpawnScanner.java @@ -67,6 +67,7 @@ final class MarkerSpawnScanner { J.a(manager.managedTask("bukkit_world_manager_marker_scan", () -> { try { if (initialOnly && manager.getMantle().hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER)) { + manager.chunkMaintenance.recordInitialSpawnCompletion(chunkX, chunkZ); markerScanQueue.remove(key); return; } @@ -91,7 +92,7 @@ final class MarkerSpawnScanner { continue; } prepared.add(new PreparedMarkerSpawn(new IrisPosition(position.getX(), - position.getY() + minimumY, position.getZ()), data.spawners, data.environment)); + position.getY() + minimumY, position.getZ()), data.spawners, data.environment, data.marker)); } consumer.accept(List.copyOf(prepared)); } finally { @@ -137,7 +138,7 @@ final class MarkerSpawnScanner { } IrisPosition position = new IrisPosition((chunkX << 4) + x, y, (chunkZ << 4) + z); - MarkerSpawnData data = markerData.computeIfAbsent(position, k -> new MarkerSpawnData(environment)); + MarkerSpawnData data = markerData.computeIfAbsent(position, k -> new MarkerSpawnData(environment, mark)); data.requiresEmptyAbove = data.requiresEmptyAbove || mark.isEmptyAbove(); for (String i : mark.getSpawners()) { @@ -149,7 +150,6 @@ final class MarkerSpawnScanner { if (spawner.isCompatExcluded()) { continue; } - spawner.setReferenceMarker(mark); data.spawners.add(spawner); } }); @@ -184,16 +184,18 @@ final class MarkerSpawnScanner { })); } - record PreparedMarkerSpawn(IrisPosition position, KSet spawners, BiomeEnvironment environment) { + record PreparedMarkerSpawn(IrisPosition position, KSet spawners, BiomeEnvironment environment, IrisMarker marker) { } private static final class MarkerSpawnData { private final KSet spawners = new KSet<>(); private final BiomeEnvironment environment; + private final IrisMarker marker; private boolean requiresEmptyAbove; - private MarkerSpawnData(BiomeEnvironment environment) { + private MarkerSpawnData(BiomeEnvironment environment, IrisMarker marker) { this.environment = environment; + this.marker = marker; } } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/WorldChunkMaintenance.java b/core/src/main/java/art/arcane/iris/generation/runtime/WorldChunkMaintenance.java index faa867718..4f6b6d082 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/WorldChunkMaintenance.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/WorldChunkMaintenance.java @@ -20,6 +20,7 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.generation.cache.Cache; +import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter.SavedChunkMantle; import art.arcane.iris.world.history.SavedBiomeUnavailableException; @@ -35,6 +36,7 @@ import art.arcane.volmlib.util.math.PowerOfTwoCoordinates; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.matter.Matter; import org.bukkit.Chunk; +import org.bukkit.GameRules; import org.bukkit.World; import org.bukkit.entity.Player; @@ -44,6 +46,7 @@ import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ForkJoinPool; @@ -59,10 +62,12 @@ final class WorldChunkMaintenance { private static final int MAX_FORCED_CHUNK_UPDATES = 128; private static final int MAX_PENDING_MATERIALIZATIONS = 128; private static final int MAX_COMPLETED_MATERIALIZATIONS = 8192; + private static final int MAX_COMPLETED_INITIAL_SPAWNS = 8192; private final IrisWorldManager manager; private final Set mantleWarmupQueue = ConcurrentHashMap.newKeySet(); private final Set markerFlagQueue = ConcurrentHashMap.newKeySet(); + private final Set completedInitialSpawns = Collections.synchronizedSet(new LinkedHashSet<>()); private final Set discoveredFlagQueue = ConcurrentHashMap.newKeySet(); private final Set chunkUpdateQueue = ConcurrentHashMap.newKeySet(); private final Map materializations = new ConcurrentHashMap<>(); @@ -85,6 +90,7 @@ final class WorldChunkMaintenance { } } completedMaterializations.clear(); + completedInitialSpawns.clear(); } void invalidateMaterialization(int chunkX, int chunkZ) { @@ -375,46 +381,107 @@ final class WorldChunkMaintenance { } } - void raiseInitialSpawnMarkerFlag(World world, int chunkX, int chunkZ, Runnable onFirstRaise) { - if (world == null || onFirstRaise == null) { + boolean isInitialSpawnComplete(int chunkX, int chunkZ) { + return completedInitialSpawns.contains(Cache.key(chunkX, chunkZ)); + } + + void recordInitialSpawnCompletion(int chunkX, int chunkZ) { + rememberInitialSpawn(Cache.key(chunkX, chunkZ)); + } + + void runInitialSpawn(World world, int chunkX, int chunkZ, Runnable initialSpawn) { + if (world == null || initialSpawn == null || closed) { return; } - - if (!J.isFolia()) { - manager.getMantle().raiseFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER, onFirstRaise); - return; - } - long key = Cache.key(chunkX, chunkZ); - if (!markerFlagQueue.add(key)) { + if (completedInitialSpawns.contains(key) || !markerFlagQueue.add(key)) { return; } + J.a(manager.managedTask("bukkit_world_manager_initial_spawn_prepare", + () -> prepareInitialSpawnFlag(world, chunkX, chunkZ, key, initialSpawn), + () -> markerFlagQueue.remove(key))); + } - J.a(manager.managedTask("bukkit_world_manager_spawn_marker_flag", () -> { - boolean raised = false; - try { - Mantle mantle = manager.getMantle(); - if (!mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER)) { - mantle.flag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER, true); - raised = true; - } - } catch (Throwable e) { - IrisLogging.reportError(e); - } finally { + private void prepareInitialSpawnFlag(World world, int chunkX, int chunkZ, long key, Runnable initialSpawn) { + try { + if (manager.getMantle().hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER)) { + rememberInitialSpawn(key); markerFlagQueue.remove(key); - } - - if (!raised) { return; } + boolean accepted = J.runRegion(world, chunkX, chunkZ, + manager.managedTask("bukkit_world_manager_initial_spawn_apply", + () -> applyInitialSpawn(world, chunkX, chunkZ, key, initialSpawn), + () -> markerFlagQueue.remove(key))); + if (!accepted) { + markerFlagQueue.remove(key); + } + } catch (Throwable failure) { + markerFlagQueue.remove(key); + IrisLogging.reportError("Failed to prepare initial Iris entities in chunk " + chunkX + "," + chunkZ + ".", failure); + } + } - J.runRegion(world, chunkX, chunkZ, manager.managedTask("bukkit_world_manager_spawn_marker_callback", () -> { - if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) { - return; - } - onFirstRaise.run(); - })); - }, () -> markerFlagQueue.remove(key))); + private void applyInitialSpawn(World world, int chunkX, int chunkZ, long key, Runnable initialSpawn) { + GenerationSessionLease completionLease = null; + try { + if (closed || !world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ) + || !Boolean.TRUE.equals(world.getGameRuleValue(GameRules.SPAWN_MOBS))) { + markerFlagQueue.remove(key); + return; + } + Optional admitted = initialCompletionLease(); + if (admitted.isEmpty()) { + markerFlagQueue.remove(key); + return; + } + completionLease = admitted.get(); + initialSpawn.run(); + rememberInitialSpawn(key); + completionLease.detachThread(); + GenerationSessionLease transferred = completionLease; + J.a(() -> persistInitialSpawnFlag(chunkX, chunkZ, key, transferred)); + completionLease = null; + } catch (Throwable failure) { + markerFlagQueue.remove(key); + IrisLogging.reportError("Failed to apply initial Iris entities in chunk " + chunkX + "," + chunkZ + ".", failure); + } finally { + if (completionLease != null) { + completionLease.close(); + } + } + } + + private Optional initialCompletionLease() throws GenerationSessionException { + Engine engine = manager.getEngine(); + GenerationSessionManager sessions = engine.getGenerationSessions(); + if (sessions == null) { + return Optional.of(GenerationSessionLease.noop()); + } + IrisContext context = IrisContext.get(); + if (context != null && context.getEngine() == engine && context.getGenerationSessionId() != 0L) { + return Optional.of(sessions.continueSession("bukkit_world_manager_initial_spawn_complete", context.getGenerationSessionId())); + } + return sessions.tryAcquireForEngine(engine, "bukkit_world_manager_initial_spawn_complete"); + } + + private void persistInitialSpawnFlag(int chunkX, int chunkZ, long key, GenerationSessionLease lease) { + try (lease; IrisContext.Scope ignored = IrisContext.open(manager.getEngine(), lease.sessionId(), null)) { + manager.getMantle().flag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER, true); + } catch (Throwable failure) { + IrisLogging.reportError("Failed to save initial Iris entity completion in chunk " + chunkX + "," + chunkZ + ".", failure); + } finally { + markerFlagQueue.remove(key); + } + } + + private void rememberInitialSpawn(long key) { + synchronized (completedInitialSpawns) { + completedInitialSpawns.add(key); + if (completedInitialSpawns.size() > MAX_COMPLETED_INITIAL_SPAWNS) { + completedInitialSpawns.remove(completedInitialSpawns.iterator().next()); + } + } } void warmupMantleChunkAsync(int chunkX, int chunkZ) { 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 4180d97f3..dad046b5e 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 @@ -25,6 +25,9 @@ import art.arcane.volmlib.util.math.Rarity; import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.world.entity.IrisEntitySpawn; +import art.arcane.iris.world.entity.EntitySpawnSeed; +import art.arcane.iris.world.entity.IrisMarker; +import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext; import art.arcane.iris.pack.value.IrisPosition; import art.arcane.iris.world.entity.IrisSpawner; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; @@ -47,6 +50,9 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import java.util.List; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Objects; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -57,10 +63,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Predicate; import java.util.function.Supplier; -import java.util.stream.Collectors; -import java.util.stream.Stream; /** * Ambient and marker entity spawning for a Bukkit Iris world. Every count and spawn hops onto the @@ -308,12 +311,15 @@ final class WorldEntitySpawner { return; } + if (!manager.chunkMaintenance.isInitialSpawnComplete(c.getX(), c.getZ())) { + spawnInitially(c); + return; + } if (IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) { manager.markerScanner.scanMarkerSpawners(c, false, markers -> { for (PreparedMarkerSpawn marker : markers) { spawnPreparedMarker(marker, false); } - prepareInitialSpawn(c, markers); }); } @@ -333,7 +339,9 @@ final class WorldEntitySpawner { } void spawnInitially(Chunk chunk) { - if (manager.getEngine().isClosed() || !isEntitySpawningEnabledForCurrentWorld()) { + if (manager.getEngine().isClosed() || !isEntitySpawningEnabledForCurrentWorld() + || manager.chunkMaintenance.isInitialSpawnComplete(chunk.getX(), chunk.getZ()) + || !Boolean.TRUE.equals(chunk.getWorld().getGameRuleValue(GameRules.SPAWN_MOBS))) { return; } if (IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) { @@ -356,71 +364,94 @@ final class WorldEntitySpawner { } } Optional preparedEnvironment = environment; - manager.chunkMaintenance.raiseInitialSpawnMarkerFlag(chunk.getWorld(), chunk.getX(), chunk.getZ(), () -> { - for (PreparedMarkerSpawn marker : markers) { + List ordered = new ArrayList<>(markers); + ordered.sort(Comparator.comparingInt((PreparedMarkerSpawn marker) -> marker.position().getX()) + .thenComparingInt(marker -> marker.position().getY()) + .thenComparingInt(marker -> marker.position().getZ())); + manager.chunkMaintenance.runInitialSpawn(chunk.getWorld(), chunk.getX(), chunk.getZ(), () -> { + for (PreparedMarkerSpawn marker : ordered) { spawnPreparedMarker(marker, true); } if (preparedEnvironment.isPresent()) { - J.runRegion(chunk.getWorld(), chunk.getX(), chunk.getZ(), - manager.managedTask("bukkit_world_manager_initial_spawn_followup", () -> { - if (!chunk.getWorld().isChunkLoaded(chunk.getX(), chunk.getZ())) { - return; - } - BiomeEnvironment selected = preparedEnvironment.get(); - try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(selected)) { - spawnAmbient(chunk, true, selected); - } - }), RNG.r.i(5, 200)); + BiomeEnvironment selected = preparedEnvironment.get(); + try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(selected)) { + spawnAmbient(chunk, true, selected); + } } }); } private void spawnPreparedMarker(PreparedMarkerSpawn marker, boolean initial) { - IrisSpawner spawner = new KList<>(marker.spawners()).getRandom(); - if (spawner == null || manager.getEngine().isClosed()) { + if (manager.getEngine().isClosed()) { + return; + } + RNG random = initial ? EntitySpawnSeed.marker(manager.getEngine().getSeedManager().getEntity(), + marker.position().getX(), marker.position().getY(), marker.position().getZ()) : new RNG(RNG.r.nextLong()); + KList spawners = new KList<>(marker.spawners()); + if (initial) { + spawners.sort(Comparator.comparing(spawner -> Objects.toString(spawner.getLoadKey(), ""))); + } + IrisSpawner spawner = spawners.getRandom(random); + if (spawner == null) { return; } try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(marker.environment())) { - spawnMarker(marker.position(), spawner, initial); + spawnMarker(marker.position(), spawner, marker.marker(), random, initial); } } - private void spawnAmbient(Chunk c, boolean initial, BiomeEnvironment environment) { - if (!ambientAllowed(c, initial)) { + private void spawnAmbient(Chunk chunk, boolean initial, BiomeEnvironment environment) { + if (!ambientAllowed(chunk, 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()); - ChunkCounter counter = new ChunkCounter(c.getEntities()); - - IrisBiome biome = environment.biome(); - IrisEntitySpawn v = spawnRandomly(Stream.concat(environment.data().getSpawnerLoader() - .loadAll(environment.dimension().getEntitySpawners()) - .shuffleCopy(RNG.r) - .stream() - .filter(filter) - .filter((i) -> i.isValid(biome)), - Stream.concat(environment.data() - .getSpawnerLoader() - .loadAll(environment.region().getEntitySpawners()) - .shuffleCopy(RNG.r) - .stream() - .filter(filter), - environment.data().getSpawnerLoader() - .loadAll(environment.biome().getEntitySpawners()) - .shuffleCopy(RNG.r) - .stream() - .filter(filter))) - .flatMap((i) -> stream(i, initial)) - .filter(entry -> counter.remainingCapacity(entry, manager.getEngine()) > 0) - .collect(Collectors.toList())) - .getRandom(); - //@done - if (v == null || v.getReferenceSpawner() == null) + ChunkCounter counter = initial ? null : new ChunkCounter(chunk.getEntities()); + List pool = new ArrayList<>(); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), + environment.biome(), chunk, initial, counter); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), + null, chunk, initial, counter); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.biome().getEntitySpawners()), + null, chunk, initial, counter); + RNG random = initial ? EntitySpawnSeed.chunk(manager.getEngine().getSeedManager().getEntity(), + chunk.getX(), chunk.getZ()) : new RNG(RNG.r.nextLong()); + SpawnSelection selected = selectSpawn(pool, random); + if (selected == null) { return; + } + int capacity = initial ? Math.max(0, selected.spawner().getMaxEntitiesPerChunk()) + : counter.remainingCapacity(selected.entry(), selected.spawner(), manager.getEngine()); + int spawned = selected.entry().spawn(manager.getEngine(), chunk, random, capacity, + new SpawnContext(selected.spawner(), null, initial)); + actuallySpawned.addAndGet(spawned); + if (spawned > 0 && !initial) { + selected.spawner().spawn(manager.getEngine(), chunk.getX(), chunk.getZ()); + } + } - spawn(c, v, counter.remainingCapacity(v, manager.getEngine())); + private void collectSpawns(List pool, KList spawners, IrisBiome biome, + Chunk chunk, boolean initial, ChunkCounter counter) { + for (IrisSpawner spawner : spawners) { + if (!spawnerAllowed(spawner, manager.getEngine(), chunk.getX(), chunk.getZ(), initial) + || biome != null && !spawner.isValid(biome)) { + continue; + } + for (IrisEntitySpawn entry : initial ? spawner.getInitialSpawns() : spawner.getSpawns()) { + IrisEntity entity = entry.getRealEntity(manager.getEngine()); + int capacity = initial ? Math.max(0, spawner.getMaxEntitiesPerChunk()) + : counter.remainingCapacity(entry, spawner, manager.getEngine()); + if (entity != null && capacity > 0) { + pool.add(new SpawnSelection(entry, spawner)); + } + } + } + } + + static boolean spawnerAllowed(IrisSpawner spawner, Engine engine, int chunkX, int chunkZ, boolean initial) { + return !spawner.isCompatExcluded() && (initial || spawner.canSpawn(engine, chunkX, chunkZ)); + } + + static SpawnSelection selectSpawn(List pool, RNG random) { + return Rarity.expandWeighted(pool).getRandom(random); } static boolean ambientAllowed(Chunk chunk, boolean initial) { @@ -428,46 +459,22 @@ final class WorldEntitySpawner { && (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, remainingCapacity); - actuallySpawned.addAndGet(s); - if (s > 0) { - ref.spawn(manager.getEngine(), c.getX(), c.getZ()); - } - } - - private void spawn(IrisPosition pos, IrisEntitySpawn i) { - IrisSpawner ref = i.getReferenceSpawner(); - if (!ref.canSpawn(manager.getEngine(), PowerOfTwoCoordinates.blockToChunkFloor(pos.getX()), PowerOfTwoCoordinates.blockToChunkFloor(pos.getZ()))) - return; - - int s = i.spawn(manager.getEngine(), pos, RNG.r); - actuallySpawned.addAndGet(s); - if (s > 0) { - ref.spawn(manager.getEngine(), PowerOfTwoCoordinates.blockToChunkFloor(pos.getX()), PowerOfTwoCoordinates.blockToChunkFloor(pos.getZ())); - } - } - - private void spawnMarker(IrisPosition block, IrisSpawner spawner, boolean initial) { - KList s = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); - if (s.isEmpty()) { + private void spawnMarker(IrisPosition position, IrisSpawner spawner, IrisMarker marker, RNG random, boolean initial) { + int chunkX = PowerOfTwoCoordinates.blockToChunkFloor(position.getX()); + int chunkZ = PowerOfTwoCoordinates.blockToChunkFloor(position.getZ()); + if (!spawnerAllowed(spawner, manager.getEngine(), chunkX, chunkZ, initial)) { return; } - - IrisEntitySpawn ss = spawnRandomly(s).getRandom(); - ss.setReferenceSpawner(spawner); - ss.setReferenceMarker(spawner.getReferenceMarker()); - spawn(block, ss); - } - - private Stream stream(IrisSpawner s, boolean initial) { - for (IrisEntitySpawn i : initial ? s.getInitialSpawns() : s.getSpawns()) { - i.setReferenceSpawner(s); - i.setReferenceMarker(s.getReferenceMarker()); + KList entries = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); + IrisEntitySpawn selected = Rarity.expandWeighted(entries).getRandom(random); + if (selected == null) { + return; + } + int spawned = selected.spawn(manager.getEngine(), position, random, new SpawnContext(spawner, marker, initial)); + actuallySpawned.addAndGet(spawned); + if (spawned > 0 && !initial) { + spawner.spawn(manager.getEngine(), chunkX, chunkZ); } - - return (initial ? s.getInitialSpawns() : s.getSpawns()).stream(); } boolean isEntitySpawningEnabledForCurrentWorld() { @@ -478,8 +485,11 @@ final class WorldEntitySpawner { return IrisSettings.get().getStudio().isEntitySpawning(); } - private KList spawnRandomly(List types) { - return Rarity.expandWeighted(types); + record SpawnSelection(IrisEntitySpawn entry, IrisSpawner spawner) implements Rarity { + @Override + public int getRarity() { + return entry.getRarity(); + } } static final class ChunkCounter { @@ -493,9 +503,9 @@ final class WorldEntitySpawner { } } - int remainingCapacity(IrisEntitySpawn entry, Engine engine) { + int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Engine engine) { IrisEntity entity = entry.getRealEntity(engine); - return entry.getReferenceSpawner().remainingCapacity(entity, counts); + return spawner.remainingCapacity(entity, counts); } } } diff --git a/core/src/main/java/art/arcane/iris/world/entity/EntitySpawnSeed.java b/core/src/main/java/art/arcane/iris/world/entity/EntitySpawnSeed.java new file mode 100644 index 000000000..462d89977 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/entity/EntitySpawnSeed.java @@ -0,0 +1,35 @@ +package art.arcane.iris.world.entity; + +import art.arcane.volmlib.util.math.RNG; + +public final class EntitySpawnSeed { + private static final long CHUNK_SALT = 0xA0761D6478BD642FL; + private static final long MARKER_SALT = 0xE7037ED1A0B428DBL; + private static final long ENTITY_SALT = 0x8EBC6AF09C88C6E3L; + + private EntitySpawnSeed() { + } + + public static RNG chunk(long entitySeed, int x, int z) { + long seed = mix(entitySeed ^ CHUNK_SALT); + seed = mix(seed ^ (long) x * 0xD6E8FEB86659FD93L); + return new RNG(mix(seed ^ (long) z * 0x8CB92BA72F3D8DD7L)); + } + + public static RNG marker(long entitySeed, int x, int y, int z) { + long seed = mix(entitySeed ^ MARKER_SALT); + seed = mix(seed ^ (long) x * 0xD6E8FEB86659FD93L); + seed = mix(seed ^ (long) y * 0xA5A3564E27F886A7L); + return new RNG(mix(seed ^ (long) z * 0x8CB92BA72F3D8DD7L)); + } + + public static RNG entity(long batchSeed, int ordinal) { + return new RNG(mix(batchSeed ^ ENTITY_SALT ^ (long) ordinal * 0x9E3779B97F4A7C15L)); + } + + private static long mix(long seed) { + seed = (seed ^ seed >>> 30) * 0xBF58476D1CE4E5B9L; + seed = (seed ^ seed >>> 27) * 0x94D049BB133111EBL; + return seed ^ seed >>> 31; + } +} diff --git a/core/src/main/java/art/arcane/iris/world/entity/IrisAttributeModifier.java b/core/src/main/java/art/arcane/iris/world/entity/IrisAttributeModifier.java index 4259d2dc5..5967e5622 100644 --- a/core/src/main/java/art/arcane/iris/world/entity/IrisAttributeModifier.java +++ b/core/src/main/java/art/arcane/iris/world/entity/IrisAttributeModifier.java @@ -40,7 +40,6 @@ import org.bukkit.inventory.meta.ItemMeta; import java.util.Locale; import java.util.Set; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @Snippet("attribute-modifier") @@ -134,12 +133,12 @@ public class IrisAttributeModifier { } } - private AttributeModifier createModifier(RNG rng) { - NamespacedKey key = NamespacedKey.minecraft(generateModifierKey()); + AttributeModifier createModifier(RNG rng) { + NamespacedKey key = NamespacedKey.minecraft(generateModifierKey(rng)); return new AttributeModifier(key, getAmount(rng), resolveOperation(), EquipmentSlotGroup.ANY); } - private String generateModifierKey() { + private String generateModifierKey(RNG rng) { String source = getName() == null ? "modifier" : getName(); String normalized = source.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9/._-]", "_"); if (normalized.isBlank()) { @@ -148,7 +147,8 @@ public class IrisAttributeModifier { if (normalized.length() > 32) { normalized = normalized.substring(0, 32); } - String random = UUID.randomUUID().toString().replace("-", ""); + String random = Long.toUnsignedString(rng.nextLong(), 16) + + "_" + Long.toUnsignedString(rng.nextLong(), 16); return normalized + "_" + random; } } 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 93ed7823d..7ffd950bc 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 @@ -268,7 +268,8 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions { } public Entity spawn(Engine gen, Location at) { - return spawn(gen, at, new RNG(at.hashCode())); + return spawn(gen, at, EntitySpawnSeed.marker(gen.getSeedManager().getEntity(), + at.getBlockX(), at.getBlockY(), at.getBlockZ())); } public Entity spawn(Engine gen, Location at, RNG rng) { @@ -531,17 +532,19 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions { return specialType != null && !specialType.equals(""); } - private static final class BukkitOps { + static final class BukkitOps { private static void persistVillager(Villager villager) { villager.setPersistent(true); } - private static void bindLoot(IrisEntity entity, Engine gen, Lootable l, Location finalAt, RNG rng) { + static void bindLoot(IrisEntity entity, Engine gen, Lootable l, Location finalAt, RNG rng) { IrisData definitions = gen.getData(); + long lootSeed = EntitySpawnSeed.entity(rng.getSeed(), -2).getSeed(); + NamespacedKey lootKey = new NamespacedKey("iris", "loot-" + Long.toUnsignedString(lootSeed, 16)); l.setLootTable(new LootTable() { @Override public NamespacedKey getKey() { - return new NamespacedKey("iris", "loot-" + entity.hashCode()); + return lootKey; } @Override 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 d898e7c3e..322f2b4c7 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 @@ -54,6 +54,9 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.entity.Entity; +import java.util.Objects; +import java.util.Comparator; + @Snippet("entity-spawn") @Accessors(chain = true) @NoArgsConstructor @@ -63,7 +66,6 @@ import org.bukkit.entity.Entity; 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) @Required @@ -78,10 +80,8 @@ public class IrisEntitySpawn implements Rarity { @MinNumber(1) @Description("The max of this entity to spawn") private int maxSpawns = 1; - private transient IrisSpawner referenceSpawner; - private transient IrisMarker referenceMarker; - public int spawn(Engine gen, Chunk c, RNG rng, int remainingCapacity) { + public int spawn(Engine gen, Chunk chunk, RNG rng, int remainingCapacity, SpawnContext context) { if (remainingCapacity <= 0) { return 0; } @@ -89,42 +89,39 @@ public class IrisEntitySpawn implements Rarity { if (definition == null) { return 0; } - int spawns = Math.min(remainingCapacity, LootResolver.inclusive(rng, minSpawns, maxSpawns)); - int s = 0; - - if (spawns > 0) { - for (int id = 0; id < spawns; id++) { - IrisSpawnGroup group = getReferenceSpawner().getGroup(); - 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) { - if (referenceSpawner.getAllowedLightLevels().contains(l.getBlock().getLightLevel())) { - if (spawn100(gen, l) != null) { - s++; - } - } - } else { - if (spawn100(gen, l) != null) { - s++; - } - } - } + long batchSeed = rng.getSeed(); + int count = Math.min(remainingCapacity, LootResolver.inclusive(rng, minSpawns, maxSpawns)); + IrisSpawnGroup group = context.spawner().getGroup(); + KList caveMarkers = null; + if (context.initial() && group == IrisSpawnGroup.CAVE && !J.isFolia()) { + caveMarkers = gen.getMantle().findMarkers(chunk.getX(), chunk.getZ(), MarkerMatter.CAVE_FLOOR); + caveMarkers.sort(Comparator.comparingInt(IrisPosition::getX) + .thenComparingInt(IrisPosition::getY).thenComparingInt(IrisPosition::getZ)); + } + int spawned = 0; + for (int ordinal = 0; ordinal < count; ordinal++) { + RNG attemptRng = EntitySpawnSeed.entity(batchSeed, ordinal); + RNG positionRng = attemptRng.nextParallelRNG(0x632BE59BD9B4E019L); + Location location; + if (group == IrisSpawnGroup.CAVE) { + location = caveMarkers == null + ? findCaveSpawnLocation(gen, chunk, positionRng, definition.getSurface()) + : selectCaveSpawnLocation(caveMarkers, chunk.getWorld(), positionRng); + } else { + int x = (chunk.getX() << 4) + positionRng.i(16); + int z = (chunk.getZ() << 4) + positionRng.i(16); + World world = chunk.getWorld(); + int floor = world.getHighestBlockYAt(x, z, HeightMap.OCEAN_FLOOR); + int top = world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE); + Integer y = selectSurfaceSpawnY(group, definition.getSurface(), floor, top, positionRng); + location = y == null ? null : new Location(world, x, y, z); + } + if (location != null && lightAllowed(location, context) + && spawn100(gen, location, attemptRng, false) != null) { + spawned++; } } - - return s; + return spawned; } public static Integer selectSurfaceSpawnY(IrisSpawnGroup group, IrisSurface surface, @@ -138,43 +135,41 @@ public class IrisEntitySpawn implements Rarity { return top > floor ? LootResolver.inclusive(rng, floor + 1, top) : null; } - public int spawn(Engine gen, IrisPosition c, RNG rng) { - int spawns = LootResolver.inclusive(rng, minSpawns, maxSpawns); - int s = 0; - - if (!BukkitWorldBinding.tryBind(gen.getWorld())) { + public int spawn(Engine gen, IrisPosition position, RNG rng, SpawnContext context) { + long batchSeed = rng.getSeed(); + int count = LootResolver.inclusive(rng, minSpawns, maxSpawns); + if (count <= 0 || !BukkitWorldBinding.tryBind(gen.getWorld())) { return 0; } - World world = BukkitWorldBinding.world(gen.getWorld()); - if (spawns > 0) { - - if (referenceMarker != null && referenceMarker.shouldExhaust()) { - if (J.isFolia()) { - J.a(() -> gen.getMantle().getMantle().remove(c.getX(), c.getY() - gen.getWorld().minHeight(), c.getZ(), MatterMarker.class)); - } else { - gen.getMantle().getMantle().remove(c.getX(), c.getY() - gen.getWorld().minHeight(), c.getZ(), MatterMarker.class); - } - } - - for (int id = 0; id < spawns; id++) { - Location l = BukkitPlatform.toLocation(c, world).add(0, 1, 0); - - if (referenceSpawner.getAllowedLightLevels().getMin() > 0 || referenceSpawner.getAllowedLightLevels().getMax() < 15) { - if (referenceSpawner.getAllowedLightLevels().contains(l.getBlock().getLightLevel())) { - if (spawn100(gen, l, true) != null) { - s++; - } - } - } else { - if (spawn100(gen, l, true) != null) { - s++; - } - } + if (context.marker() != null && context.marker().shouldExhaust(EntitySpawnSeed.entity(batchSeed, -1))) { + if (J.isFolia()) { + J.a(() -> gen.getMantle().getMantle().remove(position.getX(), + position.getY() - gen.getWorld().minHeight(), position.getZ(), MatterMarker.class)); + } else { + gen.getMantle().getMantle().remove(position.getX(), + position.getY() - gen.getWorld().minHeight(), position.getZ(), MatterMarker.class); } } + int spawned = 0; + for (int ordinal = 0; ordinal < count; ordinal++) { + Location location = BukkitPlatform.toLocation(position, world).add(0, 1, 0); + if (lightAllowed(location, context) + && spawn100(gen, location, EntitySpawnSeed.entity(batchSeed, ordinal), true) != null) { + spawned++; + } + } + return spawned; + } - return s; + private static boolean lightAllowed(Location location, SpawnContext context) { + if (context.initial()) { + return true; + } + IrisSpawner spawner = context.spawner(); + return spawner.getAllowedLightLevels().getMin() <= 0 + && spawner.getAllowedLightLevels().getMax() >= 15 + || spawner.getAllowedLightLevels().contains(location.getBlock().getLightLevel()); } public IrisEntity getRealEntity(Engine g) { @@ -191,16 +186,13 @@ public class IrisEntitySpawn implements Rarity { return entity == null || entity.isCompatExcluded() ? null : entity; } - public Entity spawn(Engine g, Location at) { - if (getRealEntity(g) == null) { + public Entity spawn(Engine engine, Location at) { + if (getRealEntity(engine) == null) { return null; } - - if (LootResolver.oneIn(rng.aquire(() -> new RNG(g.getSeedManager().getEntity())), getRarity())) { - return spawn100(g, at); - } - - return null; + RNG rng = EntitySpawnSeed.marker(engine.getSeedManager().getEntity(), + at.getBlockX(), at.getBlockY(), at.getBlockZ()); + return LootResolver.oneIn(rng, getRarity()) ? spawn100(engine, at, rng, false) : null; } static Location findCaveSpawnLocation(Engine engine, Chunk chunk, RNG rng, IrisSurface surface) { @@ -249,11 +241,7 @@ public class IrisEntitySpawn implements Rarity { return marker == null ? null : BukkitPlatform.toLocation(marker, world); } - private Entity spawn100(Engine g, Location at) { - return spawn100(g, at, false); - } - - private Entity spawn100(Engine g, Location at, boolean ignoreSurfaces) { + private Entity spawn100(Engine g, Location at, RNG rng, boolean ignoreSurfaces) { try { IrisEntity irisEntity = getRealEntity(g); if (irisEntity == null) { // No entity @@ -278,7 +266,7 @@ public class IrisEntitySpawn implements Rarity { } } - Entity e = irisEntity.spawn(g, at.clone().add(0.5, surface.isFluid() ? 0.5 : 0, 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); 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()); } @@ -291,6 +279,12 @@ public class IrisEntitySpawn implements Rarity { } } + public record SpawnContext(IrisSpawner spawner, IrisMarker marker, boolean initial) { + public SpawnContext { + Objects.requireNonNull(spawner, "spawner"); + } + } + private static boolean isAir(Material material) { return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR; } diff --git a/core/src/main/java/art/arcane/iris/world/entity/IrisMarker.java b/core/src/main/java/art/arcane/iris/world/entity/IrisMarker.java index 55f2b324c..82ad239b8 100644 --- a/core/src/main/java/art/arcane/iris/world/entity/IrisMarker.java +++ b/core/src/main/java/art/arcane/iris/world/entity/IrisMarker.java @@ -51,8 +51,8 @@ public class IrisMarker extends IrisRegistrant { @Description("If this marker is used, what is the chance it removes itself. For example 25% (0.25) would mean that on average 4 uses will remove a specific marker. Set this below 0 (-1) to never exhaust & set this to 1 or higher to always exhaust on first use.") private double exhaustionChance = 0; - public boolean shouldExhaust() { - return exhaustionChance > RNG.r.nextDouble(); + public boolean shouldExhaust(RNG rng) { + return exhaustionChance > rng.nextDouble(); } @Override 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 bd2535069..58d666f76 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 @@ -49,36 +49,33 @@ import java.util.Map; @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor -@Description("Represents an entity spawn during initial chunk generation") +@Description("Configures initial inhabitants and ongoing entity spawning") @Data public class IrisSpawner extends IrisRegistrant { - - private transient IrisMarker referenceMarker; - @ArrayType(min = 1, type = IrisEntitySpawn.class) @Description("The entity spawns to add") private KList spawns = new KList<>(); @ArrayType(min = 1, type = IrisEntitySpawn.class) - @Description("The entity spawns to add initially. EXECUTES PER CHUNK!") + @Description("The seeded entity pool used once per chunk or marker for initial inhabitants. Live time, weather, light, population, and cooldowns do not restrict this pool.") private KList initialSpawns = new KList<>(); - @Description("Maximum ambient entities of the selected entity's native spawn category in one chunk. Spawn groups are limited to the remaining capacity.") + @Description("Maximum ambient entities of the selected entity's native spawn category in one chunk. Ongoing groups use remaining capacity; initial groups use this limit independently of existing mobs.") private int maxEntitiesPerChunk = 1; - @Description("The block of 24 hour time to contain this spawn in.") + @Description("The 24-hour time window for ongoing spawns.") private IrisTimeBlock timeBlock = new IrisTimeBlock(); - @Description("The weather condition required for this spawner to fire.") + @Description("The weather condition required for ongoing spawns.") private IrisWeather weather = IrisWeather.ANY; - @Description("The maximum rate this spawner can fire") + @Description("The maximum ongoing spawn rate") private IrisRate maximumRate = new IrisRate(); - @Description("The maximum rate this spawner can fire on a specific chunk") + @Description("The maximum ongoing spawn rate in a specific chunk") private IrisRate maximumRatePerChunk = new IrisRate(); - @Description("The light levels this spawn is allowed to run in (0-15 inclusive)") + @Description("The light levels allowed for ongoing spawns (0-15 inclusive). Initial inhabitants ignore light.") private IrisRange allowedLightLevels = new IrisRange(0, 15); @Description("Where should these spawns be placed") diff --git a/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java b/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java index 43aafa3cf..da3c7c274 100644 --- a/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java +++ b/core/src/test/java/art/arcane/iris/generation/chunk/LinkedTerrainChunkTest.java @@ -20,7 +20,6 @@ import org.mockito.MockedStatic; import java.lang.reflect.Proxy; import java.util.Random; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -35,18 +34,7 @@ public class LinkedTerrainChunkTest { @BeforeClass public static void initializeBiomes() 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") - ? biome((Key) arguments[0]) : null)); - } - return registered.get(); - } - return RETURNS_DEFAULTS.answer(invocation); - }); + RegistryAccess access = BukkitTestServer.registryAccess(); InternalAPIBridge bridge = mock(InternalAPIBridge.class, invocation -> invocation.getMethod().getName().equals("constructLegacyCustomBiome") ? biome(Key.key("minecraft:custom")) : RETURNS_DEFAULTS.answer(invocation)); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldChunkMaintenanceInitialSpawnTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldChunkMaintenanceInitialSpawnTest.java new file mode 100644 index 000000000..6dbaf2aca --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldChunkMaintenanceInitialSpawnTest.java @@ -0,0 +1,211 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.platform.bukkit.plugin.Chunks; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import org.bukkit.GameRules; +import org.bukkit.World; +import org.junit.Test; +import org.junit.BeforeClass; +import org.mockito.MockedStatic; + +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +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.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorldChunkMaintenanceInitialSpawnTest { + @BeforeClass + public static void initializeGameRules() throws Exception { + WorldEntitySpawnerAdmissionTest.initializeGameRules(); + } + + @Test + public void completionIsWrittenAfterOwningRegionWorkAndDuplicateRequestsAreSkipped() { + try (Fixture fixture = new Fixture()) { + AtomicInteger attempts = new AtomicInteger(); + fixture.start(attempts::incrementAndGet); + fixture.start(attempts::incrementAndGet); + assertEquals(1, fixture.async.size()); + fixture.async.remove().run(); + verify(fixture.world, never()).isChunkLoaded(2, -1); + verify(fixture.mantle, never()).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true); + + fixture.regions.remove().run(); + + assertEquals(1, attempts.get()); + assertTrue(fixture.maintenance.isInitialSpawnComplete(2, -1)); + verify(fixture.mantle, never()).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true); + fixture.start(attempts::incrementAndGet); + assertTrue(fixture.regions.isEmpty()); + fixture.async.remove().run(); + verify(fixture.mantle).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true); + } + } + + @Test + public void unloadAndDisabledMobSpawningRemainEligibleForRetry() { + for (boolean unloaded : new boolean[]{true, false}) { + try (Fixture fixture = new Fixture()) { + AtomicInteger attempts = new AtomicInteger(); + when(fixture.world.isChunkLoaded(2, -1)).thenReturn(!unloaded); + when(fixture.world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(unloaded); + fixture.start(attempts::incrementAndGet); + fixture.async.remove().run(); + fixture.regions.remove().run(); + assertFalse(fixture.maintenance.isInitialSpawnComplete(2, -1)); + assertEquals(0, attempts.get()); + verify(fixture.mantle, never()).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true); + + when(fixture.world.isChunkLoaded(2, -1)).thenReturn(true); + when(fixture.world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true); + fixture.start(attempts::incrementAndGet); + fixture.async.remove().run(); + fixture.regions.remove().run(); + assertEquals(1, attempts.get()); + } + } + } + + @Test + public void persistedCompletionAndRegionRejectionDoNotRunInitialWork() { + try (Fixture fixture = new Fixture()) { + AtomicInteger attempts = new AtomicInteger(); + when(fixture.mantle.hasFlag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER)).thenReturn(true); + fixture.start(attempts::incrementAndGet); + fixture.async.remove().run(); + assertTrue(fixture.maintenance.isInitialSpawnComplete(2, -1)); + assertTrue(fixture.regions.isEmpty()); + assertEquals(0, attempts.get()); + } + try (Fixture fixture = new Fixture()) { + AtomicInteger attempts = new AtomicInteger(); + fixture.acceptRegions = false; + fixture.start(attempts::incrementAndGet); + fixture.async.remove().run(); + fixture.start(attempts::incrementAndGet); + assertEquals(1, fixture.async.size()); + assertFalse(fixture.maintenance.isInitialSpawnComplete(2, -1)); + assertEquals(0, attempts.get()); + } + } + + @Test + public void shutdownDrainWaitsForTheReservedCompletionWriteAfterInitialEntitiesWereEmitted() throws Exception { + try (Fixture fixture = new Fixture()) { + AtomicInteger spawned = new AtomicInteger(); + fixture.start(spawned::incrementAndGet); + fixture.async.remove().run(); + try (GenerationSessionLease owner = fixture.sessions.acquire("initial_spawn_owner"); + IrisContext.Scope ignored = IrisContext.open(fixture.engine, owner.sessionId(), null)) { + fixture.regions.remove().run(); + } + assertEquals(1, spawned.get()); + assertEquals(1, fixture.sessions.activeLeases()); + when(fixture.engine.isClosing()).thenReturn(true); + ExecutorService closer = Executors.newSingleThreadExecutor(); + try { + CountDownLatch sealing = new CountDownLatch(1); + Future drained = closer.submit(() -> { + sealing.countDown(); + fixture.sessions.sealAndAwait("shutdown", 2_000L, true); + return null; + }); + assertTrue(sealing.await(1L, TimeUnit.SECONDS)); + assertFalse(drained.isDone()); + fixture.async.remove().run(); + drained.get(1L, TimeUnit.SECONDS); + + verify(fixture.mantle).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true); + assertEquals(0, fixture.sessions.activeLeases()); + } finally { + closer.shutdownNow(); + } + } + } + + @SuppressWarnings("unchecked") + private static final class Fixture implements AutoCloseable { + private final Engine engine = mock(Engine.class); + private final GenerationSessionManager sessions = new GenerationSessionManager(); + private final IrisWorldManager manager = mock(IrisWorldManager.class); + private final World world = mock(World.class); + private final Mantle mantle = mock(Mantle.class); + private final WorldChunkMaintenance maintenance = new WorldChunkMaintenance(manager); + private final Queue async = new ArrayDeque<>(); + private final Queue regions = new ArrayDeque<>(); + private final MockedStatic scheduler; + private final MockedStatic chunks; + private boolean acceptRegions = true; + + private Fixture() { + scheduler = mockStatic(J.class); + try { + chunks = mockStatic(Chunks.class); + } catch (RuntimeException | Error failure) { + scheduler.close(); + throw failure; + } + try { + configure(); + } catch (RuntimeException | Error failure) { + close(); + throw failure; + } + } + + private void configure() { + when(manager.getEngine()).thenReturn(engine); + when(engine.getGenerationSessions()).thenReturn(sessions); + when(manager.getMantle()).thenReturn(mantle); + when(manager.managedTask(anyString(), any(Runnable.class), any(Runnable.class))) + .thenAnswer(invocation -> invocation.getArgument(1)); + when(world.isChunkLoaded(2, -1)).thenReturn(true); + when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true); + chunks.when(() -> Chunks.isSafe(world, 2, -1)).thenReturn(true); + scheduler.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> { + async.add(invocation.getArgument(0)); + return null; + }); + scheduler.when(() -> J.runRegion(eq(world), eq(2), eq(-1), any(Runnable.class))) + .thenAnswer(invocation -> { + if (acceptRegions) { + regions.add(invocation.getArgument(3)); + } + return acceptRegions; + }); + } + + private void start(Runnable work) { + maintenance.runInitialSpawn(world, 2, -1, work); + } + + @Override + public void close() { + while (!async.isEmpty()) { + async.remove().run(); + } + chunks.close(); + scheduler.close(); + } + } +} 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 index 7edd9bd1a..68af9ff48 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerAdmissionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerAdmissionTest.java @@ -4,20 +4,15 @@ 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; @@ -26,19 +21,7 @@ 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); - }); + RegistryAccess access = BukkitTestServer.registryAccess(); InternalAPIBridge bridge = mock(InternalAPIBridge.class); try (MockedStatic registries = mockStatic(RegistryAccess.class); MockedStatic internals = mockStatic(InternalAPIBridge.class)) { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerBiomeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerBiomeTest.java index f3c3a090a..d34bbb431 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerBiomeTest.java @@ -7,6 +7,7 @@ import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.world.entity.IrisEntitySpawn; +import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext; import art.arcane.iris.pack.value.IrisPosition; import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.world.entity.IrisSpawner; @@ -54,9 +55,9 @@ public class WorldEntitySpawnerBiomeTest { configured.when(IrisSettings::get).thenReturn(settings); WorldEntitySpawner spawner = new WorldEntitySpawner(manager); spawner.prepareInitialSpawn(chunk, List.of()); - verify(maintenance, never()).raiseInitialSpawnMarkerFlag(any(), anyInt(), anyInt(), any()); + verify(maintenance, never()).runInitialSpawn(any(), anyInt(), anyInt(), any()); spawner.prepareInitialSpawn(chunk, List.of()); - verify(maintenance).raiseInitialSpawnMarkerFlag(eq(world), eq(2), eq(-1), any()); + verify(maintenance).runInitialSpawn(eq(world), eq(2), eq(-1), any()); } } @@ -76,11 +77,10 @@ public class WorldEntitySpawnerBiomeTest { when(definition.getInitialSpawns()).thenReturn(new KList<>(entity)); when(definition.canSpawn(engine, 0, 0)).thenReturn(true); when(entity.getRarity()).thenReturn(1); - when(entity.getReferenceSpawner()).thenReturn(definition); - when(entity.spawn(eq(engine), eq(position), any(RNG.class))).thenReturn(1); + when(entity.spawn(eq(engine), eq(position), any(RNG.class), any(SpawnContext.class))).thenReturn(1); KSet definitions = new KSet<>(); definitions.add(definition); - PreparedMarkerSpawn marker = new PreparedMarkerSpawn(position, definitions, environment); + PreparedMarkerSpawn marker = new PreparedMarkerSpawn(position, definitions, environment, null); IrisSettings settings = new IrisSettings(); settings.getWorld().setAmbientEntitySpawningSystem(false); @@ -88,11 +88,11 @@ public class WorldEntitySpawnerBiomeTest { configured.when(IrisSettings::get).thenReturn(settings); new WorldEntitySpawner(manager).prepareInitialSpawn(chunk, List.of(marker)); ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); - verify(manager.chunkMaintenance).raiseInitialSpawnMarkerFlag(eq(world), eq(0), eq(0), callback.capture()); + verify(manager.chunkMaintenance).runInitialSpawn(eq(world), eq(0), eq(0), callback.capture()); doThrow(new SavedBiomeUnavailableException("Evicted", true)).when(engine) .getBiomeEnvironment(anyInt(), anyInt(), anyInt()); callback.getValue().run(); - verify(entity).spawn(eq(engine), eq(position), any(RNG.class)); + verify(entity).spawn(eq(engine), eq(position), any(RNG.class), any(SpawnContext.class)); verify(engine).openBiomeEnvironmentScope(environment); verify(scope).close(); verify(engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt()); @@ -102,6 +102,7 @@ public class WorldEntitySpawnerBiomeTest { private static IrisWorldManager manager(Engine engine) throws Exception { IrisWorldManager manager = mock(IrisWorldManager.class); when(manager.getEngine()).thenReturn(engine); + when(engine.getSeedManager()).thenReturn(mock(SeedManager.class)); Field field = IrisWorldManager.class.getDeclaredField("chunkMaintenance"); field.setAccessible(true); field.set(manager, mock(WorldChunkMaintenance.class)); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerInitialSeedTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerInitialSeedTest.java new file mode 100644 index 000000000..88b65137d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerInitialSeedTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.world.entity.IrisEntity; +import art.arcane.iris.world.entity.IrisEntitySpawn; +import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext; +import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.Chunk; +import org.bukkit.GameRules; +import org.bukkit.World; +import org.junit.Test; +import org.junit.BeforeClass; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +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.anyInt; +import static org.mockito.ArgumentMatchers.eq; +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 WorldEntitySpawnerInitialSeedTest { + @BeforeClass + public static void initializeGameRules() throws Exception { + WorldEntitySpawnerAdmissionTest.initializeGameRules(); + } + + @Test + public void initialPopulationUsesAuthoredCapacityWithoutLiveGatesOrPopulationReads() throws Exception { + Fixture fixture = new Fixture(); + fixture.spawn(2, -3); + + assertEquals(1, fixture.observed.size()); + Observation observation = fixture.observed.get("2,-3"); + assertEquals(4, observation.capacity()); + assertTrue(observation.initial()); + verify(fixture.definition, never()).canSpawn(any(Engine.class), anyInt(), anyInt()); + verify(fixture.definition, never()).spawn(any(Engine.class), anyInt(), anyInt()); + verify(fixture.lastChunk, never()).getEntities(); + } + + @Test + public void chunkBatchSeedsAreIndependentOfVisitOrderAndUnrelatedRandomActivity() throws Exception { + Fixture fixture = new Fixture(); + for (int coordinate = -16; coordinate < 16; coordinate++) { + fixture.spawn(coordinate, -coordinate - 1); + } + Map forward = new HashMap<>(fixture.observed); + fixture.observed.clear(); + for (int coordinate = 15; coordinate >= -16; coordinate--) { + for (int draw = 0; draw < 19; draw++) { + RNG.r.nextLong(); + } + fixture.spawn(coordinate, -coordinate - 1); + } + assertEquals(forward, fixture.observed); + assertEquals(32, forward.values().stream().map(Observation::seed).distinct().count()); + } + + @Test + public void initialSpawnerIgnoresCooldownButExcludedContentIsNeverAdmitted() { + Engine engine = mock(Engine.class); + IrisSpawner definition = mock(IrisSpawner.class); + when(definition.canSpawn(engine, 1, 2)).thenReturn(false); + assertTrue(WorldEntitySpawner.spawnerAllowed(definition, engine, 1, 2, true)); + assertFalse(WorldEntitySpawner.spawnerAllowed(definition, engine, 1, 2, false)); + when(definition.isCompatExcluded()).thenReturn(true); + assertFalse(WorldEntitySpawner.spawnerAllowed(definition, engine, 1, 2, true)); + } + + @SuppressWarnings("unchecked") + private static final class Fixture { + private final Engine engine = mock(Engine.class); + private final IrisWorldManager manager = mock(IrisWorldManager.class); + private final World world = mock(World.class); + private final IrisSpawner definition = mock(IrisSpawner.class); + private final IrisEntitySpawn entry = mock(IrisEntitySpawn.class); + private final Map observed = new HashMap<>(); + private final BiomeEnvironment environment; + private final Method spawn; + private final WorldEntitySpawner spawner; + private Chunk lastChunk; + + private Fixture() throws Exception { + SeedManager seeds = mock(SeedManager.class); + when(seeds.getEntity()).thenReturn(918273645L); + when(engine.getSeedManager()).thenReturn(seeds); + when(manager.getEngine()).thenReturn(engine); + IrisData data = mock(IrisData.class); + ResourceLoader loader = mock(ResourceLoader.class); + when(data.getSpawnerLoader()).thenReturn(loader); + KList authored = new KList<>("qa/initial"); + when(loader.loadAll(any(KList.class))).thenReturn(new KList<>()); + when(loader.loadAll(authored)).thenReturn(new KList<>(definition)); + IrisBiome biome = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + IrisDimension dimension = mock(IrisDimension.class); + when(biome.getEntitySpawners()).thenReturn(authored); + when(region.getEntitySpawners()).thenReturn(new KList<>()); + when(dimension.getEntitySpawners()).thenReturn(new KList<>()); + when(definition.getInitialSpawns()).thenReturn(new KList<>(entry)); + when(definition.getMaxEntitiesPerChunk()).thenReturn(4); + when(entry.getRarity()).thenReturn(1); + when(entry.getRealEntity(engine)).thenReturn(mock(IrisEntity.class)); + when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true); + when(entry.spawn(eq(engine), any(Chunk.class), any(RNG.class), anyInt(), any(SpawnContext.class))) + .thenAnswer(invocation -> { + Chunk chunk = invocation.getArgument(1); + RNG rng = invocation.getArgument(2); + int capacity = invocation.getArgument(3); + SpawnContext context = invocation.getArgument(4); + observed.put(chunk.getX() + "," + chunk.getZ(), new Observation(rng.getSeed(), capacity, context.initial())); + return 4; + }); + environment = new BiomeEnvironment(1L, biome, region, dimension, data); + spawner = new WorldEntitySpawner(manager); + spawn = WorldEntitySpawner.class.getDeclaredMethod("spawnAmbient", Chunk.class, boolean.class, BiomeEnvironment.class); + spawn.setAccessible(true); + } + + private void spawn(int x, int z) throws Exception { + lastChunk = mock(Chunk.class); + when(lastChunk.getWorld()).thenReturn(world); + when(lastChunk.getX()).thenReturn(x); + when(lastChunk.getZ()).thenReturn(z); + spawn.invoke(spawner, lastChunk, true, environment); + } + } + + private record Observation(long seed, int capacity, boolean initial) { + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerMarkerSeedTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerMarkerSeedTest.java new file mode 100644 index 000000000..ef3711952 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerMarkerSeedTest.java @@ -0,0 +1,98 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.MarkerSpawnScanner.PreparedMarkerSpawn; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisPosition; +import art.arcane.iris.world.entity.IrisEntitySpawn; +import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext; +import art.arcane.iris.world.entity.IrisMarker; +import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KSet; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorldEntitySpawnerMarkerSeedTest { + @Test + public void initialMarkersIgnoreHashSetInsertionOrderAndOtherChunksRandomDraws() throws Exception { + Engine engine = mock(Engine.class); + SeedManager seeds = mock(SeedManager.class); + when(engine.getSeedManager()).thenReturn(seeds); + when(seeds.getEntity()).thenReturn(145398276L); + IrisWorldManager manager = mock(IrisWorldManager.class); + when(manager.getEngine()).thenReturn(engine); + BiomeEnvironment environment = new BiomeEnvironment(1L, mock(IrisBiome.class), mock(IrisRegion.class), + mock(IrisDimension.class), mock(IrisData.class)); + when(engine.openBiomeEnvironmentScope(environment)).thenReturn(mock(BiomeEnvironment.Scope.class)); + Map observed = new HashMap<>(); + IrisMarker marker = new IrisMarker(); + IrisSpawner first = definition("qa/a", engine, marker, observed); + IrisSpawner second = definition("qa/b", engine, marker, observed); + KSet forward = new KSet<>(); + forward.add(first); + forward.add(second); + KSet reversed = new KSet<>(); + reversed.add(second); + reversed.add(first); + Method spawn = WorldEntitySpawner.class.getDeclaredMethod("spawnPreparedMarker", PreparedMarkerSpawn.class, boolean.class); + spawn.setAccessible(true); + WorldEntitySpawner spawner = new WorldEntitySpawner(manager); + for (int x = -32; x < 32; x++) { + IrisPosition position = new IrisPosition(x * 16 + 1, -49, -x * 16 + 3); + spawn.invoke(spawner, new PreparedMarkerSpawn(position, forward, environment, marker), true); + } + Map expected = new HashMap<>(observed); + observed.clear(); + for (int x = 31; x >= -32; x--) { + RNG.r.nextLong(); + IrisPosition position = new IrisPosition(x * 16 + 1, -49, -x * 16 + 3); + spawn.invoke(spawner, new PreparedMarkerSpawn(position, reversed, environment, marker), true); + } + assertEquals(expected, observed); + assertEquals(64, observed.size()); + assertTrue(observed.values().stream().allMatch(Observation::initial)); + verify(first, never()).canSpawn(any(Engine.class), anyInt(), anyInt()); + verify(second, never()).canSpawn(any(Engine.class), anyInt(), anyInt()); + verify(first, never()).spawn(any(Engine.class), anyInt(), anyInt()); + verify(second, never()).spawn(any(Engine.class), anyInt(), anyInt()); + } + + private static IrisSpawner definition(String key, Engine engine, IrisMarker marker, Map observed) { + IrisSpawner spawner = mock(IrisSpawner.class); + IrisEntitySpawn entry = mock(IrisEntitySpawn.class); + when(spawner.getLoadKey()).thenReturn(key); + when(spawner.getInitialSpawns()).thenReturn(new KList<>(entry)); + when(entry.getRarity()).thenReturn(1); + when(entry.spawn(eq(engine), any(IrisPosition.class), any(RNG.class), any(SpawnContext.class))) + .thenAnswer(invocation -> { + IrisPosition position = invocation.getArgument(1); + RNG rng = invocation.getArgument(2); + SpawnContext context = invocation.getArgument(3); + assertEquals(marker, context.marker()); + observed.put(position.getX() + "," + position.getY() + "," + position.getZ(), + new Observation(context.spawner().getLoadKey(), rng.getSeed(), context.initial())); + return 1; + }); + return spawner; + } + + private record Observation(String definition, long seed, boolean initial) { + } +} 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 index 17b3b9247..98b1c1cae 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerPopulationTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerPopulationTest.java @@ -35,15 +35,15 @@ public class WorldEntitySpawnerPopulationTest { IrisEntity definition = mock(IrisEntity.class); when(definition.spawnCategory()).thenReturn("monster"); when(entry.getRealEntity(engine)).thenReturn(definition); - when(entry.getReferenceSpawner()).thenReturn(new IrisSpawner().setMaxEntitiesPerChunk(2)); + IrisSpawner spawner = 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)); + assertEquals(1, counter.remainingCapacity(entry, spawner, engine)); when(definition.spawnCategory()).thenReturn("creature"); - assertEquals(0, counter.remainingCapacity(entry, engine)); + assertEquals(0, counter.remainingCapacity(entry, spawner, engine)); } } diff --git a/core/src/test/java/art/arcane/iris/studio/generation/ObjectStudioGeometryTest.java b/core/src/test/java/art/arcane/iris/studio/generation/ObjectStudioGeometryTest.java index 46960259e..15ec6e69e 100644 --- a/core/src/test/java/art/arcane/iris/studio/generation/ObjectStudioGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/studio/generation/ObjectStudioGeometryTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.studio.generation; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.BukkitTestServer; import art.arcane.iris.pack.loading.ResourceLoader; import art.arcane.iris.studio.object.ObjectStudioLayout; import art.arcane.iris.studio.object.ObjectStudioSaveService; @@ -38,7 +39,6 @@ import java.nio.file.Files; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -59,19 +59,9 @@ public class ObjectStudioGeometryTest { public final TemporaryFolder temporary = new TemporaryFolder(); @BeforeClass - @SuppressWarnings("unchecked") public static void initializeBukkit() throws Exception { PreObjectMatterTest.setUpBukkit(); - AtomicReference> registered = new AtomicReference<>(); - RegistryAccess access = mock(RegistryAccess.class, invocation -> { - if (invocation.getMethod().getName().equals("getRegistry")) { - if (registered.get() == null) { - registered.set(biomeRegistry()); - } - return registered.get(); - } - return RETURNS_DEFAULTS.answer(invocation); - }); + RegistryAccess access = BukkitTestServer.registryAccess(); InternalAPIBridge bridge = mock(InternalAPIBridge.class, invocation -> { if (invocation.getMethod().getName().equals("constructLegacyCustomBiome")) { return biome(Key.key("minecraft:custom")); @@ -89,20 +79,6 @@ public class ObjectStudioGeometryTest { } } - @SuppressWarnings("unchecked") - private static Registry biomeRegistry() { - return (Registry) Proxy.newProxyInstance(Registry.class.getClassLoader(), - new Class[]{Registry.class}, - (proxy, method, arguments) -> switch (method.getName()) { - case "getOrThrow" -> biome((Key) arguments[0]); - case "hashCode" -> System.identityHashCode(proxy); - case "equals" -> proxy == arguments[0]; - case "toString" -> "iris-object-studio-biome-registry"; - case "size" -> 0; - case "hasTag" -> false; - default -> null; - }); - } @Test public void jungleClutterSignedBoundsStayInsideTheEditableCellAboveTheFloor() throws Exception { diff --git a/core/src/test/java/art/arcane/iris/world/entity/EntitySpawnSeedTest.java b/core/src/test/java/art/arcane/iris/world/entity/EntitySpawnSeedTest.java new file mode 100644 index 000000000..acf82595e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/entity/EntitySpawnSeedTest.java @@ -0,0 +1,63 @@ +package art.arcane.iris.world.entity; + +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +public class EntitySpawnSeedTest { + @Test + public void coordinateAndAttemptSeedsRemainPinned() { + assertEquals(-4130335506775599505L, EntitySpawnSeed.chunk(0L, 0, 0).getSeed()); + assertEquals(8337887711347547470L, EntitySpawnSeed.chunk(1337L, -2, 3).getSeed()); + assertEquals(7508281853961706446L, + EntitySpawnSeed.chunk(-1L, Integer.MAX_VALUE, Integer.MIN_VALUE).getSeed()); + assertEquals(-2305592493329120404L, EntitySpawnSeed.marker(0L, 0, 0, 0).getSeed()); + assertEquals(8373759002518926811L, EntitySpawnSeed.marker(1337L, -32, -64, 48).getSeed()); + assertEquals(-8713573891296014035L, + EntitySpawnSeed.marker(-1L, Integer.MAX_VALUE, Integer.MIN_VALUE, 1).getSeed()); + assertEquals(-6302458730787398690L, EntitySpawnSeed.entity(1337L, -1).getSeed()); + assertEquals(858402071020777840L, EntitySpawnSeed.entity(1337L, 0).getSeed()); + assertEquals(-632476273033191086L, EntitySpawnSeed.entity(1337L, 1).getSeed()); + assertEquals(4034378215462583149L, EntitySpawnSeed.entity(1337L, 17).getSeed()); + } + + @Test + public void consumptionAndReverseOrderDoNotChangeSiblingAttempts() { + long batchSeed = EntitySpawnSeed.chunk(1337L, -2, 3).getSeed(); + List forward = new ArrayList<>(); + for (int ordinal = 0; ordinal < 16; ordinal++) { + forward.add(EntitySpawnSeed.entity(batchSeed, ordinal).nextLong()); + } + for (int ordinal = 15; ordinal >= 0; ordinal--) { + RNG sibling = EntitySpawnSeed.entity(batchSeed, ordinal - 1); + for (int draw = 0; draw < 100; draw++) { + sibling.nextLong(); + } + assertEquals(forward.get(ordinal).longValue(), EntitySpawnSeed.entity(batchSeed, ordinal).nextLong()); + } + assertNotEquals(EntitySpawnSeed.marker(1337L, -32, -64, 48).getSeed(), + EntitySpawnSeed.marker(1337L, -32, -63, 48).getSeed()); + assertNotEquals(EntitySpawnSeed.chunk(1337L, -2, 3).getSeed(), + EntitySpawnSeed.chunk(1338L, -2, 3).getSeed()); + } + + @Test + public void concurrentCoordinateEvaluationMatchesSerialEvaluation() { + List> results = new ArrayList<>(); + for (int ordinal = 0; ordinal < 64; ordinal++) { + int index = ordinal; + results.add(CompletableFuture.supplyAsync(() -> EntitySpawnSeed.marker( + 42L, index - 32, -64 + index, -index).nextLong())); + } + for (int ordinal = 63; ordinal >= 0; ordinal--) { + assertEquals(EntitySpawnSeed.marker(42L, ordinal - 32, -64 + ordinal, -ordinal).nextLong(), + results.get(ordinal).join().longValue()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/world/entity/IrisAttributeModifierSeedTest.java b/core/src/test/java/art/arcane/iris/world/entity/IrisAttributeModifierSeedTest.java new file mode 100644 index 000000000..d461b3a11 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/entity/IrisAttributeModifierSeedTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.world.entity; + +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +public class IrisAttributeModifierSeedTest { + @Test + public void repeatedSeedProducesIdenticalModifierKeyAmountAndOperation() { + IrisAttributeModifier definition = new IrisAttributeModifier().setName("Custom Bonus") + .setMinAmount(-3).setMaxAmount(7).setOperation("ADD_SCALAR"); + AttributeModifier first = definition.createModifier(new RNG(1337L)); + AttributeModifier repeated = definition.createModifier(new RNG(1337L)); + assertEquals(first.getKey(), repeated.getKey()); + assertEquals("minecraft:custom_bonus_a8f1288a2c8a93b8_b072351edfd5484c", first.getKey().toString()); + assertEquals(first.getAmount(), repeated.getAmount(), 0D); + assertEquals(5.832726771624211D, first.getAmount(), 0D); + assertEquals(AttributeModifier.Operation.ADD_SCALAR, first.getOperation()); + assertTrue(first.getKey().getKey().startsWith("custom_bonus_")); + assertTrue(first.getAmount() >= -3D && first.getAmount() <= 7D); + } + + @Test + public void sameNamedModifiersRemainDistinctWithinOneSeededSequence() { + IrisAttributeModifier definition = new IrisAttributeModifier().setName("Bonus"); + RNG rng = new RNG(1337L); + AttributeModifier first = definition.createModifier(rng); + AttributeModifier second = definition.createModifier(rng); + assertNotEquals(first.getKey(), second.getKey()); + assertNotEquals(first.getKey(), definition.createModifier(new RNG(1338L)).getKey()); + } + + @Test + public void zeroChanceNeverAppliesAModifier() { + ItemMeta metadata = mock(ItemMeta.class); + IrisAttributeModifier definition = new IrisAttributeModifier().setChance(0D); + definition.apply(new RNG(1337L), metadata); + verifyNoInteractions(metadata); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/entity/IrisEntityLootSeedTest.java b/core/src/test/java/art/arcane/iris/world/entity/IrisEntityLootSeedTest.java new file mode 100644 index 000000000..325d997d7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/entity/IrisEntityLootSeedTest.java @@ -0,0 +1,49 @@ +package art.arcane.iris.world.entity; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.loot.LootTable; +import org.bukkit.loot.Lootable; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class IrisEntityLootSeedTest { + @Test + public void lootKeyDependsOnSpawnSeedRatherThanEntityHashOrWorldIdentity() { + IrisEntity first = new IrisEntity().setCustomName("First"); + IrisEntity second = new IrisEntity().setCustomName("Second"); + assertNotEquals(first.hashCode(), second.hashCode()); + LootTable firstTable = bind(first, new RNG(1337L), mock(World.class)); + LootTable secondTable = bind(second, new RNG(1337L), mock(World.class)); + assertEquals(firstTable.getKey(), secondTable.getKey()); + assertEquals("iris:loot-22371b531cd5acd3", firstTable.getKey().toString()); + assertNotEquals(firstTable.getKey(), bind(first, new RNG(1338L), mock(World.class)).getKey()); + } + + @Test + public void priorCustomizationConsumptionDoesNotChangeTheLootIdentity() { + IrisEntity definition = new IrisEntity(); + RNG consumed = new RNG(1337L); + for (int draw = 0; draw < 100; draw++) { + consumed.nextLong(); + } + assertEquals(bind(definition, new RNG(1337L), mock(World.class)).getKey(), + bind(definition, consumed, mock(World.class)).getKey()); + } + + private static LootTable bind(IrisEntity definition, RNG rng, World world) { + Engine engine = mock(Engine.class); + Lootable lootable = mock(Lootable.class); + IrisEntity.BukkitOps.bindLoot(definition, engine, lootable, new Location(world, -31.5, -63.5, 48.5), rng); + ArgumentCaptor table = ArgumentCaptor.forClass(LootTable.class); + verify(lootable).setLootTable(table.capture()); + return table.getValue(); + } +} 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 5e0f76665..beadd5c7d 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 @@ -2,6 +2,7 @@ package art.arcane.iris.world.entity; import art.arcane.iris.generation.decoration.IrisSurface; import art.arcane.iris.pack.value.IrisPosition; +import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.SeedManager; @@ -29,12 +30,14 @@ import java.util.ArrayList; import java.util.List; 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.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.spy; @@ -381,9 +384,8 @@ public class IrisEntitySpawnTest { IrisPosition position = invocation.getArgument(0); return new Location(fixture.world, position.getX(), position.getY(), position.getZ()); }); - fixture.entry.setReferenceSpawner(new IrisSpawner()); - assertEquals(0, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 78, 0), new RNG(1L))); - assertEquals(3, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 77, 0), new RNG(1L))); + assertEquals(0, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 78, 0), new RNG(1L), new IrisEntitySpawn.SpawnContext(new IrisSpawner(), null, false))); + assertEquals(3, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 77, 0), new RNG(1L), new IrisEntitySpawn.SpawnContext(new IrisSpawner(), null, false))); } } @@ -399,6 +401,96 @@ public class IrisEntitySpawnTest { } } + @Test + public void failedFirstSiblingDoesNotChangeLaterPositionsOrCustomizationSeeds() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) { + assertEquals(3, fixture.spawn(IrisSpawnGroup.NORMAL)); + List expectedPositions = new ArrayList<>(fixture.positions.subList(1, 3)); + List expectedSeeds = new ArrayList<>(fixture.customizationSeeds.subList(1, 3)); + Location rejected = fixture.positions.getFirst(); + Block obstruction = mock(Block.class); + when(obstruction.getType()).thenReturn(Material.STONE); + when(obstruction.isSolid()).thenReturn(true); + when(fixture.world.getBlockAt(rejected.getBlockX(), rejected.getBlockY(), rejected.getBlockZ())) + .thenReturn(obstruction); + fixture.positions.clear(); + fixture.customizationSeeds.clear(); + assertEquals(2, fixture.spawn(IrisSpawnGroup.NORMAL)); + assertEquals(expectedPositions, fixture.positions); + assertEquals(expectedSeeds, fixture.customizationSeeds); + } + } + + @Test + public void initialCavePopulationDoesNotDependOnMarkerStorageOrder() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78)); + MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(J::isFolia).thenReturn(false); + EngineMantle mantle = mock(EngineMantle.class); + when(fixture.engine.getMantle()).thenReturn(mantle); + IrisPosition first = new IrisPosition(7, 79, 9); + IrisPosition second = new IrisPosition(3, 79, 5); + when(mantle.findMarkers(0, 0, MarkerMatter.CAVE_FLOOR)) + .thenReturn(new KList<>(first, second), new KList<>(second, first)); + 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()); + }); + IrisEntitySpawn.SpawnContext context = new IrisEntitySpawn.SpawnContext( + new IrisSpawner().setGroup(IrisSpawnGroup.CAVE), null, true); + assertEquals(3, fixture.entry.spawn(fixture.engine, fixture.chunk, new RNG(1337L), 3, context)); + List expected = new ArrayList<>(fixture.positions); + List expectedSeeds = new ArrayList<>(fixture.customizationSeeds); + fixture.positions.clear(); + fixture.customizationSeeds.clear(); + assertEquals(3, fixture.entry.spawn(fixture.engine, fixture.chunk, new RNG(1337L), 3, context)); + assertEquals(expected, fixture.positions); + assertEquals(expectedSeeds, fixture.customizationSeeds); + verify(mantle, times(2)).findMarkers(0, 0, MarkerMatter.CAVE_FLOOR); + } + } + + @Test + public void initialPopulationIgnoresLiveLightButRecurringPopulationHonorsIt() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) { + fixture.lightLevel = 15; + IrisSpawner spawner = new IrisSpawner().setAllowedLightLevels(new IrisRange(0, 0)); + assertEquals(0, fixture.entry.spawn(fixture.engine, fixture.chunk, new RNG(1337L), 3, + new IrisEntitySpawn.SpawnContext(spawner, null, false))); + assertEquals(3, fixture.entry.spawn(fixture.engine, fixture.chunk, new RNG(1337L), 3, + new IrisEntitySpawn.SpawnContext(spawner, null, true))); + } + } + + @Test + public void markerExhaustionUsesOnlyTheProvidedSeed() { + IrisMarker marker = new IrisMarker().setExhaustionChance(0.5D); + for (long seed = 0; seed < 32; seed++) { + assertEquals(marker.shouldExhaust(new RNG(seed)), marker.shouldExhaust(new RNG(seed))); + } + assertTrue(marker.setExhaustionChance(1D).shouldExhaust(new RNG(1337L))); + assertFalse(marker.setExhaustionChance(-1D).shouldExhaust(new RNG(1337L))); + } + + @Test + public void directEntitySeedDoesNotDependOnWorldObjectIdentity() { + Engine engine = mock(Engine.class); + SeedManager seeds = mock(SeedManager.class); + when(engine.getSeedManager()).thenReturn(seeds); + when(seeds.getEntity()).thenReturn(1337L); + IrisEntity entity = spy(new IrisEntity()); + List captured = new ArrayList<>(); + doAnswer(invocation -> { + RNG rng = invocation.getArgument(2); + captured.add(rng.getSeed()); + return null; + }).when(entity).spawn(eq(engine), any(Location.class), any(RNG.class)); + entity.spawn(engine, new Location(mock(World.class), -31.5, -63.5, 48.5)); + entity.spawn(engine, new Location(mock(World.class), -31.5, -63.5, 48.5)); + assertEquals(List.of(8373759002518926811L, 8373759002518926811L), captured); + } + private record SpawnOptions(IrisSurface surface, Material fluid, int floor, int top) { } @@ -408,6 +500,8 @@ public class IrisEntitySpawnTest { private final Chunk chunk = mock(Chunk.class); private final IrisEntitySpawn entry = spy(new IrisEntitySpawn()); private final List positions = new ArrayList<>(); + private final List customizationSeeds = new ArrayList<>(); + private int lightLevel; private final MockedStatic platform = mockStatic(BukkitPlatform.class); private Material airMaterial = Material.AIR; private int remainingCapacity = Integer.MAX_VALUE; @@ -430,6 +524,7 @@ public class IrisEntitySpawnTest { Block block = mock(Block.class); when(block.getType()).thenReturn(y <= floor ? Material.STONE : y <= top ? fluid : airMaterial); when(block.isSolid()).thenReturn(y <= floor); + when(block.getLightLevel()).thenReturn((byte) lightLevel); return block; }); when(world.getBlockAt(any(Location.class))).thenAnswer(invocation -> { @@ -439,6 +534,8 @@ public class IrisEntitySpawnTest { when(definition.spawn(eq(engine), any(Location.class), any(RNG.class))).thenAnswer(invocation -> { Location location = invocation.getArgument(1); positions.add(location.clone()); + RNG customization = invocation.getArgument(2); + customizationSeeds.add(customization.getSeed()); Entity spawned = mock(Entity.class); when(spawned.getLocation()).thenReturn(location.clone()); when(spawned.getType()).thenReturn(EntityType.TADPOLE); @@ -451,8 +548,8 @@ public class IrisEntitySpawnTest { } private int spawn(IrisSpawnGroup group) { - entry.setReferenceSpawner(new IrisSpawner().setGroup(group)); - return entry.spawn(engine, chunk, new RNG(1337L), remainingCapacity); + return entry.spawn(engine, chunk, new RNG(1337L), remainingCapacity, + new IrisEntitySpawn.SpawnContext(new IrisSpawner().setGroup(group), null, false)); } @Override diff --git a/core/src/testFixtures/java/art/arcane/iris/testsupport/BukkitTestServer.java b/core/src/testFixtures/java/art/arcane/iris/testsupport/BukkitTestServer.java index 5ecaf1c7f..205d6e7c7 100644 --- a/core/src/testFixtures/java/art/arcane/iris/testsupport/BukkitTestServer.java +++ b/core/src/testFixtures/java/art/arcane/iris/testsupport/BukkitTestServer.java @@ -1,7 +1,14 @@ package art.arcane.iris.testsupport; +import io.papermc.paper.registry.RegistryAccess; +import io.papermc.paper.registry.RegistryKey; +import net.kyori.adventure.key.Key; import org.bukkit.Bukkit; +import org.bukkit.GameRule; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.block.Biome; import org.bukkit.Material; import org.bukkit.Server; import org.bukkit.block.data.BlockData; @@ -11,7 +18,10 @@ import org.junit.runner.Description; import org.junit.runners.model.Statement; import org.mockito.invocation.InvocationOnMock; +import java.lang.reflect.Proxy; import java.util.Locale; +import java.util.HashMap; +import java.util.Map; import java.util.logging.Logger; import static org.mockito.ArgumentMatchers.any; @@ -20,6 +30,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.RETURNS_DEFAULTS; public final class BukkitTestServer { private static final String DEFAULT_NAME = "IrisTestServer"; @@ -71,6 +82,45 @@ public final class BukkitTestServer { } } + public static RegistryAccess registryAccess() { + Map registries = new HashMap<>(); + return mock(RegistryAccess.class, invocation -> { + if (!invocation.getMethod().getName().equals("getRegistry")) { + return RETURNS_DEFAULTS.answer(invocation); + } + Object key = invocation.getArgument(0); + return registries.computeIfAbsent(key, registryKey -> registryProxy(registryKey == RegistryKey.GAME_RULE + || registryKey == GameRule.class)); + }); + } + + private static Object registryProxy(boolean gameRules) { + Map values = new HashMap<>(); + return Proxy.newProxyInstance(Registry.class.getClassLoader(), new Class[]{Registry.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getOrThrow", "get" -> values.computeIfAbsent((Key) arguments[0], + key -> gameRules ? mock(GameRule.class) : biome(key)); + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + case "toString" -> gameRules ? "iris-test-game-rule-registry" : "iris-test-biome-registry"; + case "size" -> values.size(); + case "hasTag" -> false; + default -> null; + }); + } + + private static Biome biome(Key key) { + NamespacedKey namespaced = new NamespacedKey(key.namespace(), key.value()); + return (Biome) Proxy.newProxyInstance(Biome.class.getClassLoader(), new Class[]{Biome.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getKey" -> namespaced; + case "hashCode" -> namespaced.hashCode(); + case "equals" -> proxy == arguments[0]; + case "toString", "name" -> key.value(); + default -> null; + }); + } + public static ServerBranding branding() { return new ServerBranding(); }