mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Restore natural mob spawning
- Restore cave spawning and correct floor placement across platforms. - Enforce category population limits and clamp ambient spawn batches. - Honor mob-spawning rules and entity-ticking chunk admission. - Separate chunk saving from configurable distance-despawn retention. - Add spawning regressions and Folia gameplay acceptance coverage.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
version = '1.0.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly files(providers.gradleProperty('irisJar').get())
|
||||
compileOnly files(providers.gradleProperty('irisGsonJar').get())
|
||||
compileOnly 'io.papermc.paper:paper-api:26.1.2.build.74-stable'
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain.languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java.srcDirs = ['src']
|
||||
resources.srcDirs = ['resources']
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.compilerArgs += '-parameters'
|
||||
}
|
||||
|
||||
tasks.named('jar') {
|
||||
archiveBaseName = 'IrisNaturalSpawningFixture'
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
name: IrisNaturalSpawningFixture
|
||||
version: 1.0.0
|
||||
main: art.arcane.iris.fixture.NaturalSpawningFixture
|
||||
api-version: '26.1.2'
|
||||
folia-supported: true
|
||||
depend: [Iris]
|
||||
commands:
|
||||
irisspawnqa:
|
||||
permission: irisspawnqa.run
|
||||
permissions:
|
||||
irisspawnqa.run:
|
||||
default: op
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'iris-natural-spawning-fixture'
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
package art.arcane.iris.fixture;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.runtime.BiomeEnvironment;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.world.IrisCreator;
|
||||
import art.arcane.iris.world.entity.IrisSpawner;
|
||||
import art.arcane.iris.world.entity.IrisEntitySpawn;
|
||||
import art.arcane.iris.world.entity.IrisEntity;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.platform.bukkit.BukkitEntityType;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Difficulty;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.GameRules;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public final class NaturalSpawningFixture extends JavaPlugin implements Listener {
|
||||
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "spawn_qa");
|
||||
private static final long WORLD_SEED = 78264193L;
|
||||
private static final int FROG_CAPACITY = 16;
|
||||
private static final int ZOMBIE_CAPACITY = 2;
|
||||
private static final int BIOME_READY_ATTEMPTS = 60;
|
||||
private static final long BIOME_RETRY_TICKS = 10L;
|
||||
|
||||
private boolean previousAmbient;
|
||||
private boolean previousMarkers;
|
||||
private boolean previousForcedPersistence;
|
||||
private volatile boolean ready;
|
||||
private int firstZombieBatch;
|
||||
private int ambientEvents;
|
||||
private int cancelledAmbientEvents;
|
||||
private final ThreadLocal<Boolean> ambientInvocation = ThreadLocal.withInitial(() -> false);
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
Bukkit.getPluginManager().registerEvents(this, this);
|
||||
previousAmbient = IrisSettings.get().getWorld().isAmbientEntitySpawningSystem();
|
||||
previousMarkers = IrisSettings.get().getWorld().isMarkerEntitySpawningSystem();
|
||||
previousForcedPersistence = IrisSettings.get().getWorld().isForcePersistEntities();
|
||||
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(false);
|
||||
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(false);
|
||||
IrisSettings.get().getWorld().setForcePersistEntities(false);
|
||||
getLogger().info("Natural spawning fixture ready; commands use the Iris ambient path on the owning region.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(previousAmbient);
|
||||
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(previousMarkers);
|
||||
IrisSettings.get().getWorld().setForcePersistEntities(previousForcedPersistence);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (arguments.length != 1) {
|
||||
notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|surface|cave");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
switch (arguments[0]) {
|
||||
case "setup" -> setup(sender);
|
||||
case "frogs" -> runRegion(sender, () -> spawnFrogs(sender));
|
||||
case "zombies" -> runRegion(sender, () -> spawnZombies(sender));
|
||||
case "stress" -> runRegion(sender, () -> stress(sender));
|
||||
case "check" -> runRegion(sender, () -> check(sender));
|
||||
case "surface" -> view(sender, 67D);
|
||||
case "cave" -> view(sender, -46D);
|
||||
default -> throw new IllegalArgumentException("Unknown spawning operation");
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void isolateSpawns(CreatureSpawnEvent event) {
|
||||
if (!WORLD_KEY.equals(event.getLocation().getWorld().getKey())) {
|
||||
return;
|
||||
}
|
||||
if (!ambientInvocation.get()) {
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
ambientEvents++;
|
||||
if (event.isCancelled()) {
|
||||
cancelledAmbientEvents++;
|
||||
}
|
||||
}
|
||||
|
||||
private void setup(CommandSender sender) {
|
||||
if (!(sender instanceof Player)) {
|
||||
throw new IllegalArgumentException("Setup requires a player");
|
||||
}
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
if (world != null) {
|
||||
require(IrisToolbelt.isIrisWorld(world) && world.getSeed() == WORLD_SEED,
|
||||
"Existing fixture key is not the owned Iris world with the expected seed");
|
||||
ready = false;
|
||||
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
|
||||
return;
|
||||
}
|
||||
ready = false;
|
||||
Bukkit.getAsyncScheduler().runNow(this, task -> createWorld(sender));
|
||||
}
|
||||
|
||||
private void createWorld(CommandSender sender) {
|
||||
try {
|
||||
World world = new IrisCreator().name(WORLD_KEY.toString()).dimension("spawn-qa")
|
||||
.seed(WORLD_SEED).create();
|
||||
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void configureWorld(CommandSender sender, World world) {
|
||||
try {
|
||||
world.setDifficulty(Difficulty.NORMAL);
|
||||
world.setGameRule(GameRules.SPAWN_MOBS, true);
|
||||
List<CompletableFuture<Void>> chunks = new ArrayList<>();
|
||||
for (int chunkX = -1; chunkX <= 1; chunkX++) {
|
||||
for (int chunkZ = -1; chunkZ <= 1; chunkZ++) {
|
||||
chunks.add(retainChunk(world, chunkX, chunkZ));
|
||||
}
|
||||
}
|
||||
CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(sender, failure);
|
||||
} else {
|
||||
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> prepareChunk(sender, world));
|
||||
}
|
||||
});
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> retainChunk(World world, int chunkX, int chunkZ) {
|
||||
CompletableFuture<Void> retained = new CompletableFuture<>();
|
||||
world.getChunkAtAsync(chunkX, chunkZ, true).whenComplete((chunk, failure) -> {
|
||||
if (failure != null) {
|
||||
retained.completeExceptionally(failure);
|
||||
return;
|
||||
}
|
||||
Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
chunk.addPluginChunkTicket(this);
|
||||
retained.complete(null);
|
||||
} catch (Throwable error) {
|
||||
retained.completeExceptionally(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
return retained;
|
||||
}
|
||||
|
||||
private void prepareChunk(CommandSender sender, World world) {
|
||||
try {
|
||||
require(WORLD_KEY.equals(world.getKey()) && IrisToolbelt.isIrisWorld(world)
|
||||
&& world.getSeed() == WORLD_SEED, "Fixture world ownership changed");
|
||||
Chunk chunk = world.getChunkAt(0, 0);
|
||||
int removed = 0;
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||
entity.remove();
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
require(!(entity instanceof LivingEntity) || entity instanceof Player || !entity.isValid(),
|
||||
"Fixture target chunk retained a living mob after cleanup: " + entity.getType());
|
||||
}
|
||||
getLogger().info("SPAWN_QA_CLEANUP removed=" + removed + " chunk=0,0");
|
||||
firstZombieBatch = 0;
|
||||
ambientEvents = 0;
|
||||
cancelledAmbientEvents = 0;
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
chunk.getBlock(x, 64, z).setType(Material.GRASS_BLOCK, false);
|
||||
for (int y = 65; y <= 68; y++) {
|
||||
chunk.getBlock(x, y, z).setType(Material.AIR, false);
|
||||
}
|
||||
for (int y = -48; y <= -42; y++) {
|
||||
boolean shell = y == -48 || y == -42 || x == 0 || x == 15 || z == 0 || z == 15;
|
||||
chunk.getBlock(x, y, z).setType(shell ? Material.STONE : Material.CAVE_AIR, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED) {
|
||||
throw new IllegalStateException("Fixture is not the requested Iris world");
|
||||
}
|
||||
awaitBiomeEnvironment(sender, world, 0);
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitBiomeEnvironment(CommandSender sender, World world, int attempt) {
|
||||
try {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
engine.getSurfaceBiomeEnvironment(8, 8);
|
||||
ready = true;
|
||||
teleport(sender, world, 67D, "SPAWN_QA_SETUP world=" + WORLD_KEY + " seed=" + WORLD_SEED);
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
if (!unavailable.isLoading() || attempt >= BIOME_READY_ATTEMPTS) {
|
||||
fail(sender, unavailable);
|
||||
return;
|
||||
}
|
||||
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0,
|
||||
task -> awaitBiomeEnvironment(sender, world, attempt + 1), BIOME_RETRY_TICKS);
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void spawnFrogs(CommandSender sender) {
|
||||
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||
RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs");
|
||||
getLogger().info(spawnDiagnostics(spawn, chunk));
|
||||
for (int attempt = 0; attempt < 12; attempt++) {
|
||||
invokeAmbient(spawn, chunk);
|
||||
require(count(chunk, EntityType.FROG) <= FROG_CAPACITY, "Frog batch exceeded its category capacity");
|
||||
}
|
||||
require(count(chunk, EntityType.FROG) == FROG_CAPACITY,
|
||||
"Frog ambient category did not fill: " + spawnDiagnostics(spawn, chunk));
|
||||
require(count(chunk, EntityType.ZOMBIE) == 0, "Unexpected zombie before cave phase");
|
||||
verifyPersistence(chunk);
|
||||
evidence(sender, "SPAWN_QA_FROGS frogs=" + count(chunk, EntityType.FROG)
|
||||
+ " persistent=true removable=true ambient=true");
|
||||
}
|
||||
|
||||
private void spawnZombies(CommandSender sender) {
|
||||
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Frogs must fill their category before cave spawning");
|
||||
require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran");
|
||||
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
|
||||
getLogger().info(spawnDiagnostics(spawn, chunk));
|
||||
invokeAmbient(spawn, chunk);
|
||||
firstZombieBatch = count(chunk, EntityType.ZOMBIE);
|
||||
require(firstZombieBatch == ZOMBIE_CAPACITY,
|
||||
"Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk));
|
||||
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Cave spawning changed the passive population");
|
||||
verifyPersistence(chunk);
|
||||
evidence(sender, "SPAWN_QA_ZOMBIES frogs=" + count(chunk, EntityType.FROG)
|
||||
+ " zombies=" + firstZombieBatch + " firstBatch=" + firstZombieBatch + " ambient=true");
|
||||
}
|
||||
|
||||
private void stress(CommandSender sender) {
|
||||
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
|
||||
for (int attempt = 0; attempt < 50; attempt++) {
|
||||
invokeAmbient(spawn, chunk);
|
||||
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity");
|
||||
}
|
||||
evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE));
|
||||
}
|
||||
|
||||
private void check(CommandSender sender) {
|
||||
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Passive population differs");
|
||||
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Hostile population differs");
|
||||
require(firstZombieBatch == ZOMBIE_CAPACITY, "First hostile batch was not pinned");
|
||||
require(chunk.getBlock(8, -47, 8).getType() == Material.CAVE_AIR, "Cave is not real CAVE_AIR");
|
||||
require(chunk.getBlock(8, -47, 8).getLightLevel() == 0, "Cave is not dark");
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity.getType() == EntityType.ZOMBIE) {
|
||||
require(entity.getLocation().getY() < -42, "Hostile spawned outside the cave");
|
||||
}
|
||||
}
|
||||
verifyPersistence(chunk);
|
||||
evidence(sender, "SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0");
|
||||
}
|
||||
|
||||
private RuntimeSpawn runtimeSpawn(Chunk chunk, String biomeKey) {
|
||||
try {
|
||||
Engine engine = IrisToolbelt.access(chunk.getWorld()).getEngine();
|
||||
BiomeEnvironment surface = engine.getSurfaceBiomeEnvironment(8, 8);
|
||||
IrisBiome biome = surface.data().getBiomeLoader().load(biomeKey);
|
||||
require(biome != null, "Synthetic biome was not loaded");
|
||||
BiomeEnvironment selected = new BiomeEnvironment(surface.activationId(), biome, surface.region(),
|
||||
surface.dimension(), surface.data());
|
||||
Object manager = engine.getWorldManager();
|
||||
Field field = manager.getClass().getDeclaredField("entitySpawner");
|
||||
field.setAccessible(true);
|
||||
Object spawner = field.get(manager);
|
||||
Method method = spawner.getClass().getDeclaredMethod("spawnAmbient", Chunk.class, boolean.class, BiomeEnvironment.class);
|
||||
method.setAccessible(true);
|
||||
return new RuntimeSpawn(engine, spawner, method, selected);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Could not access Iris ambient spawning", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private String spawnDiagnostics(RuntimeSpawn spawn, Chunk chunk) {
|
||||
World world = chunk.getWorld();
|
||||
int floor = world.getHighestBlockYAt(8, 8, HeightMap.OCEAN_FLOOR);
|
||||
int top = world.getHighestBlockYAt(8, 8, HeightMap.WORLD_SURFACE);
|
||||
Map<String, Integer> categories = new TreeMap<>();
|
||||
Map<String, Integer> types = new TreeMap<>();
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||
categories.merge(BukkitEntityType.of(entity.getType()).spawnCategory(), 1, Integer::sum);
|
||||
types.merge(entity.getType().name(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
StringBuilder diagnostics = new StringBuilder("SPAWN_QA_DIAGNOSTICS frogs=")
|
||||
.append(count(chunk, EntityType.FROG)).append(" zombies=").append(count(chunk, EntityType.ZOMBIE))
|
||||
.append(" categories=").append(categories).append(" types=").append(types)
|
||||
.append(" loadLevel=").append(chunk.getLoadLevel())
|
||||
.append(" spawnMobs=").append(world.getGameRuleValue(GameRules.SPAWN_MOBS))
|
||||
.append(" animals=").append(world.getAllowAnimals()).append(" monsters=").append(world.getAllowMonsters())
|
||||
.append(" floor=").append(floor).append(" top=").append(top)
|
||||
.append(" support=").append(world.getBlockAt(8, top, 8).getType())
|
||||
.append(" above=").append(world.getBlockAt(8, top + 1, 8).getType())
|
||||
.append(" light=").append(world.getBlockAt(8, top + 1, 8).getLightLevel())
|
||||
.append(" events=").append(ambientEvents).append(" cancelled=").append(cancelledAmbientEvents);
|
||||
for (String key : spawn.environment().biome().getEntitySpawners()) {
|
||||
IrisSpawner spawner = spawn.environment().data().getSpawnerLoader().load(key);
|
||||
if (spawner == null) {
|
||||
diagnostics.append(" spawner=").append(key).append(":missing");
|
||||
continue;
|
||||
}
|
||||
diagnostics.append(" spawner=").append(key).append(" canSpawn=")
|
||||
.append(spawner.canSpawn(spawn.engine(), chunk.getX(), chunk.getZ()))
|
||||
.append(" excluded=").append(spawner.isCompatExcluded())
|
||||
.append(" group=").append(spawner.getGroup()).append(" capacity=").append(spawner.getMaxEntitiesPerChunk());
|
||||
for (IrisEntitySpawn entry : spawner.getSpawns()) {
|
||||
IrisEntity entity = entry.getRealEntity(spawn.engine());
|
||||
diagnostics.append(" entity=").append(entry.getEntity());
|
||||
if (entity == null) {
|
||||
diagnostics.append(":missing");
|
||||
continue;
|
||||
}
|
||||
diagnostics.append(" type=").append(entity.getBukkitType()).append(" category=").append(entity.spawnCategory())
|
||||
.append(" surface=").append(entity.getSurface())
|
||||
.append(" supportMatches=").append(entity.getSurface().matches(world.getBlockAt(8, top, 8)))
|
||||
.append(" bounds=").append(BukkitPlatform.entityBoundingBox(entity.getBukkitType()));
|
||||
}
|
||||
}
|
||||
return diagnostics.toString();
|
||||
}
|
||||
|
||||
private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk) {
|
||||
ambientInvocation.set(true);
|
||||
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
|
||||
spawn.method().invoke(spawn.spawner(), chunk, false, spawn.environment());
|
||||
} catch (InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Iris ambient spawning failed", failure.getCause());
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Could not invoke Iris ambient spawning", failure);
|
||||
} finally {
|
||||
ambientInvocation.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private int count(Chunk chunk, EntityType type) {
|
||||
int count = 0;
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity.getType() == type) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void verifyPersistence(Chunk chunk) {
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity.getType() == EntityType.FROG || entity.getType() == EntityType.ZOMBIE) {
|
||||
require(entity.isPersistent(), "Ambient entity stopped participating in chunk saves");
|
||||
require(entity instanceof LivingEntity living && living.getRemoveWhenFarAway(),
|
||||
"Ambient entity has distance despawning disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void view(CommandSender sender, double y) {
|
||||
teleport(sender, requireWorld(), y, "SPAWN_QA_VIEW y=" + (int) y);
|
||||
}
|
||||
|
||||
private void teleport(CommandSender sender, World world, double y, String message) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
throw new IllegalArgumentException("Viewing requires a player");
|
||||
}
|
||||
player.getScheduler().run(this, task -> {
|
||||
player.setGameMode(GameMode.CREATIVE);
|
||||
player.setAllowFlight(true);
|
||||
player.setFlying(true);
|
||||
player.teleportAsync(new Location(world, 40.5D, y, 8.5D)).whenComplete((success, failure) -> {
|
||||
if (failure != null || !Boolean.TRUE.equals(success)) {
|
||||
fail(sender, failure == null ? new IllegalStateException("Fixture teleport failed") : failure);
|
||||
} else {
|
||||
notifySender(sender, message);
|
||||
}
|
||||
});
|
||||
}, null);
|
||||
}
|
||||
|
||||
private World requireWorld() {
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
require(ready && world != null, "Run setup before a spawning phase");
|
||||
return world;
|
||||
}
|
||||
|
||||
private void runRegion(CommandSender sender, Runnable operation) {
|
||||
World world = requireWorld();
|
||||
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> {
|
||||
try {
|
||||
operation.run();
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void evidence(CommandSender sender, String message) {
|
||||
getLogger().info(message);
|
||||
notifySender(sender, message);
|
||||
}
|
||||
|
||||
private void notifySender(CommandSender sender, String message) {
|
||||
if (sender instanceof Player player) {
|
||||
player.getScheduler().run(this, task -> player.sendMessage(message), null);
|
||||
} else {
|
||||
sender.sendMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(CommandSender sender, Throwable failure) {
|
||||
getLogger().log(Level.SEVERE, "Spawning acceptance failed", failure);
|
||||
notifySender(sender, "SPAWN_QA_FAILED " + failure.getMessage());
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new IllegalStateException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private record RuntimeSpawn(Engine engine, Object spawner, Method method, BiomeEnvironment environment) {
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Cave Zombies",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"layers": [
|
||||
{
|
||||
"minHeight": 1,
|
||||
"maxHeight": 1,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:grass_block"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
{
|
||||
"generator": "flat",
|
||||
"min": 64,
|
||||
"max": 64
|
||||
}
|
||||
],
|
||||
"entitySpawners": [
|
||||
"qa/cave-zombies"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Frogs",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"layers": [
|
||||
{
|
||||
"minHeight": 1,
|
||||
"maxHeight": 1,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:grass_block"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
{
|
||||
"generator": "flat",
|
||||
"min": 64,
|
||||
"max": 64
|
||||
}
|
||||
],
|
||||
"entitySpawners": [
|
||||
"qa/frogs"
|
||||
]
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Spawn QA",
|
||||
"version": 1,
|
||||
"mode": {
|
||||
"type": "OVERWORLD"
|
||||
},
|
||||
"regions": [
|
||||
"flat"
|
||||
],
|
||||
"focusRegion": "flat",
|
||||
"focus": "frogs",
|
||||
"landChance": 1,
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {
|
||||
"min": -64,
|
||||
"max": 320
|
||||
},
|
||||
"logicalHeight": 384,
|
||||
"fluidHeight": 0,
|
||||
"bedrock": false,
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:stone"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"type": "minecraft:frog",
|
||||
"reason": "NATURAL",
|
||||
"surface": "LAND",
|
||||
"ai": false,
|
||||
"aware": false,
|
||||
"gravity": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"type": "minecraft:zombie",
|
||||
"reason": "NATURAL",
|
||||
"surface": "LAND",
|
||||
"ai": false,
|
||||
"aware": false,
|
||||
"gravity": false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"interpolator": {
|
||||
"function": "NONE",
|
||||
"horizontalScale": 1
|
||||
},
|
||||
"seed": 310,
|
||||
"composite": [
|
||||
{
|
||||
"seed": 310,
|
||||
"style": {
|
||||
"style": "FLAT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"landBiomes": [
|
||||
"frogs"
|
||||
],
|
||||
"seaBiomes": [
|
||||
"frogs"
|
||||
],
|
||||
"shoreBiomes": [
|
||||
"frogs"
|
||||
]
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"group": "CAVE",
|
||||
"maxEntitiesPerChunk": 2,
|
||||
"allowedLightLevels": {
|
||||
"min": 0,
|
||||
"max": 0
|
||||
},
|
||||
"spawns": [
|
||||
{
|
||||
"entity": "qa/zombie",
|
||||
"rarity": 1,
|
||||
"minSpawns": 7,
|
||||
"maxSpawns": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"group": "NORMAL",
|
||||
"maxEntitiesPerChunk": 16,
|
||||
"spawns": [
|
||||
{
|
||||
"entity": "qa/frog",
|
||||
"rarity": 1,
|
||||
"minSpawns": 7,
|
||||
"maxSpawns": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export default {
|
||||
name: 'iris-natural-spawning',
|
||||
description: 'Observe category-limited Iris ambient frogs and cave zombies on Folia.',
|
||||
async run(context) {
|
||||
const wait = (predicate, label, timeoutMs = 30000) => context.waitUntil(predicate, { label, timeoutMs, intervalMs: 100 })
|
||||
const command = async (operation, expected, timeoutMs = 30000) => {
|
||||
const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs)
|
||||
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
|
||||
return response
|
||||
}
|
||||
const entities = name => Object.values(context.bot.entities).filter(entity => entity.name === name)
|
||||
await context.step('prepare the synthetic Iris world and dark cave', async () => {
|
||||
await command('setup', 'SPAWN_QA_SETUP world=iris:spawn_qa seed=78264193', 180000)
|
||||
await wait(() => Math.abs(context.bot.entity.position.y - 67) < 1, 'Fixture world teleport')
|
||||
})
|
||||
await context.step('fill the passive category through real Iris ambient spawning', async () => {
|
||||
await command('frogs', 'SPAWN_QA_FROGS frogs=16 persistent=true removable=true ambient=true', 60000)
|
||||
await wait(() => entities('frog').length === 16, 'All sixteen ambient frogs became visible')
|
||||
context.report.irisNaturalSpawning = { visibleFrogs: entities('frog').length }
|
||||
})
|
||||
await context.step('spawn an authored seven-zombie batch with capacity two despite existing frogs', async () => {
|
||||
await command('zombies', 'SPAWN_QA_ZOMBIES frogs=16 zombies=2 firstBatch=2 ambient=true', 60000)
|
||||
await command('cave', 'SPAWN_QA_VIEW y=-46')
|
||||
await wait(() => Math.abs(context.bot.entity.position.y + 46) < 1, 'Underground fixture teleport')
|
||||
await wait(() => context.bot.blockAt(context.bot.entity.position.clone().set(8, -47, 8))?.name === 'cave_air', 'Live cave_air block reached the client')
|
||||
await wait(() => entities('zombie').length === 2, 'Both ambient cave zombies became visible')
|
||||
context.expect(entities('zombie').every(entity => entity.position.y < -42), 'Zombies were not inside the underground chamber')
|
||||
context.report.irisNaturalSpawning.visibleZombies = entities('zombie').length
|
||||
context.report.irisNaturalSpawning.caveAir = true
|
||||
})
|
||||
await context.step('repeat fifty ambient attempts without exceeding the hostile capacity', async () => {
|
||||
await command('stress', 'SPAWN_QA_STRESS attempts=50 zombies=2', 60000)
|
||||
await command('check', 'SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0', 30000)
|
||||
context.expect(entities('zombie').length === 2, 'Client observed a hostile capacity overflow')
|
||||
context.report.irisNaturalSpawning.saturatedAttempts = 50
|
||||
context.report.irisNaturalSpawning.firstHostileBatch = 2
|
||||
context.report.irisNaturalSpawning.removeWhenFarAwayAllowed = true
|
||||
context.report.irisNaturalSpawning.chunkSaving = true
|
||||
})
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -61,11 +61,12 @@ public final class ModdedEntitySpawner {
|
||||
}
|
||||
|
||||
double x = blockX + 0.5;
|
||||
double y = blockY + 0.5;
|
||||
double verticalOffset = irisEntity.getSurface().isFluid() ? 0.5 : 0;
|
||||
double y = blockY + verticalOffset;
|
||||
double z = blockZ + 0.5;
|
||||
boolean riseEffectActive = irisEntity.isSpawnEffectRiseOutOfGround() && level.hasPlayersNearby(new NativeEntityRuntime.Position(x, y, z), PLAYER_EFFECT_RADIUS);
|
||||
int spawnBlockY = riseEffectActive ? blockY - 5 : blockY;
|
||||
double spawnY = spawnBlockY + 0.5;
|
||||
double spawnY = spawnBlockY + verticalOffset;
|
||||
|
||||
NativeSpawnedEntity created = create(irisEntity, level, x, spawnY, z);
|
||||
if (created == null) {
|
||||
@@ -98,8 +99,7 @@ public final class ModdedEntitySpawner {
|
||||
String customName = irisEntity.getCustomName();
|
||||
entity.applyBase(irisEntity, customName == null ? null : colorize(customName));
|
||||
|
||||
boolean persistent = irisEntity.isKeepEntity() || forcePersist();
|
||||
applyPersistence(entity, persistent);
|
||||
applyPersistence(irisEntity, entity, forcePersist());
|
||||
|
||||
applyPassengers(engine, irisEntity, entity, level, blockX, blockY, blockZ, rng);
|
||||
|
||||
@@ -273,8 +273,8 @@ public final class ModdedEntitySpawner {
|
||||
return IrisSettings.get().getWorld().isForcePersistEntities();
|
||||
}
|
||||
|
||||
private static void applyPersistence(NativeSpawnedEntity entity, boolean persistent) {
|
||||
if (!entity.persistence(persistent)) {
|
||||
static void applyPersistence(IrisEntity options, NativeSpawnedEntity entity, boolean forcePersistence) {
|
||||
if (!entity.persistence(options.isKeepEntity() || forcePersistence)) {
|
||||
String type = entity.typeKey();
|
||||
if (WARNED_PERSISTENCE_TYPES.add(type)) {
|
||||
IrisLogging.warn("Iris entity: vanilla cannot persist non-serializable entity type '" + type + "'");
|
||||
|
||||
+18
-13
@@ -55,6 +55,7 @@ import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterMarker;
|
||||
import art.arcane.volmlib.util.matter.slices.MarkerMatter;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
@@ -511,13 +512,16 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
}
|
||||
|
||||
private void spawnAmbient(NativeWorld level, int chunkX, int chunkZ, boolean initial, BiomeEnvironment environment) {
|
||||
if (!NativeSpawnQueries.ambientAllowed(level, chunkX, chunkZ, initial)) {
|
||||
return;
|
||||
}
|
||||
IrisComplex complex = engine.getComplex();
|
||||
if (complex == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
IrisBiome biome = environment.biome();
|
||||
int chunkMobs = countChunkLivingEntities(level, chunkX, chunkZ);
|
||||
Map<String, Integer> chunkMobs = NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ);
|
||||
|
||||
KList<IrisEntitySpawn> pool = new KList<>();
|
||||
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial);
|
||||
@@ -535,20 +539,18 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
if (!canSpawn(spawner, chunkX, chunkZ)) {
|
||||
return;
|
||||
}
|
||||
int spawned = spawnEntry(level, chosen, spawner, chunkX, chunkZ);
|
||||
int spawned = spawnEntry(level, chosen, spawner, chunkX, chunkZ,
|
||||
remainingCapacity(chosen, spawner, chunkMobs));
|
||||
if (spawned > 0) {
|
||||
spawner.spawn(engine, chunkX, chunkZ);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectSpawns(KList<IrisEntitySpawn> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, int chunkMobs, boolean initial) {
|
||||
private void collectSpawns(KList<IrisEntitySpawn> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map<String, Integer> chunkMobs, boolean initial) {
|
||||
for (IrisSpawner spawner : spawners) {
|
||||
if (spawner == null) {
|
||||
continue;
|
||||
}
|
||||
if (spawner.getMaxEntitiesPerChunk() <= chunkMobs) {
|
||||
continue;
|
||||
}
|
||||
if (biomeFilter != null && !spawner.isValid(biomeFilter)) {
|
||||
continue;
|
||||
}
|
||||
@@ -559,12 +561,19 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
for (IrisEntitySpawn entry : spawns) {
|
||||
entry.setReferenceSpawner(spawner);
|
||||
entry.setReferenceMarker(spawner.getReferenceMarker());
|
||||
if (remainingCapacity(entry, spawner, chunkMobs) > 0) {
|
||||
pool.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, int chunkX, int chunkZ) {
|
||||
private int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Map<String, Integer> counts) {
|
||||
IrisEntity entity = entry.getRealEntity(engine);
|
||||
return spawner.remainingCapacity(entity, counts);
|
||||
}
|
||||
|
||||
private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, int chunkX, int chunkZ, int remainingCapacity) {
|
||||
IrisEntity irisEntity = entry.getRealEntity(engine);
|
||||
if (irisEntity == null) {
|
||||
return 0;
|
||||
@@ -572,7 +581,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
|
||||
int min = entry.getMinSpawns();
|
||||
int max = entry.getMaxSpawns();
|
||||
int count = LootResolver.inclusive(RNG.r, min, max);
|
||||
int count = Math.min(Math.max(0, remainingCapacity), LootResolver.inclusive(RNG.r, min, max));
|
||||
if (count <= 0) {
|
||||
return 0;
|
||||
}
|
||||
@@ -593,7 +602,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
}
|
||||
IrisPosition caveFloor = caveFloors.getRandom(RNG.r);
|
||||
worldX = caveFloor.getX();
|
||||
worldY = caveFloor.getY() + 1;
|
||||
worldY = caveFloor.getY();
|
||||
worldZ = caveFloor.getZ();
|
||||
} else {
|
||||
worldX = (chunkX << 4) + RNG.r.i(16);
|
||||
@@ -708,10 +717,6 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
|| NativeSpawnQueries.solid(level.getBlock(worldX, worldY + 2, worldZ));
|
||||
}
|
||||
|
||||
private int countChunkLivingEntities(NativeWorld level, int chunkX, int chunkZ) {
|
||||
return NativeSpawnQueries.livingEntities(level, chunkX, chunkZ);
|
||||
}
|
||||
|
||||
/**
|
||||
* ServerChunkCache.tickChunks rebuilds NaturalSpawner.SpawnState every tick from every entity in the
|
||||
* level, so read that instead of walking all entities again on an Iris timer. The count is the natural
|
||||
|
||||
+74
-5
@@ -2,6 +2,13 @@ package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityBehavior;
|
||||
|
||||
import art.arcane.iris.world.entity.IrisEntity;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import org.junit.BeforeClass;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import org.junit.Test;
|
||||
import org.junit.Before;
|
||||
import art.arcane.volmlib.nativelib.modded.EntityBehaviorTags;
|
||||
@@ -11,18 +18,31 @@ import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedEntityPersistenceTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Before
|
||||
public void configureEntityTags() {
|
||||
NativeEntityBehavior.bind(new EntityBehaviorTags("iris_non_persistent", "iris_unaware"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatedNonPersistentEntityIsExcludedFromVanillaSaves() {
|
||||
public void explicitlyExcludedEntityIsExcludedFromVanillaSaves() {
|
||||
Set<String> tags = new HashSet<>();
|
||||
|
||||
NativeEntityBehavior.configurePersistenceTags(tags, false);
|
||||
NativeEntityBehavior.configureSavingTags(tags, false);
|
||||
|
||||
assertFalse(NativeEntityBehavior.shouldSave(tags, true));
|
||||
}
|
||||
@@ -30,9 +50,9 @@ public class ModdedEntityPersistenceTest {
|
||||
@Test
|
||||
public void positivePersistenceRemovesTheSaveExclusion() {
|
||||
Set<String> tags = new HashSet<>();
|
||||
NativeEntityBehavior.configurePersistenceTags(tags, false);
|
||||
NativeEntityBehavior.configureSavingTags(tags, false);
|
||||
|
||||
NativeEntityBehavior.configurePersistenceTags(tags, true);
|
||||
NativeEntityBehavior.configureSavingTags(tags, true);
|
||||
|
||||
assertTrue(NativeEntityBehavior.shouldSave(tags, true));
|
||||
}
|
||||
@@ -41,8 +61,57 @@ public class ModdedEntityPersistenceTest {
|
||||
public void interceptionNeverOverridesVanillaSaveRejection() {
|
||||
Set<String> tags = new HashSet<>();
|
||||
|
||||
NativeEntityBehavior.configurePersistenceTags(tags, true);
|
||||
NativeEntityBehavior.configureSavingTags(tags, true);
|
||||
|
||||
assertFalse(NativeEntityBehavior.shouldSave(tags, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void defaultAmbientMobSavesWithoutBecomingPermanent() {
|
||||
Mob mob = mob();
|
||||
NativeSpawnedEntity entity = new NativeSpawnedEntity(mob);
|
||||
IrisEntity options = new IrisEntity();
|
||||
|
||||
ModdedEntitySpawner.applyPersistence(options, entity, false);
|
||||
entity.configureMob(options);
|
||||
|
||||
assertTrue(NativeEntityBehavior.shouldSave(mob, true));
|
||||
verify(mob, never()).setPersistenceRequired();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitKeepForceAndRemovalFlagsControlNativeDespawn() {
|
||||
for (boolean keep : new boolean[]{false, true}) {
|
||||
for (boolean force : new boolean[]{false, true}) {
|
||||
for (boolean removable : new boolean[]{false, true}) {
|
||||
Mob mob = mob();
|
||||
NativeSpawnedEntity entity = new NativeSpawnedEntity(mob);
|
||||
IrisEntity options = new IrisEntity().setKeepEntity(keep).setRemovable(removable);
|
||||
|
||||
ModdedEntitySpawner.applyPersistence(options, entity, force);
|
||||
entity.configureMob(options);
|
||||
|
||||
assertTrue(NativeEntityBehavior.shouldSave(mob, true));
|
||||
if (keep || force || !removable) {
|
||||
verify(mob, atLeastOnce()).setPersistenceRequired();
|
||||
} else {
|
||||
verify(mob, never()).setPersistenceRequired();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Mob mob() {
|
||||
Mob mob = mock(Mob.class);
|
||||
EntityType<?> type = mock(EntityType.class);
|
||||
Set<String> tags = new HashSet<>();
|
||||
when(type.canSerialize()).thenReturn(true);
|
||||
doReturn(type).when(mob).getType();
|
||||
when(mob.entityTags()).thenReturn(tags);
|
||||
when(mob.shouldBeSaved()).thenReturn(true);
|
||||
when(mob.removeTag(anyString())).thenAnswer(invocation -> tags.remove(invocation.getArgument(0)));
|
||||
when(mob.addTag(anyString())).thenAnswer(invocation -> tags.add(invocation.getArgument(0)));
|
||||
return mob;
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -21,7 +21,7 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedEntitySpawnerParityTest {
|
||||
@Test
|
||||
public void collisionCheckUsesPaperInclusiveIntegerDimensions() {
|
||||
public void landBodyFitsTwoBlockCaveAndRejectsSecondBlockCeiling() {
|
||||
Set<BlockPos> checked = new HashSet<>();
|
||||
|
||||
boolean clear = NativeEntityRuntime.isAreaClearForSpawn(
|
||||
@@ -32,6 +32,36 @@ public class ModdedEntitySpawnerParityTest {
|
||||
|
||||
assertTrue(clear);
|
||||
assertEquals(Set.of(new BlockPos(10, 64, 20), new BlockPos(10, 65, 20)), checked);
|
||||
assertTrue(NativeEntityRuntime.isAreaClearForSpawn(10, 64, 20, 0.6F, 1.95F,
|
||||
(x, y, z) -> y >= 64 && y < 66));
|
||||
assertFalse(NativeEntityRuntime.isAreaClearForSpawn(10, 64, 20, 0.6F, 1.95F,
|
||||
(x, y, z) -> y == 64));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fractionalWideLandBodiesIncludeAdjacentObstructions() {
|
||||
Set<BlockPos> checked = new HashSet<>();
|
||||
assertTrue(NativeEntityRuntime.isAreaClearForSpawn(-1, -49, -1, 1.3F, 1.95F,
|
||||
(x, y, z) -> {
|
||||
checked.add(new BlockPos(x, y, z));
|
||||
return true;
|
||||
}));
|
||||
assertEquals(18, checked.size());
|
||||
assertTrue(checked.contains(new BlockPos(-2, -48, -2)));
|
||||
assertTrue(checked.contains(new BlockPos(0, -49, 0)));
|
||||
assertFalse(NativeEntityRuntime.isAreaClearForSpawn(-1, -49, -1, 1.3F, 1.95F,
|
||||
(x, y, z) -> !new BlockPos(x, y, z).equals(new BlockPos(0, -48, -1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactLandBodyBoundariesExcludeUntouchedBlocks() {
|
||||
Set<BlockPos> checked = new HashSet<>();
|
||||
assertTrue(NativeEntityRuntime.isAreaClearForSpawn(0, -49, 0, 1F, 2F,
|
||||
(x, y, z) -> {
|
||||
checked.add(new BlockPos(x, y, z));
|
||||
return true;
|
||||
}));
|
||||
assertEquals(Set.of(new BlockPos(0, -49, 0), new BlockPos(0, -48, 0)), checked);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+97
-1
@@ -3,12 +3,21 @@ package art.arcane.iris.modded;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import art.arcane.iris.generation.mantle.EngineMantle;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.SeedManager;
|
||||
import art.arcane.iris.pack.value.IrisPosition;
|
||||
|
||||
import art.arcane.iris.world.entity.IrisEntity;
|
||||
import art.arcane.iris.world.entity.IrisEntitySpawn;
|
||||
import art.arcane.iris.world.entity.IrisSpawnGroup;
|
||||
import art.arcane.iris.world.entity.IrisSpawner;
|
||||
import art.arcane.iris.generation.decoration.IrisSurface;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.matter.slices.MarkerMatter;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
@@ -23,7 +32,9 @@ import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -33,8 +44,15 @@ import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedWaterEntitySpawnTest {
|
||||
@@ -48,7 +66,8 @@ public class ModdedWaterEntitySpawnTest {
|
||||
}
|
||||
}
|
||||
BuiltInRegistries.BLOCK.prepareTagReload(new TagLoader.LoadResult<>(Registries.BLOCK, Map.of(
|
||||
BlockTags.BLOCKS_MOTION, List.of(Blocks.OAK_FENCE.builtInRegistryHolder())))).apply();
|
||||
BlockTags.BLOCKS_MOTION, List.of(Blocks.OAK_FENCE.builtInRegistryHolder(),
|
||||
Blocks.STONE.builtInRegistryHolder())))).apply();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -75,6 +94,22 @@ public class ModdedWaterEntitySpawnTest {
|
||||
assertFalse(ModdedEntitySpawner.isAreaClearForSpawn(new NativeEntityRuntime(level), entity, 0, 78, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeLandClearanceAcceptsEveryAirBlockAndRejectsStone() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
NativeEntityRuntime runtime = new NativeEntityRuntime(level);
|
||||
IrisEntity entity = new IrisEntity().setType("zombie").setSurface(IrisSurface.LAND);
|
||||
for (Block air : List.of(Blocks.AIR, Blocks.CAVE_AIR, Blocks.VOID_AIR)) {
|
||||
when(level.getBlockState(any(BlockPos.class))).thenReturn(air.defaultBlockState());
|
||||
assertTrue(ModdedEntitySpawner.isAreaClearForSpawn(runtime, entity, -1, -49, -1));
|
||||
}
|
||||
when(level.getBlockState(any(BlockPos.class))).thenAnswer(invocation -> {
|
||||
BlockPos position = invocation.getArgument(0);
|
||||
return position.getY() == -48 ? Blocks.STONE.defaultBlockState() : Blocks.CAVE_AIR.defaultBlockState();
|
||||
});
|
||||
assertFalse(ModdedEntitySpawner.isAreaClearForSpawn(runtime, entity, -1, -49, -1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void waterloggedSolidBlocksStillObstructWaterSpawns() {
|
||||
assertTrue(ModdedWorldManager.matchesSurface(IrisSurface.WATER,
|
||||
@@ -107,6 +142,29 @@ public class ModdedWaterEntitySpawnTest {
|
||||
assertEquals(Set.of(new BlockPos(0, 78, 0)), checked);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void spawnAndRiseStartUseFloorHeightOnLandAndCenterHeightInFluids() {
|
||||
for (IrisSurface surface : List.of(IrisSurface.LAND, IrisSurface.WATER, IrisSurface.LAVA)) {
|
||||
for (boolean rise : new boolean[]{false, true}) {
|
||||
Engine engine = mock(Engine.class);
|
||||
NativeEntityRuntime runtime = mock(NativeEntityRuntime.class);
|
||||
NativeSpawnedEntity created = mock(NativeSpawnedEntity.class);
|
||||
IrisEntity entity = new IrisEntity().setSpecialType("test:mob")
|
||||
.setSurface(surface).setSpawnEffectRiseOutOfGround(rise);
|
||||
when(runtime.chunksSafe(-1, 0)).thenReturn(true);
|
||||
when(runtime.hasPlayersNearby(any(NativeEntityRuntime.Position.class), eq(32D))).thenReturn(true);
|
||||
when(runtime.spawnCustom(eq("test:mob"), any(NativeEntityRuntime.Position.class), any()))
|
||||
.thenReturn(created);
|
||||
|
||||
assertSame(created, ModdedEntitySpawner.spawn(engine, entity, runtime, -1, -49, 0, new RNG(1L)));
|
||||
|
||||
double targetY = -49 + (surface.isFluid() ? 0.5 : 0) - (rise ? 5 : 0);
|
||||
verify(runtime).spawnCustom(eq("test:mob"),
|
||||
eq(new NativeEntityRuntime.Position(-0.5, targetY, 0.5)), any());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mixedSpawnerUsesFluidDepthWithoutChangingLandMembers() {
|
||||
RNG rng = new RNG(1L);
|
||||
@@ -117,4 +175,42 @@ public class ModdedWaterEntitySpawnTest {
|
||||
assertNull(IrisEntitySpawn.selectSurfaceSpawnY(IrisSpawnGroup.NORMAL, IrisSurface.WATER, 78, 78, rng));
|
||||
assertNull(IrisEntitySpawn.selectSurfaceSpawnY(IrisSpawnGroup.UNDERWATER, IrisSurface.WATER, 78, 78, rng));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void caveFloorMarkerSpawnsAboveSupportAtNegativeWorldHeight() throws ReflectiveOperationException {
|
||||
Engine engine = mock(Engine.class);
|
||||
EngineMantle mantle = mock(EngineMantle.class);
|
||||
when(engine.getMantle()).thenReturn(mantle);
|
||||
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
|
||||
when(mantle.findMarkers(-2, 3, MarkerMatter.CAVE_FLOOR))
|
||||
.thenReturn(new KList<>(new IrisPosition(-25, -49, 57)));
|
||||
NativeWorld world = mock(NativeWorld.class);
|
||||
when(world.minHeight()).thenReturn(-64);
|
||||
when(world.maxHeight()).thenReturn(320);
|
||||
when(world.nativeHandle()).thenReturn(mock(ServerLevel.class));
|
||||
when(world.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
|
||||
ModdedBlockState.of((int) invocation.getArgument(1) <= -50
|
||||
? Blocks.STONE.defaultBlockState() : Blocks.CAVE_AIR.defaultBlockState(), null));
|
||||
IrisEntity entity = new IrisEntity().setSurface(IrisSurface.LAND);
|
||||
IrisEntitySpawn entry = spy(new IrisEntitySpawn().setMinSpawns(1).setMaxSpawns(1));
|
||||
doReturn(entity).when(entry).getRealEntity(engine);
|
||||
IrisSpawner spawner = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE);
|
||||
Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnEntry", NativeWorld.class,
|
||||
IrisEntitySpawn.class, IrisSpawner.class, int.class, int.class, int.class);
|
||||
spawn.setAccessible(true);
|
||||
ModdedWorldManager manager = new ModdedWorldManager(engine);
|
||||
try (MockedStatic<ModdedEntitySpawner> entities = mockStatic(ModdedEntitySpawner.class)) {
|
||||
entities.when(() -> ModdedEntitySpawner.isAreaClearForSpawn(any(NativeEntityRuntime.class),
|
||||
eq(entity), eq(-25), eq(-49), eq(57))).thenReturn(true);
|
||||
entities.when(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class),
|
||||
eq(-25), eq(-49), eq(57), any(RNG.class))).thenReturn(mock(NativeSpawnedEntity.class));
|
||||
|
||||
assertEquals(1, spawn.invoke(manager, world, entry, spawner, -2, 3, 1));
|
||||
|
||||
entities.verify(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class),
|
||||
eq(-25), eq(-49), eq(57), any(RNG.class)));
|
||||
} finally {
|
||||
manager.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.level.gamerules.GameRules;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeSpawnPopulationTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void categoryCountsExcludePlayersAndNeighboringChunks() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
NativeWorld world = mock(NativeWorld.class);
|
||||
when(world.nativeHandle()).thenReturn(level);
|
||||
when(level.getMinY()).thenReturn(-64);
|
||||
when(level.getMaxY()).thenReturn(320);
|
||||
LivingEntity frog = mob(MobCategory.CREATURE, -4, 7);
|
||||
LivingEntity zombie = mob(MobCategory.MONSTER, -4, 7);
|
||||
LivingEntity neighbor = mob(MobCategory.MONSTER, -3, 7);
|
||||
Player player = mock(Player.class);
|
||||
when(player.blockPosition()).thenReturn(new BlockPos(-60, 50, 120));
|
||||
List<Entity> entities = List.of(frog, frog, frog, zombie, neighbor, player);
|
||||
when(level.getEntities(isNull(Entity.class), any(AABB.class), any()))
|
||||
.thenAnswer(invocation -> {
|
||||
Predicate<? super Entity> filter = invocation.getArgument(2);
|
||||
return entities.stream().filter(filter).toList();
|
||||
});
|
||||
assertEquals(Map.of("creature", 3, "monster", 1),
|
||||
NativeSpawnQueries.livingEntityCategories(world, -4, 7));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ambientAdmissionRequiresMobSpawningAndTickingForRecurringAttempts() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
NativeWorld world = mock(NativeWorld.class);
|
||||
GameRules rules = mock(GameRules.class);
|
||||
when(world.nativeHandle()).thenReturn(level);
|
||||
when(level.getMinY()).thenReturn(-64);
|
||||
when(level.getGameRules()).thenReturn(rules);
|
||||
when(rules.get(GameRules.SPAWN_MOBS)).thenReturn(false);
|
||||
assertFalse(NativeSpawnQueries.ambientAllowed(world, -4, 7, true));
|
||||
assertFalse(NativeSpawnQueries.ambientAllowed(world, -4, 7, false));
|
||||
when(rules.get(GameRules.SPAWN_MOBS)).thenReturn(true);
|
||||
assertTrue(NativeSpawnQueries.ambientAllowed(world, -4, 7, true));
|
||||
assertFalse(NativeSpawnQueries.ambientAllowed(world, -4, 7, false));
|
||||
when(level.isPositionEntityTicking(new BlockPos(-56, -64, 120))).thenReturn(true);
|
||||
assertTrue(NativeSpawnQueries.ambientAllowed(world, -4, 7, false));
|
||||
}
|
||||
|
||||
private static LivingEntity mob(MobCategory category, int chunkX, int chunkZ) {
|
||||
EntityType<?> type = mock(EntityType.class);
|
||||
when(type.getCategory()).thenReturn(category);
|
||||
LivingEntity entity = mock(LivingEntity.class);
|
||||
doReturn(type).when(entity).getType();
|
||||
when(entity.blockPosition()).thenReturn(new BlockPos((chunkX << 4) + 8, 32, (chunkZ << 4) + 8));
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ public class IrisSettings {
|
||||
@Data
|
||||
public static class IrisSettingsWorld {
|
||||
public boolean postLoadBlockUpdates = true;
|
||||
public boolean forcePersistEntities = true;
|
||||
public boolean forcePersistEntities = false;
|
||||
public boolean ambientEntitySpawningSystem = true;
|
||||
public long asyncTickIntervalMS = 700;
|
||||
public double targetSpawnEntitiesPerChunk = 0.95;
|
||||
|
||||
@@ -28,6 +28,8 @@ import art.arcane.iris.world.entity.IrisEntitySpawn;
|
||||
import art.arcane.iris.pack.value.IrisPosition;
|
||||
import art.arcane.iris.world.entity.IrisSpawner;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.platform.bukkit.BukkitEntityType;
|
||||
import art.arcane.iris.world.entity.IrisEntity;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.platform.bukkit.plugin.Chunks;
|
||||
import art.arcane.iris.world.task.J;
|
||||
@@ -37,13 +39,16 @@ import art.arcane.volmlib.util.format.Form;
|
||||
import art.arcane.volmlib.util.math.PowerOfTwoCoordinates;
|
||||
import art.arcane.volmlib.util.math.Position2;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import lombok.Data;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.GameRules;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import art.arcane.iris.generation.runtime.MarkerSpawnScanner.PreparedMarkerSpawn;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -381,6 +386,9 @@ final class WorldEntitySpawner {
|
||||
}
|
||||
|
||||
private void spawnAmbient(Chunk c, boolean initial, BiomeEnvironment environment) {
|
||||
if (!ambientAllowed(c, initial)) {
|
||||
return;
|
||||
}
|
||||
//@builder
|
||||
// Excluded spawners cannot spawn anything on this Minecraft version, so they never enter the selection pool.
|
||||
Predicate<IrisSpawner> filter = i -> !i.isCompatExcluded() && i.canSpawn(manager.getEngine(), c.getX(), c.getZ());
|
||||
@@ -404,20 +412,25 @@ final class WorldEntitySpawner {
|
||||
.shuffleCopy(RNG.r)
|
||||
.stream()
|
||||
.filter(filter)))
|
||||
.filter(counter)
|
||||
.flatMap((i) -> stream(i, initial))
|
||||
.filter(entry -> counter.remainingCapacity(entry, manager.getEngine()) > 0)
|
||||
.collect(Collectors.toList()))
|
||||
.getRandom();
|
||||
//@done
|
||||
if (v == null || v.getReferenceSpawner() == null)
|
||||
return;
|
||||
|
||||
spawn(c, v);
|
||||
spawn(c, v, counter.remainingCapacity(v, manager.getEngine()));
|
||||
}
|
||||
|
||||
private void spawn(Chunk c, IrisEntitySpawn i) {
|
||||
static boolean ambientAllowed(Chunk chunk, boolean initial) {
|
||||
return Boolean.TRUE.equals(chunk.getWorld().getGameRuleValue(GameRules.SPAWN_MOBS))
|
||||
&& (initial || chunk.getLoadLevel() == Chunk.LoadLevel.ENTITY_TICKING);
|
||||
}
|
||||
|
||||
private void spawn(Chunk c, IrisEntitySpawn i, int remainingCapacity) {
|
||||
IrisSpawner ref = i.getReferenceSpawner();
|
||||
int s = i.spawn(manager.getEngine(), c, RNG.r);
|
||||
int s = i.spawn(manager.getEngine(), c, RNG.r, remainingCapacity);
|
||||
actuallySpawned.addAndGet(s);
|
||||
if (s > 0) {
|
||||
ref.spawn(manager.getEngine(), c.getX(), c.getZ());
|
||||
@@ -469,26 +482,20 @@ final class WorldEntitySpawner {
|
||||
return Rarity.expandWeighted(types);
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class ChunkCounter implements Predicate<IrisSpawner> {
|
||||
private final Entity[] entities;
|
||||
private transient int index = 0;
|
||||
private transient int count = 0;
|
||||
static final class ChunkCounter {
|
||||
private final Map<String, Integer> counts = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public boolean test(IrisSpawner spawner) {
|
||||
int max = spawner.getMaxEntitiesPerChunk();
|
||||
if (max <= count)
|
||||
return false;
|
||||
|
||||
while (index < entities.length) {
|
||||
if (entities[index++] instanceof LivingEntity) {
|
||||
if (++count >= max)
|
||||
return false;
|
||||
ChunkCounter(Entity[] entities) {
|
||||
for (Entity entity : entities) {
|
||||
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||
counts.merge(BukkitEntityType.of(entity.getType()).spawnCategory(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
int remainingCapacity(IrisEntitySpawn entry, Engine engine) {
|
||||
IrisEntity entity = entry.getRealEntity(engine);
|
||||
return entry.getReferenceSpawner().remainingCapacity(entity, counts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
package art.arcane.iris.world.entity;
|
||||
|
||||
import art.arcane.volmlib.nativelib.entity.NativeEntityOptions;
|
||||
import art.arcane.volmlib.nativelib.entity.NativeEntityType;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
|
||||
import art.arcane.iris.command.IrisCommand;
|
||||
import art.arcane.iris.generation.decoration.IrisSurface;
|
||||
@@ -130,8 +134,8 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
|
||||
@Description("Should this entity be allowed to pickup items")
|
||||
private boolean pickupItems = false;
|
||||
|
||||
@Description("Should this entity be removed when far away")
|
||||
private boolean removable = false;
|
||||
@Description("Allow normal distance despawning. keepEntity and world.forcePersistEntities override this setting.")
|
||||
private boolean removable = true;
|
||||
|
||||
@Description("Entity helmet equipment")
|
||||
private IrisLoot helmet = null;
|
||||
@@ -197,6 +201,25 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
|
||||
@Description("Run raw commands when this entity is spawned. Use {x}, {y}, and {z} for location. /summon pig {x} {y} {z}")
|
||||
private KList<IrisCommand> rawCommands = new KList<>();
|
||||
|
||||
public String spawnCategory() {
|
||||
IrisPlatform platform = IrisPlatforms.getOrNull();
|
||||
if (platform == null || type == null || type.isBlank()) {
|
||||
return "misc";
|
||||
}
|
||||
PlatformRegistries registries = platform.registries();
|
||||
NativeEntityType resolved = registries == null ? null : registries.entity(type.trim().toLowerCase(Locale.ROOT));
|
||||
String category = resolved == null ? null : resolved.spawnCategory();
|
||||
return category == null || category.isBlank() ? "misc" : category;
|
||||
}
|
||||
|
||||
void applyPersistence(Entity entity, boolean forcePersistence) {
|
||||
boolean persistent = keepEntity || forcePersistence;
|
||||
entity.setPersistent(true);
|
||||
if (entity instanceof LivingEntity living) {
|
||||
living.setRemoveWhenFarAway(removable && !persistent);
|
||||
}
|
||||
}
|
||||
|
||||
public EntityType getBukkitType() {
|
||||
if (type == null || type.isBlank()) {
|
||||
return EntityType.UNKNOWN;
|
||||
@@ -292,7 +315,7 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
|
||||
e.setGravity(isGravity());
|
||||
e.setInvulnerable(isInvulnerable());
|
||||
e.setSilent(isSilent());
|
||||
e.setPersistent(isKeepEntity() || IrisSettings.get().getWorld().isForcePersistEntities());
|
||||
applyPersistence(e, IrisSettings.get().getWorld().isForcePersistEntities());
|
||||
|
||||
int gg = 0;
|
||||
for (IrisEntity i : passengers) {
|
||||
@@ -333,8 +356,6 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
|
||||
l.setLeashHolder(getLeashHolder().spawn(gen, at, rng.nextParallelRNG(234548)));
|
||||
}
|
||||
|
||||
l.setRemoveWhenFarAway(isRemovable());
|
||||
|
||||
if (getHelmet() != null && LootResolver.oneIn(rng, getHelmet().getRarity())) {
|
||||
l.getEquipment().setHelmet(getHelmet().get(gen.isStudio(), rng));
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import org.bukkit.HeightMap;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Entity;
|
||||
|
||||
@Snippet("entity-spawn")
|
||||
@@ -60,6 +61,8 @@ import org.bukkit.entity.Entity;
|
||||
@Description("Represents an entity spawn during initial chunk generation")
|
||||
@Data
|
||||
public class IrisEntitySpawn implements Rarity {
|
||||
private static final int CAVE_COLUMN_ATTEMPTS = 8;
|
||||
private static final int CAVE_VERTICAL_ATTEMPTS = 128;
|
||||
private final transient AtomicCache<RNG> rng = new AtomicCache<>();
|
||||
private final transient AtomicCache<IrisEntity> ent = new AtomicCache<>();
|
||||
@RegistryListResource(IrisEntity.class)
|
||||
@@ -78,25 +81,32 @@ public class IrisEntitySpawn implements Rarity {
|
||||
private transient IrisSpawner referenceSpawner;
|
||||
private transient IrisMarker referenceMarker;
|
||||
|
||||
public int spawn(Engine gen, Chunk c, RNG rng) {
|
||||
public int spawn(Engine gen, Chunk c, RNG rng, int remainingCapacity) {
|
||||
if (remainingCapacity <= 0) {
|
||||
return 0;
|
||||
}
|
||||
IrisEntity definition = getRealEntity(gen);
|
||||
if (definition == null) {
|
||||
return 0;
|
||||
}
|
||||
int spawns = LootResolver.inclusive(rng, minSpawns, maxSpawns);
|
||||
int spawns = Math.min(remainingCapacity, LootResolver.inclusive(rng, minSpawns, maxSpawns));
|
||||
int s = 0;
|
||||
|
||||
if (spawns > 0) {
|
||||
for (int id = 0; id < spawns; id++) {
|
||||
int x = (c.getX() * 16) + rng.i(16);
|
||||
int z = (c.getZ() * 16) + rng.i(16);
|
||||
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);
|
||||
IrisSpawnGroup group = getReferenceSpawner().getGroup();
|
||||
Integer y = selectSurfaceSpawnY(group, definition.getSurface(), h, hf, rng);
|
||||
Location l = group == IrisSpawnGroup.CAVE ? findCaveSpawnLocation(gen, c, rng)
|
||||
: y == null ? null : new Location(world, x, y, z);
|
||||
l = y == null ? null : new Location(world, x, y, z);
|
||||
}
|
||||
|
||||
if (l != null) {
|
||||
if (referenceSpawner.getAllowedLightLevels().getMin() > 0 || referenceSpawner.getAllowedLightLevels().getMax() < 15) {
|
||||
@@ -193,16 +203,50 @@ public class IrisEntitySpawn implements Rarity {
|
||||
return null;
|
||||
}
|
||||
|
||||
static Location findCaveSpawnLocation(Engine engine, Chunk chunk, RNG rng) {
|
||||
static Location findCaveSpawnLocation(Engine engine, Chunk chunk, RNG rng, IrisSurface surface) {
|
||||
if (J.isFolia()) {
|
||||
if (!J.isOwnedByCurrentRegion(chunk.getWorld(), chunk.getX(), chunk.getZ())) {
|
||||
return null;
|
||||
}
|
||||
return findLiveCaveSpawnLocation(chunk, rng, surface);
|
||||
}
|
||||
KList<IrisPosition> markers = engine.getMantle().findMarkers(chunk.getX(), chunk.getZ(), MarkerMatter.CAVE_FLOOR);
|
||||
return selectCaveSpawnLocation(markers, chunk.getWorld(), rng);
|
||||
}
|
||||
|
||||
static Location findLiveCaveSpawnLocation(Chunk chunk, RNG rng, IrisSurface surface) {
|
||||
boolean fluid = surface.isFluid();
|
||||
World world = chunk.getWorld();
|
||||
int minimumY = world.getMinHeight() + 1;
|
||||
for (int attempt = 0; attempt < CAVE_COLUMN_ATTEMPTS; attempt++) {
|
||||
int localX = rng.i(1, 15);
|
||||
int localZ = rng.i(1, 15);
|
||||
int worldX = (chunk.getX() << 4) + localX;
|
||||
int worldZ = (chunk.getZ() << 4) + localZ;
|
||||
int maximumY = Math.min(world.getMaxHeight() - 3,
|
||||
world.getHighestBlockYAt(worldX, worldZ, HeightMap.OCEAN_FLOOR) - 1);
|
||||
if (maximumY < minimumY) {
|
||||
continue;
|
||||
}
|
||||
int span = maximumY - minimumY + 1;
|
||||
int startY = LootResolver.inclusive(rng, minimumY, maximumY);
|
||||
int samples = Math.min(span, CAVE_VERTICAL_ATTEMPTS);
|
||||
for (int sample = 0; sample < samples; sample++) {
|
||||
int y = minimumY + Math.floorMod(startY - minimumY - sample, span);
|
||||
Block block = chunk.getBlock(localX, y, localZ);
|
||||
if (fluid ? surface.matches(block) : isAir(block.getType())
|
||||
&& chunk.getBlock(localX, y - 1, localZ).isSolid()
|
||||
&& isAir(chunk.getBlock(localX, y + 1, localZ).getType())) {
|
||||
return new Location(world, worldX, y, worldZ);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Location selectCaveSpawnLocation(KList<IrisPosition> markers, World world, RNG rng) {
|
||||
return markers.convert((marker) -> BukkitPlatform.toLocation(marker, world).add(0, 1, 0)).getRandom(rng);
|
||||
IrisPosition marker = markers.getRandom(rng);
|
||||
return marker == null ? null : BukkitPlatform.toLocation(marker, world);
|
||||
}
|
||||
|
||||
private Entity spawn100(Engine g, Location at) {
|
||||
@@ -217,6 +261,9 @@ public class IrisEntitySpawn implements Rarity {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (J.isFolia() && !J.isOwnedByCurrentRegion(at.getWorld(), at.getBlockX() >> 4, at.getBlockZ() >> 4)) {
|
||||
return null;
|
||||
}
|
||||
IrisSurface surface = irisEntity.getSurface();
|
||||
boolean checkPosition = !ignoreSurfaces || surface.isFluid();
|
||||
if (checkPosition && !surface.matches(at.clone().subtract(0, surface.isFluid() ? 0 : 1, 0).getBlock())) {
|
||||
@@ -231,7 +278,7 @@ public class IrisEntitySpawn implements Rarity {
|
||||
}
|
||||
}
|
||||
|
||||
Entity e = irisEntity.spawn(g, at.clone().add(0.5, 0.5, 0.5), rng.aquire(() -> new RNG(g.getSeedManager().getEntity())));
|
||||
Entity e = irisEntity.spawn(g, at.clone().add(0.5, surface.isFluid() ? 0.5 : 0, 0.5), rng.aquire(() -> new RNG(g.getSeedManager().getEntity())));
|
||||
if (e != null) {
|
||||
IrisLogging.debug("Spawned " + C.DARK_AQUA + "Entity<" + getEntity() + "> " + C.GREEN + e.getType() + C.LIGHT_PURPLE + " @ " + C.GRAY + e.getLocation().getX() + ", " + e.getLocation().getY() + ", " + e.getLocation().getZ());
|
||||
}
|
||||
@@ -244,26 +291,38 @@ public class IrisEntitySpawn implements Rarity {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAir(Material material) {
|
||||
return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR;
|
||||
}
|
||||
|
||||
private boolean isAreaClearForSpawn(Location center, Vector3d boundingBox, IrisSurface surface) {
|
||||
World world = center.getWorld();
|
||||
boolean fluid = surface.isFluid();
|
||||
int startX = fluid ? (int) Math.floor(center.getX() + 0.5 - boundingBox.x / 2)
|
||||
: center.getBlockX() - (int) (boundingBox.x / 2);
|
||||
int endX = fluid ? (int) Math.floor(Math.nextDown(center.getX() + 0.5 + boundingBox.x / 2))
|
||||
: center.getBlockX() + (int) (boundingBox.x / 2);
|
||||
int startY = fluid ? (int) Math.floor(center.getY() + 0.5) : center.getBlockY();
|
||||
int endY = fluid ? (int) Math.floor(Math.nextDown(center.getY() + 0.5 + boundingBox.y))
|
||||
: center.getBlockY() + (int) boundingBox.y;
|
||||
int startZ = fluid ? (int) Math.floor(center.getZ() + 0.5 - boundingBox.z / 2)
|
||||
: center.getBlockZ() - (int) (boundingBox.z / 2);
|
||||
int endZ = fluid ? (int) Math.floor(Math.nextDown(center.getZ() + 0.5 + boundingBox.z / 2))
|
||||
: center.getBlockZ() + (int) (boundingBox.z / 2);
|
||||
int startX = (int) Math.floor(center.getX() + 0.5 - boundingBox.x / 2);
|
||||
int endX = (int) Math.floor(Math.nextDown(center.getX() + 0.5 + boundingBox.x / 2));
|
||||
double baseY = center.getY() + (fluid ? 0.5 : 0);
|
||||
int startY = (int) Math.floor(baseY);
|
||||
int endY = (int) Math.floor(Math.nextDown(baseY + boundingBox.y));
|
||||
int startZ = (int) Math.floor(center.getZ() + 0.5 - boundingBox.z / 2);
|
||||
int endZ = (int) Math.floor(Math.nextDown(center.getZ() + 0.5 + boundingBox.z / 2));
|
||||
|
||||
if (startY < world.getMinHeight() || endY >= world.getMaxHeight()) {
|
||||
return false;
|
||||
}
|
||||
if (J.isFolia()) {
|
||||
for (int chunkX = startX >> 4; chunkX <= endX >> 4; chunkX++) {
|
||||
for (int chunkZ = startZ >> 4; chunkZ <= endZ >> 4; chunkZ++) {
|
||||
if (!J.isOwnedByCurrentRegion(world, chunkX, chunkZ)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int x = startX; x <= endX; x++) {
|
||||
for (int y = startY; y <= endY; y++) {
|
||||
for (int z = startZ; z <= endZ; z++) {
|
||||
if (fluid ? !surface.matches(world.getBlockAt(x, y, z))
|
||||
: world.getBlockAt(x, y, z).getType() != Material.AIR) {
|
||||
: !isAir(world.getBlockAt(x, y, z).getType())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Accessors(chain = true)
|
||||
@@ -62,7 +63,7 @@ public class IrisSpawner extends IrisRegistrant {
|
||||
@Description("The entity spawns to add initially. EXECUTES PER CHUNK!")
|
||||
private KList<IrisEntitySpawn> initialSpawns = new KList<>();
|
||||
|
||||
@Description("This spawner will not spawn in a given chunk if that chunk has more than the defined amount of living entities.")
|
||||
@Description("Maximum ambient entities of the selected entity's native spawn category in one chunk. Spawn groups are limited to the remaining capacity.")
|
||||
private int maxEntitiesPerChunk = 1;
|
||||
|
||||
@Description("The block of 24 hour time to contain this spawn in.")
|
||||
@@ -83,6 +84,21 @@ public class IrisSpawner extends IrisRegistrant {
|
||||
@Description("Where should these spawns be placed")
|
||||
private IrisSpawnGroup group = IrisSpawnGroup.NORMAL;
|
||||
|
||||
public int remainingCapacity(IrisEntity entity, Map<String, Integer> counts) {
|
||||
if (entity == null) {
|
||||
return 0;
|
||||
}
|
||||
String category = entity.spawnCategory();
|
||||
int count = counts.getOrDefault(category, 0);
|
||||
if ("misc".equals(category)) {
|
||||
count = 0;
|
||||
for (int population : counts.values()) {
|
||||
count += population;
|
||||
}
|
||||
}
|
||||
return Math.max(0, maxEntitiesPerChunk - count);
|
||||
}
|
||||
|
||||
public boolean isValid(IrisBiome biome) {
|
||||
return switch (group) {
|
||||
case NORMAL -> switch (biome.getInferredType()) {
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
import art.arcane.iris.testsupport.BukkitTestServer;
|
||||
import io.papermc.paper.InternalAPIBridge;
|
||||
import io.papermc.paper.registry.RegistryAccess;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.GameRule;
|
||||
import org.bukkit.GameRules;
|
||||
import org.bukkit.Registry;
|
||||
import org.bukkit.World;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.RETURNS_DEFAULTS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class WorldEntitySpawnerAdmissionTest {
|
||||
@BeforeClass
|
||||
public static void initializeGameRules() throws Exception {
|
||||
BukkitTestServer.install();
|
||||
AtomicReference<Object> registered = new AtomicReference<>();
|
||||
RegistryAccess access = mock(RegistryAccess.class, invocation -> {
|
||||
if (invocation.getMethod().getName().equals("getRegistry")) {
|
||||
if (registered.get() == null) {
|
||||
registered.set(Proxy.newProxyInstance(Registry.class.getClassLoader(),
|
||||
new Class<?>[]{Registry.class},
|
||||
(proxy, method, arguments) -> method.getName().equals("getOrThrow")
|
||||
? mock(GameRule.class) : null));
|
||||
}
|
||||
return registered.get();
|
||||
}
|
||||
return RETURNS_DEFAULTS.answer(invocation);
|
||||
});
|
||||
InternalAPIBridge bridge = mock(InternalAPIBridge.class);
|
||||
try (MockedStatic<RegistryAccess> registries = mockStatic(RegistryAccess.class);
|
||||
MockedStatic<InternalAPIBridge> internals = mockStatic(InternalAPIBridge.class)) {
|
||||
registries.when(RegistryAccess::registryAccess).thenReturn(access);
|
||||
internals.when(InternalAPIBridge::get).thenReturn(bridge);
|
||||
Class.forName(GameRules.class.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void gameruleDisablesInitialAndRecurringAmbientSpawns() {
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
World world = mock(World.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(chunk.getLoadLevel()).thenReturn(Chunk.LoadLevel.ENTITY_TICKING);
|
||||
when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(false);
|
||||
assertFalse(WorldEntitySpawner.ambientAllowed(chunk, false));
|
||||
assertFalse(WorldEntitySpawner.ambientAllowed(chunk, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recurringSpawnsRequireEntityTickingChunks() {
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
World world = mock(World.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true);
|
||||
for (Chunk.LoadLevel loadLevel : Chunk.LoadLevel.values()) {
|
||||
when(chunk.getLoadLevel()).thenReturn(loadLevel);
|
||||
if (loadLevel == Chunk.LoadLevel.ENTITY_TICKING) {
|
||||
assertTrue(WorldEntitySpawner.ambientAllowed(chunk, false));
|
||||
} else {
|
||||
assertFalse(WorldEntitySpawner.ambientAllowed(chunk, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void initialAmbientSpawnsRetainChunkPopulationAdmission() {
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
World world = mock(World.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true);
|
||||
when(chunk.getLoadLevel()).thenReturn(Chunk.LoadLevel.TICKING);
|
||||
assertTrue(WorldEntitySpawner.ambientAllowed(chunk, true));
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.BukkitEntityType;
|
||||
import art.arcane.iris.world.entity.IrisEntity;
|
||||
import art.arcane.iris.world.entity.IrisEntitySpawn;
|
||||
import art.arcane.iris.world.entity.IrisSpawner;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class WorldEntitySpawnerPopulationTest {
|
||||
@Test
|
||||
public void frogsAndPlayersDoNotConsumeMonsterCapacity() {
|
||||
Engine engine = mock(Engine.class);
|
||||
LivingEntity frog = mock(LivingEntity.class);
|
||||
LivingEntity zombie = mock(LivingEntity.class);
|
||||
Player player = mock(Player.class);
|
||||
when(frog.getType()).thenReturn(EntityType.FROG);
|
||||
when(zombie.getType()).thenReturn(EntityType.ZOMBIE);
|
||||
BukkitEntityType creatureType = mock(BukkitEntityType.class);
|
||||
BukkitEntityType monsterType = mock(BukkitEntityType.class);
|
||||
when(creatureType.spawnCategory()).thenReturn("creature");
|
||||
when(monsterType.spawnCategory()).thenReturn("monster");
|
||||
IrisEntitySpawn entry = mock(IrisEntitySpawn.class);
|
||||
IrisEntity definition = mock(IrisEntity.class);
|
||||
when(definition.spawnCategory()).thenReturn("monster");
|
||||
when(entry.getRealEntity(engine)).thenReturn(definition);
|
||||
when(entry.getReferenceSpawner()).thenReturn(new IrisSpawner().setMaxEntitiesPerChunk(2));
|
||||
try (MockedStatic<BukkitEntityType> types = mockStatic(BukkitEntityType.class)) {
|
||||
types.when(() -> BukkitEntityType.of(EntityType.FROG)).thenReturn(creatureType);
|
||||
types.when(() -> BukkitEntityType.of(EntityType.ZOMBIE)).thenReturn(monsterType);
|
||||
WorldEntitySpawner.ChunkCounter counter = new WorldEntitySpawner.ChunkCounter(
|
||||
new Entity[]{frog, frog, frog, zombie, player});
|
||||
assertEquals(1, counter.remainingCapacity(entry, engine));
|
||||
when(definition.spawnCategory()).thenReturn("creature");
|
||||
assertEquals(0, counter.remainingCapacity(entry, engine));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ambientAndWaterPopulationsHaveIndependentBudgets() {
|
||||
IrisSpawner spawner = new IrisSpawner().setMaxEntitiesPerChunk(3);
|
||||
IrisEntity entity = mock(IrisEntity.class);
|
||||
Map<String, Integer> counts = Map.of("monster", 8, "ambient", 2, "water_creature", 3);
|
||||
when(entity.spawnCategory()).thenReturn("ambient");
|
||||
assertEquals(1, spawner.remainingCapacity(entity, counts));
|
||||
when(entity.spawnCategory()).thenReturn("water_creature");
|
||||
assertEquals(0, spawner.remainingCapacity(entity, counts));
|
||||
when(entity.spawnCategory()).thenReturn("creature");
|
||||
assertEquals(3, spawner.remainingCapacity(entity, counts));
|
||||
when(entity.spawnCategory()).thenReturn("misc");
|
||||
assertEquals(0, spawner.remainingCapacity(entity, counts));
|
||||
assertEquals(0, spawner.remainingCapacity(null, counts));
|
||||
assertEquals(0, spawner.setMaxEntitiesPerChunk(0).remainingCapacity(entity, counts));
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest {
|
||||
@ClassRule
|
||||
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
|
||||
|
||||
private static final String EXPECTED_SCHEMA_DIGEST = "4d37a4cf8bb02c6e334545b549433a1b75ff5b8aae0bc91cd8b21cd8f62307c0";
|
||||
private static final String EXPECTED_SCHEMA_DIGEST = "d77cc4ea8255cbfdc4f8c49e04a3c422ca3e094399a514799ec703217f7797da";
|
||||
|
||||
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
|
||||
IrisDimension.class,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package art.arcane.iris.world.entity;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import com.google.gson.Gson;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class IrisEntityPersistenceTest {
|
||||
@Test
|
||||
public void defaultAmbientMobsSaveAndAllowDistanceDespawn() {
|
||||
IrisEntity options = new IrisEntity();
|
||||
LivingEntity entity = mock(LivingEntity.class);
|
||||
|
||||
assertTrue(options.isRemovable());
|
||||
assertFalse(new IrisSettings.IrisSettingsWorld().isForcePersistEntities());
|
||||
options.applyPersistence(entity, false);
|
||||
|
||||
verify(entity).setPersistent(true);
|
||||
verify(entity).setRemoveWhenFarAway(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitRetentionOverridesDistanceRemoval() {
|
||||
for (boolean keep : new boolean[]{false, true}) {
|
||||
for (boolean force : new boolean[]{false, true}) {
|
||||
for (boolean removable : new boolean[]{false, true}) {
|
||||
IrisEntity options = new IrisEntity().setKeepEntity(keep).setRemovable(removable);
|
||||
LivingEntity entity = mock(LivingEntity.class);
|
||||
|
||||
options.applyPersistence(entity, force);
|
||||
|
||||
verify(entity).setPersistent(true);
|
||||
verify(entity).setRemoveWhenFarAway(removable && !keep && !force);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonMobEntitiesStillSaveNormally() {
|
||||
Entity entity = mock(Entity.class);
|
||||
|
||||
new IrisEntity().applyPersistence(entity, false);
|
||||
|
||||
verify(entity).setPersistent(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authoredRetentionChoicesSurviveChangedDefaults() {
|
||||
Gson gson = new Gson();
|
||||
IrisEntity retained = gson.fromJson("{\"removable\":false,\"keepEntity\":true}", IrisEntity.class);
|
||||
IrisSettings.IrisSettingsWorld settings = gson.fromJson("{\"forcePersistEntities\":true}", IrisSettings.IrisSettingsWorld.class);
|
||||
assertFalse(retained.isRemovable());
|
||||
assertTrue(retained.isKeepEntity());
|
||||
assertTrue(settings.isForcePersistEntities());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package art.arcane.iris.world.entity;
|
||||
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.volmlib.nativelib.entity.NativeEntityType;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisEntitySpawnCategoryTest {
|
||||
private IrisPlatform previous;
|
||||
|
||||
@Before
|
||||
public void saveBinding() {
|
||||
previous = IrisPlatforms.getOrNull();
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@After
|
||||
public void restoreBinding() {
|
||||
IrisPlatforms.unbind();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unavailableAndUnknownTypesUseMisc() {
|
||||
IrisEntity entity = new IrisEntity().setType("minecraft:zombie");
|
||||
assertEquals("misc", entity.spawnCategory());
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.bind(platform);
|
||||
assertEquals("misc", entity.spawnCategory());
|
||||
assertEquals("misc", new IrisEntity().setType(null).spawnCategory());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentRegistryCategoryIsNeverStuckAcrossPlatformRebinding() {
|
||||
IrisEntity entity = new IrisEntity().setType("minecraft:zombie");
|
||||
bind("monster");
|
||||
assertEquals("monster", entity.spawnCategory());
|
||||
IrisPlatforms.unbind();
|
||||
bind("creature");
|
||||
assertEquals("creature", entity.spawnCategory());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptedMixedCaseAndWhitespaceTypesKeepNativeCategory() {
|
||||
bind("monster");
|
||||
assertEquals("monster", new IrisEntity().setType(" MINECRAFT:ZoMbIe ").spawnCategory());
|
||||
}
|
||||
|
||||
private static void bind(String category) {
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
NativeEntityType type = mock(NativeEntityType.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
when(registries.entity("minecraft:zombie")).thenReturn(type);
|
||||
when(type.spawnCategory()).thenReturn(category);
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,14 @@ import art.arcane.iris.pack.value.IrisPosition;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.SeedManager;
|
||||
import art.arcane.iris.generation.mantle.EngineMantle;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.math.Vector3d;
|
||||
import art.arcane.volmlib.util.matter.slices.MarkerMatter;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.Location;
|
||||
@@ -36,6 +39,10 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.atMost;
|
||||
|
||||
public class IrisEntitySpawnTest {
|
||||
@Test
|
||||
@@ -43,6 +50,235 @@ public class IrisEntitySpawnTest {
|
||||
assertNull(IrisEntitySpawn.selectCaveSpawnLocation(new KList<>(), null, new RNG(1L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void caveFloorMarkerSpawnsAtFirstAirBlockInNegativeWorldHeight() {
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, -50, -50));
|
||||
MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
scheduler.when(J::isFolia).thenReturn(false);
|
||||
when(fixture.world.getMinHeight()).thenReturn(-64);
|
||||
EngineMantle mantle = mock(EngineMantle.class);
|
||||
when(fixture.engine.getMantle()).thenReturn(mantle);
|
||||
when(mantle.findMarkers(0, 0, MarkerMatter.CAVE_FLOOR))
|
||||
.thenReturn(new KList<>(new IrisPosition(7, -49, 9)));
|
||||
fixture.platform.when(() -> BukkitPlatform.toLocation(any(IrisPosition.class), eq(fixture.world)))
|
||||
.thenAnswer(invocation -> {
|
||||
IrisPosition position = invocation.getArgument(0);
|
||||
return new Location(fixture.world, position.getX(), position.getY(), position.getZ());
|
||||
});
|
||||
|
||||
assertEquals(3, fixture.spawn(IrisSpawnGroup.CAVE));
|
||||
|
||||
assertEquals(List.of(new Location(fixture.world, 7.5, -49, 9.5),
|
||||
new Location(fixture.world, 7.5, -49, 9.5),
|
||||
new Location(fixture.world, 7.5, -49, 9.5)), fixture.positions);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void foliaCaveSpawnUsesOwnedLiveBlocksAndNegativeWorldHeights() {
|
||||
Engine engine = mock(Engine.class);
|
||||
World world = mock(World.class);
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(chunk.getX()).thenReturn(-2);
|
||||
when(chunk.getZ()).thenReturn(3);
|
||||
when(world.getMinHeight()).thenReturn(-64);
|
||||
when(world.getMaxHeight()).thenReturn(320);
|
||||
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(0);
|
||||
when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
int y = invocation.getArgument(1);
|
||||
Block block = mock(Block.class);
|
||||
when(block.getType()).thenReturn(y == -49 || y == -48 ? Material.CAVE_AIR : Material.STONE);
|
||||
when(block.isSolid()).thenReturn(y != -49 && y != -48);
|
||||
return block;
|
||||
});
|
||||
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
scheduler.when(J::isFolia).thenReturn(true);
|
||||
scheduler.when(() -> J.isOwnedByCurrentRegion(world, -2, 3)).thenReturn(true);
|
||||
|
||||
Location location = IrisEntitySpawn.findCaveSpawnLocation(engine, chunk, new RNG(1L), IrisSurface.LAND);
|
||||
|
||||
assertEquals(-49, location.getBlockY());
|
||||
assertEquals(-2, location.getBlockX() >> 4);
|
||||
assertEquals(3, location.getBlockZ() >> 4);
|
||||
verify(engine, never()).getMantle();
|
||||
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void foliaCaveGroupSpawnsEntitiesInsideLiveCaveAir() {
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78));
|
||||
MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
scheduler.when(J::isFolia).thenReturn(true);
|
||||
scheduler.when(() -> J.isOwnedByCurrentRegion(fixture.world, 0, 0)).thenReturn(true);
|
||||
when(fixture.world.getMinHeight()).thenReturn(-64);
|
||||
when(fixture.world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(0);
|
||||
when(fixture.world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
int y = invocation.getArgument(1);
|
||||
Block block = mock(Block.class);
|
||||
boolean air = y == -49 || y == -48;
|
||||
when(block.getType()).thenReturn(air ? Material.CAVE_AIR : Material.STONE);
|
||||
when(block.isSolid()).thenReturn(!air);
|
||||
return block;
|
||||
});
|
||||
when(fixture.chunk.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
|
||||
fixture.world.getBlockAt(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)));
|
||||
|
||||
fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE))
|
||||
.thenReturn(new Vector3d(0.6, 1.95, 0.6));
|
||||
|
||||
assertEquals(3, fixture.spawn(IrisSpawnGroup.CAVE));
|
||||
|
||||
assertTrue(fixture.positions.stream().allMatch(position -> position.getY() == -49D));
|
||||
verify(fixture.engine, never()).getMantle();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void foliaCaveScanDoesNotReadUnownedChunks() {
|
||||
World world = mock(World.class);
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
scheduler.when(J::isFolia).thenReturn(true);
|
||||
assertNull(IrisEntitySpawn.findCaveSpawnLocation(mock(Engine.class), chunk, new RNG(1L), IrisSurface.LAND));
|
||||
verify(chunk, never()).getBlock(anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void caveScanIsBoundedWhenNoSafeFloorExists() {
|
||||
World world = mock(World.class);
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(world.getMinHeight()).thenReturn(-64);
|
||||
when(world.getMaxHeight()).thenReturn(4096);
|
||||
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(4000);
|
||||
Block solid = mock(Block.class);
|
||||
when(solid.getType()).thenReturn(Material.STONE);
|
||||
when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(solid);
|
||||
|
||||
assertNull(IrisEntitySpawn.findLiveCaveSpawnLocation(chunk, new RNG(1L), IrisSurface.LAND));
|
||||
|
||||
verify(chunk, times(1024)).getBlock(anyInt(), anyInt(), anyInt());
|
||||
verify(world, atMost(8)).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void foliaFluidCaveGroupsSpawnInsideMatchingNegativeHeightFluidWithoutSolidSupport() {
|
||||
for (IrisSurface surface : List.of(IrisSurface.WATER, IrisSurface.LAVA)) {
|
||||
Material fluid = surface == IrisSurface.WATER ? Material.WATER : Material.LAVA;
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(surface, fluid, -65, -1));
|
||||
MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
scheduler.when(J::isFolia).thenReturn(true);
|
||||
scheduler.when(() -> J.isOwnedByCurrentRegion(fixture.world, 0, 0)).thenReturn(true);
|
||||
when(fixture.world.getMinHeight()).thenReturn(-64);
|
||||
when(fixture.world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR)))
|
||||
.thenReturn(0);
|
||||
when(fixture.chunk.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
|
||||
fixture.world.getBlockAt(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)));
|
||||
|
||||
assertEquals(surface.name(), 3, fixture.spawn(IrisSpawnGroup.CAVE));
|
||||
|
||||
for (Location position : fixture.positions) {
|
||||
assertTrue(position.getY() < 0);
|
||||
assertEquals(fluid, position.getBlock().getType());
|
||||
assertEquals(fluid, position.clone().subtract(0, 1, 0).getBlock().getType());
|
||||
}
|
||||
verify(fixture.engine, never()).getMantle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fluidCaveScannerRejectsOtherFluidsAndDryFloorScannerRejectsFluid() {
|
||||
World world = mock(World.class);
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(world.getMinHeight()).thenReturn(-64);
|
||||
when(world.getMaxHeight()).thenReturn(320);
|
||||
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(0);
|
||||
Block water = mock(Block.class);
|
||||
when(water.getType()).thenReturn(Material.WATER);
|
||||
when(chunk.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(water);
|
||||
|
||||
assertNull(IrisEntitySpawn.findLiveCaveSpawnLocation(chunk, new RNG(1L), IrisSurface.LAVA));
|
||||
assertNull(IrisEntitySpawn.findLiveCaveSpawnLocation(chunk, new RNG(1L), IrisSurface.LAND));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landEntityBodyRejectsCeilingAndPartialWidthObstructions() {
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
|
||||
fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE))
|
||||
.thenReturn(new Vector3d(1.2, 1.95, 0.6));
|
||||
Block stone = mock(Block.class);
|
||||
when(stone.getType()).thenReturn(Material.STONE);
|
||||
when(stone.isSolid()).thenReturn(true);
|
||||
when(fixture.world.getBlockAt(-1, 79, 0)).thenReturn(stone);
|
||||
|
||||
assertNull(fixture.entry.spawn(fixture.engine, new Location(fixture.world, 0, 79, 0)));
|
||||
assertTrue(fixture.positions.isEmpty());
|
||||
}
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
|
||||
fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE))
|
||||
.thenReturn(new Vector3d(0.6, 2.01, 0.6));
|
||||
Block stone = mock(Block.class);
|
||||
when(stone.getType()).thenReturn(Material.STONE);
|
||||
when(fixture.world.getBlockAt(0, 81, 0)).thenReturn(stone);
|
||||
|
||||
assertNull(fixture.entry.spawn(fixture.engine, new Location(fixture.world, 0, 79, 0)));
|
||||
assertTrue(fixture.positions.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ambientBatchCannotExceedRemainingCategoryCapacity() {
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
|
||||
fixture.remainingCapacity = 1;
|
||||
assertEquals(1, fixture.spawn(IrisSpawnGroup.NORMAL));
|
||||
assertEquals(1, fixture.positions.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saturatedAmbientCategorySkipsEverySpawnAttempt() {
|
||||
for (int capacity : new int[]{0, -1}) {
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
|
||||
fixture.remainingCapacity = capacity;
|
||||
assertEquals(0, fixture.spawn(IrisSpawnGroup.NORMAL));
|
||||
assertTrue(fixture.positions.isEmpty());
|
||||
verify(fixture.world, never()).getHighestBlockYAt(anyInt(), anyInt(), any(HeightMap.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landEntityBodiesAcceptEveryAirMaterial() {
|
||||
for (Material material : List.of(Material.AIR, Material.CAVE_AIR, Material.VOID_AIR)) {
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
|
||||
fixture.airMaterial = material;
|
||||
assertEquals(material.name(), 3, fixture.spawn(IrisSpawnGroup.NORMAL));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void foliaWideEntityRejectsUnownedBoundingChunksBeforeReadingThem() {
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78));
|
||||
MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
scheduler.when(J::isFolia).thenReturn(true);
|
||||
scheduler.when(() -> J.isOwnedByCurrentRegion(fixture.world, 0, 0)).thenReturn(true);
|
||||
fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE))
|
||||
.thenReturn(new Vector3d(4D, 1D, 4D));
|
||||
|
||||
assertNull(fixture.entry.spawn(fixture.engine, new Location(fixture.world, 0, 79, 0)));
|
||||
|
||||
verify(fixture.world, never()).getBlockAt(eq(-2), anyInt(), anyInt());
|
||||
assertTrue(fixture.positions.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mixedNormalSpawnerPlacesEveryTadpoleInsideOneBlockDeepWater() {
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.WATER, Material.WATER, 77, 78))) {
|
||||
@@ -81,7 +317,7 @@ public class IrisEntitySpawnTest {
|
||||
public void mixedNormalSpawnerRetainsLandEntityPlacement() {
|
||||
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
|
||||
assertEquals(3, fixture.spawn(IrisSpawnGroup.NORMAL));
|
||||
assertTrue(fixture.positions.stream().allMatch(position -> position.getY() == 79.5));
|
||||
assertTrue(fixture.positions.stream().allMatch(position -> position.getY() == 79));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,8 +394,8 @@ public class IrisEntitySpawnTest {
|
||||
fixture.entry.spawn(fixture.engine, location);
|
||||
fixture.entry.spawn(fixture.engine, location);
|
||||
assertEquals(new Location(fixture.world, 0, 79, 0), location);
|
||||
assertEquals(List.of(new Location(fixture.world, 0.5, 79.5, 0.5),
|
||||
new Location(fixture.world, 0.5, 79.5, 0.5)), fixture.positions);
|
||||
assertEquals(List.of(new Location(fixture.world, 0.5, 79, 0.5),
|
||||
new Location(fixture.world, 0.5, 79, 0.5)), fixture.positions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +409,8 @@ public class IrisEntitySpawnTest {
|
||||
private final IrisEntitySpawn entry = spy(new IrisEntitySpawn());
|
||||
private final List<Location> positions = new ArrayList<>();
|
||||
private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
|
||||
private Material airMaterial = Material.AIR;
|
||||
private int remainingCapacity = Integer.MAX_VALUE;
|
||||
|
||||
private SpawnFixture(SpawnOptions options) {
|
||||
IrisSurface surface = options.surface();
|
||||
@@ -184,12 +422,13 @@ public class IrisEntitySpawnTest {
|
||||
when(definition.getBukkitType()).thenReturn(EntityType.TADPOLE);
|
||||
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(world.getMaxHeight()).thenReturn(256);
|
||||
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(floor);
|
||||
when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.WORLD_SURFACE))).thenReturn(top);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
int y = invocation.getArgument(1);
|
||||
Block block = mock(Block.class);
|
||||
when(block.getType()).thenReturn(y <= floor ? Material.STONE : y <= top ? fluid : Material.AIR);
|
||||
when(block.getType()).thenReturn(y <= floor ? Material.STONE : y <= top ? fluid : airMaterial);
|
||||
when(block.isSolid()).thenReturn(y <= floor);
|
||||
return block;
|
||||
});
|
||||
@@ -213,7 +452,7 @@ public class IrisEntitySpawnTest {
|
||||
|
||||
private int spawn(IrisSpawnGroup group) {
|
||||
entry.setReferenceSpawner(new IrisSpawner().setGroup(group));
|
||||
return entry.spawn(engine, chunk, new RNG(1337L));
|
||||
return entry.spawn(engine, chunk, new RNG(1337L), remainingCapacity);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
Reference in New Issue
Block a user