Fix seeded entity spawning

- Derive initial chunk and marker populations from stable location seeds.
- Persist initial spawn completion after region-owned application.
- Seed entity loot and attribute modifier identities.
- Cover spawning, reload completion, and registry fixtures with regression tests.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-04 06:14:46 -04:00
1 parent 12e8bb5a09
commit be0d3b0156
33 files changed
+1932 -407

No files matched your search

@@ -20,7 +20,6 @@ import org.mockito.MockedStatic;
import java.lang.reflect.Proxy;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
@@ -35,18 +34,7 @@ public class LinkedTerrainChunkTest {
@BeforeClass
public static void initializeBiomes() throws Exception {
BukkitTestServer.install();
AtomicReference<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")
? biome((Key) arguments[0]) : null));
}
return registered.get();
}
return RETURNS_DEFAULTS.answer(invocation);
});
RegistryAccess access = BukkitTestServer.registryAccess();
InternalAPIBridge bridge = mock(InternalAPIBridge.class, invocation ->
invocation.getMethod().getName().equals("constructLegacyCustomBiome")
? biome(Key.key("minecraft:custom")) : RETURNS_DEFAULTS.answer(invocation));
@@ -0,0 +1,211 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.platform.bukkit.plugin.Chunks;
import art.arcane.iris.generation.context.IrisContext;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import org.bukkit.GameRules;
import org.bukkit.World;
import org.junit.Test;
import org.junit.BeforeClass;
import org.mockito.MockedStatic;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class WorldChunkMaintenanceInitialSpawnTest {
@BeforeClass
public static void initializeGameRules() throws Exception {
WorldEntitySpawnerAdmissionTest.initializeGameRules();
}
@Test
public void completionIsWrittenAfterOwningRegionWorkAndDuplicateRequestsAreSkipped() {
try (Fixture fixture = new Fixture()) {
AtomicInteger attempts = new AtomicInteger();
fixture.start(attempts::incrementAndGet);
fixture.start(attempts::incrementAndGet);
assertEquals(1, fixture.async.size());
fixture.async.remove().run();
verify(fixture.world, never()).isChunkLoaded(2, -1);
verify(fixture.mantle, never()).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true);
fixture.regions.remove().run();
assertEquals(1, attempts.get());
assertTrue(fixture.maintenance.isInitialSpawnComplete(2, -1));
verify(fixture.mantle, never()).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true);
fixture.start(attempts::incrementAndGet);
assertTrue(fixture.regions.isEmpty());
fixture.async.remove().run();
verify(fixture.mantle).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true);
}
}
@Test
public void unloadAndDisabledMobSpawningRemainEligibleForRetry() {
for (boolean unloaded : new boolean[]{true, false}) {
try (Fixture fixture = new Fixture()) {
AtomicInteger attempts = new AtomicInteger();
when(fixture.world.isChunkLoaded(2, -1)).thenReturn(!unloaded);
when(fixture.world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(unloaded);
fixture.start(attempts::incrementAndGet);
fixture.async.remove().run();
fixture.regions.remove().run();
assertFalse(fixture.maintenance.isInitialSpawnComplete(2, -1));
assertEquals(0, attempts.get());
verify(fixture.mantle, never()).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true);
when(fixture.world.isChunkLoaded(2, -1)).thenReturn(true);
when(fixture.world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true);
fixture.start(attempts::incrementAndGet);
fixture.async.remove().run();
fixture.regions.remove().run();
assertEquals(1, attempts.get());
}
}
}
@Test
public void persistedCompletionAndRegionRejectionDoNotRunInitialWork() {
try (Fixture fixture = new Fixture()) {
AtomicInteger attempts = new AtomicInteger();
when(fixture.mantle.hasFlag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER)).thenReturn(true);
fixture.start(attempts::incrementAndGet);
fixture.async.remove().run();
assertTrue(fixture.maintenance.isInitialSpawnComplete(2, -1));
assertTrue(fixture.regions.isEmpty());
assertEquals(0, attempts.get());
}
try (Fixture fixture = new Fixture()) {
AtomicInteger attempts = new AtomicInteger();
fixture.acceptRegions = false;
fixture.start(attempts::incrementAndGet);
fixture.async.remove().run();
fixture.start(attempts::incrementAndGet);
assertEquals(1, fixture.async.size());
assertFalse(fixture.maintenance.isInitialSpawnComplete(2, -1));
assertEquals(0, attempts.get());
}
}
@Test
public void shutdownDrainWaitsForTheReservedCompletionWriteAfterInitialEntitiesWereEmitted() throws Exception {
try (Fixture fixture = new Fixture()) {
AtomicInteger spawned = new AtomicInteger();
fixture.start(spawned::incrementAndGet);
fixture.async.remove().run();
try (GenerationSessionLease owner = fixture.sessions.acquire("initial_spawn_owner");
IrisContext.Scope ignored = IrisContext.open(fixture.engine, owner.sessionId(), null)) {
fixture.regions.remove().run();
}
assertEquals(1, spawned.get());
assertEquals(1, fixture.sessions.activeLeases());
when(fixture.engine.isClosing()).thenReturn(true);
ExecutorService closer = Executors.newSingleThreadExecutor();
try {
CountDownLatch sealing = new CountDownLatch(1);
Future<?> drained = closer.submit(() -> {
sealing.countDown();
fixture.sessions.sealAndAwait("shutdown", 2_000L, true);
return null;
});
assertTrue(sealing.await(1L, TimeUnit.SECONDS));
assertFalse(drained.isDone());
fixture.async.remove().run();
drained.get(1L, TimeUnit.SECONDS);
verify(fixture.mantle).flag(2, -1, MantleFlag.INITIAL_SPAWNED_MARKER, true);
assertEquals(0, fixture.sessions.activeLeases());
} finally {
closer.shutdownNow();
}
}
}
@SuppressWarnings("unchecked")
private static final class Fixture implements AutoCloseable {
private final Engine engine = mock(Engine.class);
private final GenerationSessionManager sessions = new GenerationSessionManager();
private final IrisWorldManager manager = mock(IrisWorldManager.class);
private final World world = mock(World.class);
private final Mantle<Matter> mantle = mock(Mantle.class);
private final WorldChunkMaintenance maintenance = new WorldChunkMaintenance(manager);
private final Queue<Runnable> async = new ArrayDeque<>();
private final Queue<Runnable> regions = new ArrayDeque<>();
private final MockedStatic<J> scheduler;
private final MockedStatic<Chunks> chunks;
private boolean acceptRegions = true;
private Fixture() {
scheduler = mockStatic(J.class);
try {
chunks = mockStatic(Chunks.class);
} catch (RuntimeException | Error failure) {
scheduler.close();
throw failure;
}
try {
configure();
} catch (RuntimeException | Error failure) {
close();
throw failure;
}
}
private void configure() {
when(manager.getEngine()).thenReturn(engine);
when(engine.getGenerationSessions()).thenReturn(sessions);
when(manager.getMantle()).thenReturn(mantle);
when(manager.managedTask(anyString(), any(Runnable.class), any(Runnable.class)))
.thenAnswer(invocation -> invocation.getArgument(1));
when(world.isChunkLoaded(2, -1)).thenReturn(true);
when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true);
chunks.when(() -> Chunks.isSafe(world, 2, -1)).thenReturn(true);
scheduler.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> {
async.add(invocation.getArgument(0));
return null;
});
scheduler.when(() -> J.runRegion(eq(world), eq(2), eq(-1), any(Runnable.class)))
.thenAnswer(invocation -> {
if (acceptRegions) {
regions.add(invocation.getArgument(3));
}
return acceptRegions;
});
}
private void start(Runnable work) {
maintenance.runInitialSpawn(world, 2, -1, work);
}
@Override
public void close() {
while (!async.isEmpty()) {
async.remove().run();
}
chunks.close();
scheduler.close();
}
}
}
@@ -4,20 +4,15 @@ import art.arcane.iris.testsupport.BukkitTestServer;
import io.papermc.paper.InternalAPIBridge;
import io.papermc.paper.registry.RegistryAccess;
import org.bukkit.Chunk;
import org.bukkit.GameRule;
import org.bukkit.GameRules;
import org.bukkit.Registry;
import org.bukkit.World;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Proxy;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
@@ -26,19 +21,7 @@ public class WorldEntitySpawnerAdmissionTest {
@BeforeClass
public static void initializeGameRules() throws Exception {
BukkitTestServer.install();
AtomicReference<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);
});
RegistryAccess access = BukkitTestServer.registryAccess();
InternalAPIBridge bridge = mock(InternalAPIBridge.class);
try (MockedStatic<RegistryAccess> registries = mockStatic(RegistryAccess.class);
MockedStatic<InternalAPIBridge> internals = mockStatic(InternalAPIBridge.class)) {
@@ -7,6 +7,7 @@ import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.world.entity.IrisSpawner;
@@ -54,9 +55,9 @@ public class WorldEntitySpawnerBiomeTest {
configured.when(IrisSettings::get).thenReturn(settings);
WorldEntitySpawner spawner = new WorldEntitySpawner(manager);
spawner.prepareInitialSpawn(chunk, List.of());
verify(maintenance, never()).raiseInitialSpawnMarkerFlag(any(), anyInt(), anyInt(), any());
verify(maintenance, never()).runInitialSpawn(any(), anyInt(), anyInt(), any());
spawner.prepareInitialSpawn(chunk, List.of());
verify(maintenance).raiseInitialSpawnMarkerFlag(eq(world), eq(2), eq(-1), any());
verify(maintenance).runInitialSpawn(eq(world), eq(2), eq(-1), any());
}
}
@@ -76,11 +77,10 @@ public class WorldEntitySpawnerBiomeTest {
when(definition.getInitialSpawns()).thenReturn(new KList<>(entity));
when(definition.canSpawn(engine, 0, 0)).thenReturn(true);
when(entity.getRarity()).thenReturn(1);
when(entity.getReferenceSpawner()).thenReturn(definition);
when(entity.spawn(eq(engine), eq(position), any(RNG.class))).thenReturn(1);
when(entity.spawn(eq(engine), eq(position), any(RNG.class), any(SpawnContext.class))).thenReturn(1);
KSet<IrisSpawner> definitions = new KSet<>();
definitions.add(definition);
PreparedMarkerSpawn marker = new PreparedMarkerSpawn(position, definitions, environment);
PreparedMarkerSpawn marker = new PreparedMarkerSpawn(position, definitions, environment, null);
IrisSettings settings = new IrisSettings();
settings.getWorld().setAmbientEntitySpawningSystem(false);
@@ -88,11 +88,11 @@ public class WorldEntitySpawnerBiomeTest {
configured.when(IrisSettings::get).thenReturn(settings);
new WorldEntitySpawner(manager).prepareInitialSpawn(chunk, List.of(marker));
ArgumentCaptor<Runnable> callback = ArgumentCaptor.forClass(Runnable.class);
verify(manager.chunkMaintenance).raiseInitialSpawnMarkerFlag(eq(world), eq(0), eq(0), callback.capture());
verify(manager.chunkMaintenance).runInitialSpawn(eq(world), eq(0), eq(0), callback.capture());
doThrow(new SavedBiomeUnavailableException("Evicted", true)).when(engine)
.getBiomeEnvironment(anyInt(), anyInt(), anyInt());
callback.getValue().run();
verify(entity).spawn(eq(engine), eq(position), any(RNG.class));
verify(entity).spawn(eq(engine), eq(position), any(RNG.class), any(SpawnContext.class));
verify(engine).openBiomeEnvironmentScope(environment);
verify(scope).close();
verify(engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt());
@@ -102,6 +102,7 @@ public class WorldEntitySpawnerBiomeTest {
private static IrisWorldManager manager(Engine engine) throws Exception {
IrisWorldManager manager = mock(IrisWorldManager.class);
when(manager.getEngine()).thenReturn(engine);
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
Field field = IrisWorldManager.class.getDeclaredField("chunkMaintenance");
field.setAccessible(true);
field.set(manager, mock(WorldChunkMaintenance.class));
@@ -0,0 +1,145 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.loading.ResourceLoader;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import org.bukkit.Chunk;
import org.bukkit.GameRules;
import org.bukkit.World;
import org.junit.Test;
import org.junit.BeforeClass;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class WorldEntitySpawnerInitialSeedTest {
@BeforeClass
public static void initializeGameRules() throws Exception {
WorldEntitySpawnerAdmissionTest.initializeGameRules();
}
@Test
public void initialPopulationUsesAuthoredCapacityWithoutLiveGatesOrPopulationReads() throws Exception {
Fixture fixture = new Fixture();
fixture.spawn(2, -3);
assertEquals(1, fixture.observed.size());
Observation observation = fixture.observed.get("2,-3");
assertEquals(4, observation.capacity());
assertTrue(observation.initial());
verify(fixture.definition, never()).canSpawn(any(Engine.class), anyInt(), anyInt());
verify(fixture.definition, never()).spawn(any(Engine.class), anyInt(), anyInt());
verify(fixture.lastChunk, never()).getEntities();
}
@Test
public void chunkBatchSeedsAreIndependentOfVisitOrderAndUnrelatedRandomActivity() throws Exception {
Fixture fixture = new Fixture();
for (int coordinate = -16; coordinate < 16; coordinate++) {
fixture.spawn(coordinate, -coordinate - 1);
}
Map<String, Observation> forward = new HashMap<>(fixture.observed);
fixture.observed.clear();
for (int coordinate = 15; coordinate >= -16; coordinate--) {
for (int draw = 0; draw < 19; draw++) {
RNG.r.nextLong();
}
fixture.spawn(coordinate, -coordinate - 1);
}
assertEquals(forward, fixture.observed);
assertEquals(32, forward.values().stream().map(Observation::seed).distinct().count());
}
@Test
public void initialSpawnerIgnoresCooldownButExcludedContentIsNeverAdmitted() {
Engine engine = mock(Engine.class);
IrisSpawner definition = mock(IrisSpawner.class);
when(definition.canSpawn(engine, 1, 2)).thenReturn(false);
assertTrue(WorldEntitySpawner.spawnerAllowed(definition, engine, 1, 2, true));
assertFalse(WorldEntitySpawner.spawnerAllowed(definition, engine, 1, 2, false));
when(definition.isCompatExcluded()).thenReturn(true);
assertFalse(WorldEntitySpawner.spawnerAllowed(definition, engine, 1, 2, true));
}
@SuppressWarnings("unchecked")
private static final class Fixture {
private final Engine engine = mock(Engine.class);
private final IrisWorldManager manager = mock(IrisWorldManager.class);
private final World world = mock(World.class);
private final IrisSpawner definition = mock(IrisSpawner.class);
private final IrisEntitySpawn entry = mock(IrisEntitySpawn.class);
private final Map<String, Observation> observed = new HashMap<>();
private final BiomeEnvironment environment;
private final Method spawn;
private final WorldEntitySpawner spawner;
private Chunk lastChunk;
private Fixture() throws Exception {
SeedManager seeds = mock(SeedManager.class);
when(seeds.getEntity()).thenReturn(918273645L);
when(engine.getSeedManager()).thenReturn(seeds);
when(manager.getEngine()).thenReturn(engine);
IrisData data = mock(IrisData.class);
ResourceLoader<IrisSpawner> loader = mock(ResourceLoader.class);
when(data.getSpawnerLoader()).thenReturn(loader);
KList<String> authored = new KList<>("qa/initial");
when(loader.loadAll(any(KList.class))).thenReturn(new KList<>());
when(loader.loadAll(authored)).thenReturn(new KList<>(definition));
IrisBiome biome = mock(IrisBiome.class);
IrisRegion region = mock(IrisRegion.class);
IrisDimension dimension = mock(IrisDimension.class);
when(biome.getEntitySpawners()).thenReturn(authored);
when(region.getEntitySpawners()).thenReturn(new KList<>());
when(dimension.getEntitySpawners()).thenReturn(new KList<>());
when(definition.getInitialSpawns()).thenReturn(new KList<>(entry));
when(definition.getMaxEntitiesPerChunk()).thenReturn(4);
when(entry.getRarity()).thenReturn(1);
when(entry.getRealEntity(engine)).thenReturn(mock(IrisEntity.class));
when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true);
when(entry.spawn(eq(engine), any(Chunk.class), any(RNG.class), anyInt(), any(SpawnContext.class)))
.thenAnswer(invocation -> {
Chunk chunk = invocation.getArgument(1);
RNG rng = invocation.getArgument(2);
int capacity = invocation.getArgument(3);
SpawnContext context = invocation.getArgument(4);
observed.put(chunk.getX() + "," + chunk.getZ(), new Observation(rng.getSeed(), capacity, context.initial()));
return 4;
});
environment = new BiomeEnvironment(1L, biome, region, dimension, data);
spawner = new WorldEntitySpawner(manager);
spawn = WorldEntitySpawner.class.getDeclaredMethod("spawnAmbient", Chunk.class, boolean.class, BiomeEnvironment.class);
spawn.setAccessible(true);
}
private void spawn(int x, int z) throws Exception {
lastChunk = mock(Chunk.class);
when(lastChunk.getWorld()).thenReturn(world);
when(lastChunk.getX()).thenReturn(x);
when(lastChunk.getZ()).thenReturn(z);
spawn.invoke(spawner, lastChunk, true, environment);
}
}
private record Observation(long seed, int capacity, boolean initial) {
}
}
@@ -0,0 +1,98 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.MarkerSpawnScanner.PreparedMarkerSpawn;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext;
import art.arcane.iris.world.entity.IrisMarker;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class WorldEntitySpawnerMarkerSeedTest {
@Test
public void initialMarkersIgnoreHashSetInsertionOrderAndOtherChunksRandomDraws() throws Exception {
Engine engine = mock(Engine.class);
SeedManager seeds = mock(SeedManager.class);
when(engine.getSeedManager()).thenReturn(seeds);
when(seeds.getEntity()).thenReturn(145398276L);
IrisWorldManager manager = mock(IrisWorldManager.class);
when(manager.getEngine()).thenReturn(engine);
BiomeEnvironment environment = new BiomeEnvironment(1L, mock(IrisBiome.class), mock(IrisRegion.class),
mock(IrisDimension.class), mock(IrisData.class));
when(engine.openBiomeEnvironmentScope(environment)).thenReturn(mock(BiomeEnvironment.Scope.class));
Map<String, Observation> observed = new HashMap<>();
IrisMarker marker = new IrisMarker();
IrisSpawner first = definition("qa/a", engine, marker, observed);
IrisSpawner second = definition("qa/b", engine, marker, observed);
KSet<IrisSpawner> forward = new KSet<>();
forward.add(first);
forward.add(second);
KSet<IrisSpawner> reversed = new KSet<>();
reversed.add(second);
reversed.add(first);
Method spawn = WorldEntitySpawner.class.getDeclaredMethod("spawnPreparedMarker", PreparedMarkerSpawn.class, boolean.class);
spawn.setAccessible(true);
WorldEntitySpawner spawner = new WorldEntitySpawner(manager);
for (int x = -32; x < 32; x++) {
IrisPosition position = new IrisPosition(x * 16 + 1, -49, -x * 16 + 3);
spawn.invoke(spawner, new PreparedMarkerSpawn(position, forward, environment, marker), true);
}
Map<String, Observation> expected = new HashMap<>(observed);
observed.clear();
for (int x = 31; x >= -32; x--) {
RNG.r.nextLong();
IrisPosition position = new IrisPosition(x * 16 + 1, -49, -x * 16 + 3);
spawn.invoke(spawner, new PreparedMarkerSpawn(position, reversed, environment, marker), true);
}
assertEquals(expected, observed);
assertEquals(64, observed.size());
assertTrue(observed.values().stream().allMatch(Observation::initial));
verify(first, never()).canSpawn(any(Engine.class), anyInt(), anyInt());
verify(second, never()).canSpawn(any(Engine.class), anyInt(), anyInt());
verify(first, never()).spawn(any(Engine.class), anyInt(), anyInt());
verify(second, never()).spawn(any(Engine.class), anyInt(), anyInt());
}
private static IrisSpawner definition(String key, Engine engine, IrisMarker marker, Map<String, Observation> observed) {
IrisSpawner spawner = mock(IrisSpawner.class);
IrisEntitySpawn entry = mock(IrisEntitySpawn.class);
when(spawner.getLoadKey()).thenReturn(key);
when(spawner.getInitialSpawns()).thenReturn(new KList<>(entry));
when(entry.getRarity()).thenReturn(1);
when(entry.spawn(eq(engine), any(IrisPosition.class), any(RNG.class), any(SpawnContext.class)))
.thenAnswer(invocation -> {
IrisPosition position = invocation.getArgument(1);
RNG rng = invocation.getArgument(2);
SpawnContext context = invocation.getArgument(3);
assertEquals(marker, context.marker());
observed.put(position.getX() + "," + position.getY() + "," + position.getZ(),
new Observation(context.spawner().getLoadKey(), rng.getSeed(), context.initial()));
return 1;
});
return spawner;
}
private record Observation(String definition, long seed, boolean initial) {
}
}
@@ -35,15 +35,15 @@ public class WorldEntitySpawnerPopulationTest {
IrisEntity definition = mock(IrisEntity.class);
when(definition.spawnCategory()).thenReturn("monster");
when(entry.getRealEntity(engine)).thenReturn(definition);
when(entry.getReferenceSpawner()).thenReturn(new IrisSpawner().setMaxEntitiesPerChunk(2));
IrisSpawner spawner = new IrisSpawner().setMaxEntitiesPerChunk(2);
try (MockedStatic<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));
assertEquals(1, counter.remainingCapacity(entry, spawner, engine));
when(definition.spawnCategory()).thenReturn("creature");
assertEquals(0, counter.remainingCapacity(entry, engine));
assertEquals(0, counter.remainingCapacity(entry, spawner, engine));
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.studio.generation;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.testsupport.BukkitTestServer;
import art.arcane.iris.pack.loading.ResourceLoader;
import art.arcane.iris.studio.object.ObjectStudioLayout;
import art.arcane.iris.studio.object.ObjectStudioSaveService;
@@ -38,7 +39,6 @@ import java.nio.file.Files;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@@ -59,19 +59,9 @@ public class ObjectStudioGeometryTest {
public final TemporaryFolder temporary = new TemporaryFolder();
@BeforeClass
@SuppressWarnings("unchecked")
public static void initializeBukkit() throws Exception {
PreObjectMatterTest.setUpBukkit();
AtomicReference<Registry<Biome>> registered = new AtomicReference<>();
RegistryAccess access = mock(RegistryAccess.class, invocation -> {
if (invocation.getMethod().getName().equals("getRegistry")) {
if (registered.get() == null) {
registered.set(biomeRegistry());
}
return registered.get();
}
return RETURNS_DEFAULTS.answer(invocation);
});
RegistryAccess access = BukkitTestServer.registryAccess();
InternalAPIBridge bridge = mock(InternalAPIBridge.class, invocation -> {
if (invocation.getMethod().getName().equals("constructLegacyCustomBiome")) {
return biome(Key.key("minecraft:custom"));
@@ -89,20 +79,6 @@ public class ObjectStudioGeometryTest {
}
}
@SuppressWarnings("unchecked")
private static Registry<Biome> biomeRegistry() {
return (Registry<Biome>) Proxy.newProxyInstance(Registry.class.getClassLoader(),
new Class<?>[]{Registry.class},
(proxy, method, arguments) -> switch (method.getName()) {
case "getOrThrow" -> biome((Key) arguments[0]);
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == arguments[0];
case "toString" -> "iris-object-studio-biome-registry";
case "size" -> 0;
case "hasTag" -> false;
default -> null;
});
}
@Test
public void jungleClutterSignedBoundsStayInsideTheEditableCellAboveTheFloor() throws Exception {
@@ -0,0 +1,63 @@
package art.arcane.iris.world.entity;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
public class EntitySpawnSeedTest {
@Test
public void coordinateAndAttemptSeedsRemainPinned() {
assertEquals(-4130335506775599505L, EntitySpawnSeed.chunk(0L, 0, 0).getSeed());
assertEquals(8337887711347547470L, EntitySpawnSeed.chunk(1337L, -2, 3).getSeed());
assertEquals(7508281853961706446L,
EntitySpawnSeed.chunk(-1L, Integer.MAX_VALUE, Integer.MIN_VALUE).getSeed());
assertEquals(-2305592493329120404L, EntitySpawnSeed.marker(0L, 0, 0, 0).getSeed());
assertEquals(8373759002518926811L, EntitySpawnSeed.marker(1337L, -32, -64, 48).getSeed());
assertEquals(-8713573891296014035L,
EntitySpawnSeed.marker(-1L, Integer.MAX_VALUE, Integer.MIN_VALUE, 1).getSeed());
assertEquals(-6302458730787398690L, EntitySpawnSeed.entity(1337L, -1).getSeed());
assertEquals(858402071020777840L, EntitySpawnSeed.entity(1337L, 0).getSeed());
assertEquals(-632476273033191086L, EntitySpawnSeed.entity(1337L, 1).getSeed());
assertEquals(4034378215462583149L, EntitySpawnSeed.entity(1337L, 17).getSeed());
}
@Test
public void consumptionAndReverseOrderDoNotChangeSiblingAttempts() {
long batchSeed = EntitySpawnSeed.chunk(1337L, -2, 3).getSeed();
List<Long> forward = new ArrayList<>();
for (int ordinal = 0; ordinal < 16; ordinal++) {
forward.add(EntitySpawnSeed.entity(batchSeed, ordinal).nextLong());
}
for (int ordinal = 15; ordinal >= 0; ordinal--) {
RNG sibling = EntitySpawnSeed.entity(batchSeed, ordinal - 1);
for (int draw = 0; draw < 100; draw++) {
sibling.nextLong();
}
assertEquals(forward.get(ordinal).longValue(), EntitySpawnSeed.entity(batchSeed, ordinal).nextLong());
}
assertNotEquals(EntitySpawnSeed.marker(1337L, -32, -64, 48).getSeed(),
EntitySpawnSeed.marker(1337L, -32, -63, 48).getSeed());
assertNotEquals(EntitySpawnSeed.chunk(1337L, -2, 3).getSeed(),
EntitySpawnSeed.chunk(1338L, -2, 3).getSeed());
}
@Test
public void concurrentCoordinateEvaluationMatchesSerialEvaluation() {
List<CompletableFuture<Long>> results = new ArrayList<>();
for (int ordinal = 0; ordinal < 64; ordinal++) {
int index = ordinal;
results.add(CompletableFuture.supplyAsync(() -> EntitySpawnSeed.marker(
42L, index - 32, -64 + index, -index).nextLong()));
}
for (int ordinal = 63; ordinal >= 0; ordinal--) {
assertEquals(EntitySpawnSeed.marker(42L, ordinal - 32, -64 + ordinal, -ordinal).nextLong(),
results.get(ordinal).join().longValue());
}
}
}
@@ -0,0 +1,47 @@
package art.arcane.iris.world.entity;
import art.arcane.volmlib.util.math.RNG;
import org.bukkit.attribute.AttributeModifier;
import org.bukkit.inventory.meta.ItemMeta;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
public class IrisAttributeModifierSeedTest {
@Test
public void repeatedSeedProducesIdenticalModifierKeyAmountAndOperation() {
IrisAttributeModifier definition = new IrisAttributeModifier().setName("Custom Bonus")
.setMinAmount(-3).setMaxAmount(7).setOperation("ADD_SCALAR");
AttributeModifier first = definition.createModifier(new RNG(1337L));
AttributeModifier repeated = definition.createModifier(new RNG(1337L));
assertEquals(first.getKey(), repeated.getKey());
assertEquals("minecraft:custom_bonus_a8f1288a2c8a93b8_b072351edfd5484c", first.getKey().toString());
assertEquals(first.getAmount(), repeated.getAmount(), 0D);
assertEquals(5.832726771624211D, first.getAmount(), 0D);
assertEquals(AttributeModifier.Operation.ADD_SCALAR, first.getOperation());
assertTrue(first.getKey().getKey().startsWith("custom_bonus_"));
assertTrue(first.getAmount() >= -3D && first.getAmount() <= 7D);
}
@Test
public void sameNamedModifiersRemainDistinctWithinOneSeededSequence() {
IrisAttributeModifier definition = new IrisAttributeModifier().setName("Bonus");
RNG rng = new RNG(1337L);
AttributeModifier first = definition.createModifier(rng);
AttributeModifier second = definition.createModifier(rng);
assertNotEquals(first.getKey(), second.getKey());
assertNotEquals(first.getKey(), definition.createModifier(new RNG(1338L)).getKey());
}
@Test
public void zeroChanceNeverAppliesAModifier() {
ItemMeta metadata = mock(ItemMeta.class);
IrisAttributeModifier definition = new IrisAttributeModifier().setChance(0D);
definition.apply(new RNG(1337L), metadata);
verifyNoInteractions(metadata);
}
}
@@ -0,0 +1,49 @@
package art.arcane.iris.world.entity;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.util.math.RNG;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.loot.LootTable;
import org.bukkit.loot.Lootable;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
public class IrisEntityLootSeedTest {
@Test
public void lootKeyDependsOnSpawnSeedRatherThanEntityHashOrWorldIdentity() {
IrisEntity first = new IrisEntity().setCustomName("First");
IrisEntity second = new IrisEntity().setCustomName("Second");
assertNotEquals(first.hashCode(), second.hashCode());
LootTable firstTable = bind(first, new RNG(1337L), mock(World.class));
LootTable secondTable = bind(second, new RNG(1337L), mock(World.class));
assertEquals(firstTable.getKey(), secondTable.getKey());
assertEquals("iris:loot-22371b531cd5acd3", firstTable.getKey().toString());
assertNotEquals(firstTable.getKey(), bind(first, new RNG(1338L), mock(World.class)).getKey());
}
@Test
public void priorCustomizationConsumptionDoesNotChangeTheLootIdentity() {
IrisEntity definition = new IrisEntity();
RNG consumed = new RNG(1337L);
for (int draw = 0; draw < 100; draw++) {
consumed.nextLong();
}
assertEquals(bind(definition, new RNG(1337L), mock(World.class)).getKey(),
bind(definition, consumed, mock(World.class)).getKey());
}
private static LootTable bind(IrisEntity definition, RNG rng, World world) {
Engine engine = mock(Engine.class);
Lootable lootable = mock(Lootable.class);
IrisEntity.BukkitOps.bindLoot(definition, engine, lootable, new Location(world, -31.5, -63.5, 48.5), rng);
ArgumentCaptor<LootTable> table = ArgumentCaptor.forClass(LootTable.class);
verify(lootable).setLootTable(table.capture());
return table.getValue();
}
}
@@ -2,6 +2,7 @@ package art.arcane.iris.world.entity;
import art.arcane.iris.generation.decoration.IrisSurface;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.pack.value.IrisRange;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.SeedManager;
@@ -29,12 +30,14 @@ import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy;
@@ -381,9 +384,8 @@ public class IrisEntitySpawnTest {
IrisPosition position = invocation.getArgument(0);
return new Location(fixture.world, position.getX(), position.getY(), position.getZ());
});
fixture.entry.setReferenceSpawner(new IrisSpawner());
assertEquals(0, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 78, 0), new RNG(1L)));
assertEquals(3, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 77, 0), new RNG(1L)));
assertEquals(0, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 78, 0), new RNG(1L), new IrisEntitySpawn.SpawnContext(new IrisSpawner(), null, false)));
assertEquals(3, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 77, 0), new RNG(1L), new IrisEntitySpawn.SpawnContext(new IrisSpawner(), null, false)));
}
}
@@ -399,6 +401,96 @@ public class IrisEntitySpawnTest {
}
}
@Test
public void failedFirstSiblingDoesNotChangeLaterPositionsOrCustomizationSeeds() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
assertEquals(3, fixture.spawn(IrisSpawnGroup.NORMAL));
List<Location> expectedPositions = new ArrayList<>(fixture.positions.subList(1, 3));
List<Long> expectedSeeds = new ArrayList<>(fixture.customizationSeeds.subList(1, 3));
Location rejected = fixture.positions.getFirst();
Block obstruction = mock(Block.class);
when(obstruction.getType()).thenReturn(Material.STONE);
when(obstruction.isSolid()).thenReturn(true);
when(fixture.world.getBlockAt(rejected.getBlockX(), rejected.getBlockY(), rejected.getBlockZ()))
.thenReturn(obstruction);
fixture.positions.clear();
fixture.customizationSeeds.clear();
assertEquals(2, fixture.spawn(IrisSpawnGroup.NORMAL));
assertEquals(expectedPositions, fixture.positions);
assertEquals(expectedSeeds, fixture.customizationSeeds);
}
}
@Test
public void initialCavePopulationDoesNotDependOnMarkerStorageOrder() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78));
MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(J::isFolia).thenReturn(false);
EngineMantle mantle = mock(EngineMantle.class);
when(fixture.engine.getMantle()).thenReturn(mantle);
IrisPosition first = new IrisPosition(7, 79, 9);
IrisPosition second = new IrisPosition(3, 79, 5);
when(mantle.findMarkers(0, 0, MarkerMatter.CAVE_FLOOR))
.thenReturn(new KList<>(first, second), new KList<>(second, first));
fixture.platform.when(() -> BukkitPlatform.toLocation(any(IrisPosition.class), eq(fixture.world)))
.thenAnswer(invocation -> {
IrisPosition position = invocation.getArgument(0);
return new Location(fixture.world, position.getX(), position.getY(), position.getZ());
});
IrisEntitySpawn.SpawnContext context = new IrisEntitySpawn.SpawnContext(
new IrisSpawner().setGroup(IrisSpawnGroup.CAVE), null, true);
assertEquals(3, fixture.entry.spawn(fixture.engine, fixture.chunk, new RNG(1337L), 3, context));
List<Location> expected = new ArrayList<>(fixture.positions);
List<Long> expectedSeeds = new ArrayList<>(fixture.customizationSeeds);
fixture.positions.clear();
fixture.customizationSeeds.clear();
assertEquals(3, fixture.entry.spawn(fixture.engine, fixture.chunk, new RNG(1337L), 3, context));
assertEquals(expected, fixture.positions);
assertEquals(expectedSeeds, fixture.customizationSeeds);
verify(mantle, times(2)).findMarkers(0, 0, MarkerMatter.CAVE_FLOOR);
}
}
@Test
public void initialPopulationIgnoresLiveLightButRecurringPopulationHonorsIt() {
try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) {
fixture.lightLevel = 15;
IrisSpawner spawner = new IrisSpawner().setAllowedLightLevels(new IrisRange(0, 0));
assertEquals(0, fixture.entry.spawn(fixture.engine, fixture.chunk, new RNG(1337L), 3,
new IrisEntitySpawn.SpawnContext(spawner, null, false)));
assertEquals(3, fixture.entry.spawn(fixture.engine, fixture.chunk, new RNG(1337L), 3,
new IrisEntitySpawn.SpawnContext(spawner, null, true)));
}
}
@Test
public void markerExhaustionUsesOnlyTheProvidedSeed() {
IrisMarker marker = new IrisMarker().setExhaustionChance(0.5D);
for (long seed = 0; seed < 32; seed++) {
assertEquals(marker.shouldExhaust(new RNG(seed)), marker.shouldExhaust(new RNG(seed)));
}
assertTrue(marker.setExhaustionChance(1D).shouldExhaust(new RNG(1337L)));
assertFalse(marker.setExhaustionChance(-1D).shouldExhaust(new RNG(1337L)));
}
@Test
public void directEntitySeedDoesNotDependOnWorldObjectIdentity() {
Engine engine = mock(Engine.class);
SeedManager seeds = mock(SeedManager.class);
when(engine.getSeedManager()).thenReturn(seeds);
when(seeds.getEntity()).thenReturn(1337L);
IrisEntity entity = spy(new IrisEntity());
List<Long> captured = new ArrayList<>();
doAnswer(invocation -> {
RNG rng = invocation.getArgument(2);
captured.add(rng.getSeed());
return null;
}).when(entity).spawn(eq(engine), any(Location.class), any(RNG.class));
entity.spawn(engine, new Location(mock(World.class), -31.5, -63.5, 48.5));
entity.spawn(engine, new Location(mock(World.class), -31.5, -63.5, 48.5));
assertEquals(List.of(8373759002518926811L, 8373759002518926811L), captured);
}
private record SpawnOptions(IrisSurface surface, Material fluid, int floor, int top) {
}
@@ -408,6 +500,8 @@ public class IrisEntitySpawnTest {
private final Chunk chunk = mock(Chunk.class);
private final IrisEntitySpawn entry = spy(new IrisEntitySpawn());
private final List<Location> positions = new ArrayList<>();
private final List<Long> customizationSeeds = new ArrayList<>();
private int lightLevel;
private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
private Material airMaterial = Material.AIR;
private int remainingCapacity = Integer.MAX_VALUE;
@@ -430,6 +524,7 @@ public class IrisEntitySpawnTest {
Block block = mock(Block.class);
when(block.getType()).thenReturn(y <= floor ? Material.STONE : y <= top ? fluid : airMaterial);
when(block.isSolid()).thenReturn(y <= floor);
when(block.getLightLevel()).thenReturn((byte) lightLevel);
return block;
});
when(world.getBlockAt(any(Location.class))).thenAnswer(invocation -> {
@@ -439,6 +534,8 @@ public class IrisEntitySpawnTest {
when(definition.spawn(eq(engine), any(Location.class), any(RNG.class))).thenAnswer(invocation -> {
Location location = invocation.getArgument(1);
positions.add(location.clone());
RNG customization = invocation.getArgument(2);
customizationSeeds.add(customization.getSeed());
Entity spawned = mock(Entity.class);
when(spawned.getLocation()).thenReturn(location.clone());
when(spawned.getType()).thenReturn(EntityType.TADPOLE);
@@ -451,8 +548,8 @@ public class IrisEntitySpawnTest {
}
private int spawn(IrisSpawnGroup group) {
entry.setReferenceSpawner(new IrisSpawner().setGroup(group));
return entry.spawn(engine, chunk, new RNG(1337L), remainingCapacity);
return entry.spawn(engine, chunk, new RNG(1337L), remainingCapacity,
new IrisEntitySpawn.SpawnContext(new IrisSpawner().setGroup(group), null, false));
}
@Override