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

@@ -67,6 +67,7 @@ final class MarkerSpawnScanner {
J.a(manager.managedTask("bukkit_world_manager_marker_scan", () -> {
try {
if (initialOnly && manager.getMantle().hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER)) {
manager.chunkMaintenance.recordInitialSpawnCompletion(chunkX, chunkZ);
markerScanQueue.remove(key);
return;
}
@@ -91,7 +92,7 @@ final class MarkerSpawnScanner {
continue;
}
prepared.add(new PreparedMarkerSpawn(new IrisPosition(position.getX(),
position.getY() + minimumY, position.getZ()), data.spawners, data.environment));
position.getY() + minimumY, position.getZ()), data.spawners, data.environment, data.marker));
}
consumer.accept(List.copyOf(prepared));
} finally {
@@ -137,7 +138,7 @@ final class MarkerSpawnScanner {
}
IrisPosition position = new IrisPosition((chunkX << 4) + x, y, (chunkZ << 4) + z);
MarkerSpawnData data = markerData.computeIfAbsent(position, k -> new MarkerSpawnData(environment));
MarkerSpawnData data = markerData.computeIfAbsent(position, k -> new MarkerSpawnData(environment, mark));
data.requiresEmptyAbove = data.requiresEmptyAbove || mark.isEmptyAbove();
for (String i : mark.getSpawners()) {
@@ -149,7 +150,6 @@ final class MarkerSpawnScanner {
if (spawner.isCompatExcluded()) {
continue;
}
spawner.setReferenceMarker(mark);
data.spawners.add(spawner);
}
});
@@ -184,16 +184,18 @@ final class MarkerSpawnScanner {
}));
}
record PreparedMarkerSpawn(IrisPosition position, KSet<IrisSpawner> spawners, BiomeEnvironment environment) {
record PreparedMarkerSpawn(IrisPosition position, KSet<IrisSpawner> spawners, BiomeEnvironment environment, IrisMarker marker) {
}
private static final class MarkerSpawnData {
private final KSet<IrisSpawner> spawners = new KSet<>();
private final BiomeEnvironment environment;
private final IrisMarker marker;
private boolean requiresEmptyAbove;
private MarkerSpawnData(BiomeEnvironment environment) {
private MarkerSpawnData(BiomeEnvironment environment, IrisMarker marker) {
this.environment = environment;
this.marker = marker;
}
}
}
@@ -20,6 +20,7 @@ package art.arcane.iris.generation.runtime;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.cache.Cache;
import art.arcane.iris.generation.context.IrisContext;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter.SavedChunkMantle;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
@@ -35,6 +36,7 @@ import art.arcane.volmlib.util.math.PowerOfTwoCoordinates;
import art.arcane.volmlib.util.math.Position2;
import art.arcane.volmlib.util.matter.Matter;
import org.bukkit.Chunk;
import org.bukkit.GameRules;
import org.bukkit.World;
import org.bukkit.entity.Player;
@@ -44,6 +46,7 @@ import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
@@ -59,10 +62,12 @@ final class WorldChunkMaintenance {
private static final int MAX_FORCED_CHUNK_UPDATES = 128;
private static final int MAX_PENDING_MATERIALIZATIONS = 128;
private static final int MAX_COMPLETED_MATERIALIZATIONS = 8192;
private static final int MAX_COMPLETED_INITIAL_SPAWNS = 8192;
private final IrisWorldManager manager;
private final Set<Long> mantleWarmupQueue = ConcurrentHashMap.newKeySet();
private final Set<Long> markerFlagQueue = ConcurrentHashMap.newKeySet();
private final Set<Long> completedInitialSpawns = Collections.synchronizedSet(new LinkedHashSet<>());
private final Set<Long> discoveredFlagQueue = ConcurrentHashMap.newKeySet();
private final Set<Long> chunkUpdateQueue = ConcurrentHashMap.newKeySet();
private final Map<Long, RoutedMaterialization> materializations = new ConcurrentHashMap<>();
@@ -85,6 +90,7 @@ final class WorldChunkMaintenance {
}
}
completedMaterializations.clear();
completedInitialSpawns.clear();
}
void invalidateMaterialization(int chunkX, int chunkZ) {
@@ -375,46 +381,107 @@ final class WorldChunkMaintenance {
}
}
void raiseInitialSpawnMarkerFlag(World world, int chunkX, int chunkZ, Runnable onFirstRaise) {
if (world == null || onFirstRaise == null) {
boolean isInitialSpawnComplete(int chunkX, int chunkZ) {
return completedInitialSpawns.contains(Cache.key(chunkX, chunkZ));
}
void recordInitialSpawnCompletion(int chunkX, int chunkZ) {
rememberInitialSpawn(Cache.key(chunkX, chunkZ));
}
void runInitialSpawn(World world, int chunkX, int chunkZ, Runnable initialSpawn) {
if (world == null || initialSpawn == null || closed) {
return;
}
if (!J.isFolia()) {
manager.getMantle().raiseFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER, onFirstRaise);
return;
}
long key = Cache.key(chunkX, chunkZ);
if (!markerFlagQueue.add(key)) {
if (completedInitialSpawns.contains(key) || !markerFlagQueue.add(key)) {
return;
}
J.a(manager.managedTask("bukkit_world_manager_initial_spawn_prepare",
() -> prepareInitialSpawnFlag(world, chunkX, chunkZ, key, initialSpawn),
() -> markerFlagQueue.remove(key)));
}
J.a(manager.managedTask("bukkit_world_manager_spawn_marker_flag", () -> {
boolean raised = false;
try {
Mantle<Matter> mantle = manager.getMantle();
if (!mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER)) {
mantle.flag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER, true);
raised = true;
}
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
private void prepareInitialSpawnFlag(World world, int chunkX, int chunkZ, long key, Runnable initialSpawn) {
try {
if (manager.getMantle().hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER)) {
rememberInitialSpawn(key);
markerFlagQueue.remove(key);
}
if (!raised) {
return;
}
boolean accepted = J.runRegion(world, chunkX, chunkZ,
manager.managedTask("bukkit_world_manager_initial_spawn_apply",
() -> applyInitialSpawn(world, chunkX, chunkZ, key, initialSpawn),
() -> markerFlagQueue.remove(key)));
if (!accepted) {
markerFlagQueue.remove(key);
}
} catch (Throwable failure) {
markerFlagQueue.remove(key);
IrisLogging.reportError("Failed to prepare initial Iris entities in chunk " + chunkX + "," + chunkZ + ".", failure);
}
}
J.runRegion(world, chunkX, chunkZ, manager.managedTask("bukkit_world_manager_spawn_marker_callback", () -> {
if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) {
return;
}
onFirstRaise.run();
}));
}, () -> markerFlagQueue.remove(key)));
private void applyInitialSpawn(World world, int chunkX, int chunkZ, long key, Runnable initialSpawn) {
GenerationSessionLease completionLease = null;
try {
if (closed || !world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)
|| !Boolean.TRUE.equals(world.getGameRuleValue(GameRules.SPAWN_MOBS))) {
markerFlagQueue.remove(key);
return;
}
Optional<GenerationSessionLease> admitted = initialCompletionLease();
if (admitted.isEmpty()) {
markerFlagQueue.remove(key);
return;
}
completionLease = admitted.get();
initialSpawn.run();
rememberInitialSpawn(key);
completionLease.detachThread();
GenerationSessionLease transferred = completionLease;
J.a(() -> persistInitialSpawnFlag(chunkX, chunkZ, key, transferred));
completionLease = null;
} catch (Throwable failure) {
markerFlagQueue.remove(key);
IrisLogging.reportError("Failed to apply initial Iris entities in chunk " + chunkX + "," + chunkZ + ".", failure);
} finally {
if (completionLease != null) {
completionLease.close();
}
}
}
private Optional<GenerationSessionLease> initialCompletionLease() throws GenerationSessionException {
Engine engine = manager.getEngine();
GenerationSessionManager sessions = engine.getGenerationSessions();
if (sessions == null) {
return Optional.of(GenerationSessionLease.noop());
}
IrisContext context = IrisContext.get();
if (context != null && context.getEngine() == engine && context.getGenerationSessionId() != 0L) {
return Optional.of(sessions.continueSession("bukkit_world_manager_initial_spawn_complete", context.getGenerationSessionId()));
}
return sessions.tryAcquireForEngine(engine, "bukkit_world_manager_initial_spawn_complete");
}
private void persistInitialSpawnFlag(int chunkX, int chunkZ, long key, GenerationSessionLease lease) {
try (lease; IrisContext.Scope ignored = IrisContext.open(manager.getEngine(), lease.sessionId(), null)) {
manager.getMantle().flag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER, true);
} catch (Throwable failure) {
IrisLogging.reportError("Failed to save initial Iris entity completion in chunk " + chunkX + "," + chunkZ + ".", failure);
} finally {
markerFlagQueue.remove(key);
}
}
private void rememberInitialSpawn(long key) {
synchronized (completedInitialSpawns) {
completedInitialSpawns.add(key);
if (completedInitialSpawns.size() > MAX_COMPLETED_INITIAL_SPAWNS) {
completedInitialSpawns.remove(completedInitialSpawns.iterator().next());
}
}
}
void warmupMantleChunkAsync(int chunkX, int chunkZ) {
@@ -25,6 +25,9 @@ import art.arcane.volmlib.util.math.Rarity;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.EntitySpawnSeed;
import art.arcane.iris.world.entity.IrisMarker;
import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
@@ -47,6 +50,9 @@ import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import java.util.List;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Objects;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@@ -57,10 +63,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Ambient and marker entity spawning for a Bukkit Iris world. Every count and spawn hops onto the
@@ -308,12 +311,15 @@ final class WorldEntitySpawner {
return;
}
if (!manager.chunkMaintenance.isInitialSpawnComplete(c.getX(), c.getZ())) {
spawnInitially(c);
return;
}
if (IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) {
manager.markerScanner.scanMarkerSpawners(c, false, markers -> {
for (PreparedMarkerSpawn marker : markers) {
spawnPreparedMarker(marker, false);
}
prepareInitialSpawn(c, markers);
});
}
@@ -333,7 +339,9 @@ final class WorldEntitySpawner {
}
void spawnInitially(Chunk chunk) {
if (manager.getEngine().isClosed() || !isEntitySpawningEnabledForCurrentWorld()) {
if (manager.getEngine().isClosed() || !isEntitySpawningEnabledForCurrentWorld()
|| manager.chunkMaintenance.isInitialSpawnComplete(chunk.getX(), chunk.getZ())
|| !Boolean.TRUE.equals(chunk.getWorld().getGameRuleValue(GameRules.SPAWN_MOBS))) {
return;
}
if (IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) {
@@ -356,71 +364,94 @@ final class WorldEntitySpawner {
}
}
Optional<BiomeEnvironment> preparedEnvironment = environment;
manager.chunkMaintenance.raiseInitialSpawnMarkerFlag(chunk.getWorld(), chunk.getX(), chunk.getZ(), () -> {
for (PreparedMarkerSpawn marker : markers) {
List<PreparedMarkerSpawn> ordered = new ArrayList<>(markers);
ordered.sort(Comparator.comparingInt((PreparedMarkerSpawn marker) -> marker.position().getX())
.thenComparingInt(marker -> marker.position().getY())
.thenComparingInt(marker -> marker.position().getZ()));
manager.chunkMaintenance.runInitialSpawn(chunk.getWorld(), chunk.getX(), chunk.getZ(), () -> {
for (PreparedMarkerSpawn marker : ordered) {
spawnPreparedMarker(marker, true);
}
if (preparedEnvironment.isPresent()) {
J.runRegion(chunk.getWorld(), chunk.getX(), chunk.getZ(),
manager.managedTask("bukkit_world_manager_initial_spawn_followup", () -> {
if (!chunk.getWorld().isChunkLoaded(chunk.getX(), chunk.getZ())) {
return;
}
BiomeEnvironment selected = preparedEnvironment.get();
try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(selected)) {
spawnAmbient(chunk, true, selected);
}
}), RNG.r.i(5, 200));
BiomeEnvironment selected = preparedEnvironment.get();
try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(selected)) {
spawnAmbient(chunk, true, selected);
}
}
});
}
private void spawnPreparedMarker(PreparedMarkerSpawn marker, boolean initial) {
IrisSpawner spawner = new KList<>(marker.spawners()).getRandom();
if (spawner == null || manager.getEngine().isClosed()) {
if (manager.getEngine().isClosed()) {
return;
}
RNG random = initial ? EntitySpawnSeed.marker(manager.getEngine().getSeedManager().getEntity(),
marker.position().getX(), marker.position().getY(), marker.position().getZ()) : new RNG(RNG.r.nextLong());
KList<IrisSpawner> spawners = new KList<>(marker.spawners());
if (initial) {
spawners.sort(Comparator.comparing(spawner -> Objects.toString(spawner.getLoadKey(), "")));
}
IrisSpawner spawner = spawners.getRandom(random);
if (spawner == null) {
return;
}
try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(marker.environment())) {
spawnMarker(marker.position(), spawner, initial);
spawnMarker(marker.position(), spawner, marker.marker(), random, initial);
}
}
private void spawnAmbient(Chunk c, boolean initial, BiomeEnvironment environment) {
if (!ambientAllowed(c, initial)) {
private void spawnAmbient(Chunk chunk, boolean initial, BiomeEnvironment environment) {
if (!ambientAllowed(chunk, initial)) {
return;
}
//@builder
// Excluded spawners cannot spawn anything on this Minecraft version, so they never enter the selection pool.
Predicate<IrisSpawner> filter = i -> !i.isCompatExcluded() && i.canSpawn(manager.getEngine(), c.getX(), c.getZ());
ChunkCounter counter = new ChunkCounter(c.getEntities());
IrisBiome biome = environment.biome();
IrisEntitySpawn v = spawnRandomly(Stream.concat(environment.data().getSpawnerLoader()
.loadAll(environment.dimension().getEntitySpawners())
.shuffleCopy(RNG.r)
.stream()
.filter(filter)
.filter((i) -> i.isValid(biome)),
Stream.concat(environment.data()
.getSpawnerLoader()
.loadAll(environment.region().getEntitySpawners())
.shuffleCopy(RNG.r)
.stream()
.filter(filter),
environment.data().getSpawnerLoader()
.loadAll(environment.biome().getEntitySpawners())
.shuffleCopy(RNG.r)
.stream()
.filter(filter)))
.flatMap((i) -> stream(i, initial))
.filter(entry -> counter.remainingCapacity(entry, manager.getEngine()) > 0)
.collect(Collectors.toList()))
.getRandom();
//@done
if (v == null || v.getReferenceSpawner() == null)
ChunkCounter counter = initial ? null : new ChunkCounter(chunk.getEntities());
List<SpawnSelection> pool = new ArrayList<>();
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()),
environment.biome(), chunk, initial, counter);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()),
null, chunk, initial, counter);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.biome().getEntitySpawners()),
null, chunk, initial, counter);
RNG random = initial ? EntitySpawnSeed.chunk(manager.getEngine().getSeedManager().getEntity(),
chunk.getX(), chunk.getZ()) : new RNG(RNG.r.nextLong());
SpawnSelection selected = selectSpawn(pool, random);
if (selected == null) {
return;
}
int capacity = initial ? Math.max(0, selected.spawner().getMaxEntitiesPerChunk())
: counter.remainingCapacity(selected.entry(), selected.spawner(), manager.getEngine());
int spawned = selected.entry().spawn(manager.getEngine(), chunk, random, capacity,
new SpawnContext(selected.spawner(), null, initial));
actuallySpawned.addAndGet(spawned);
if (spawned > 0 && !initial) {
selected.spawner().spawn(manager.getEngine(), chunk.getX(), chunk.getZ());
}
}
spawn(c, v, counter.remainingCapacity(v, manager.getEngine()));
private void collectSpawns(List<SpawnSelection> pool, KList<IrisSpawner> spawners, IrisBiome biome,
Chunk chunk, boolean initial, ChunkCounter counter) {
for (IrisSpawner spawner : spawners) {
if (!spawnerAllowed(spawner, manager.getEngine(), chunk.getX(), chunk.getZ(), initial)
|| biome != null && !spawner.isValid(biome)) {
continue;
}
for (IrisEntitySpawn entry : initial ? spawner.getInitialSpawns() : spawner.getSpawns()) {
IrisEntity entity = entry.getRealEntity(manager.getEngine());
int capacity = initial ? Math.max(0, spawner.getMaxEntitiesPerChunk())
: counter.remainingCapacity(entry, spawner, manager.getEngine());
if (entity != null && capacity > 0) {
pool.add(new SpawnSelection(entry, spawner));
}
}
}
}
static boolean spawnerAllowed(IrisSpawner spawner, Engine engine, int chunkX, int chunkZ, boolean initial) {
return !spawner.isCompatExcluded() && (initial || spawner.canSpawn(engine, chunkX, chunkZ));
}
static SpawnSelection selectSpawn(List<SpawnSelection> pool, RNG random) {
return Rarity.expandWeighted(pool).getRandom(random);
}
static boolean ambientAllowed(Chunk chunk, boolean initial) {
@@ -428,46 +459,22 @@ final class WorldEntitySpawner {
&& (initial || chunk.getLoadLevel() == Chunk.LoadLevel.ENTITY_TICKING);
}
private void spawn(Chunk c, IrisEntitySpawn i, int remainingCapacity) {
IrisSpawner ref = i.getReferenceSpawner();
int s = i.spawn(manager.getEngine(), c, RNG.r, remainingCapacity);
actuallySpawned.addAndGet(s);
if (s > 0) {
ref.spawn(manager.getEngine(), c.getX(), c.getZ());
}
}
private void spawn(IrisPosition pos, IrisEntitySpawn i) {
IrisSpawner ref = i.getReferenceSpawner();
if (!ref.canSpawn(manager.getEngine(), PowerOfTwoCoordinates.blockToChunkFloor(pos.getX()), PowerOfTwoCoordinates.blockToChunkFloor(pos.getZ())))
return;
int s = i.spawn(manager.getEngine(), pos, RNG.r);
actuallySpawned.addAndGet(s);
if (s > 0) {
ref.spawn(manager.getEngine(), PowerOfTwoCoordinates.blockToChunkFloor(pos.getX()), PowerOfTwoCoordinates.blockToChunkFloor(pos.getZ()));
}
}
private void spawnMarker(IrisPosition block, IrisSpawner spawner, boolean initial) {
KList<IrisEntitySpawn> s = initial ? spawner.getInitialSpawns() : spawner.getSpawns();
if (s.isEmpty()) {
private void spawnMarker(IrisPosition position, IrisSpawner spawner, IrisMarker marker, RNG random, boolean initial) {
int chunkX = PowerOfTwoCoordinates.blockToChunkFloor(position.getX());
int chunkZ = PowerOfTwoCoordinates.blockToChunkFloor(position.getZ());
if (!spawnerAllowed(spawner, manager.getEngine(), chunkX, chunkZ, initial)) {
return;
}
IrisEntitySpawn ss = spawnRandomly(s).getRandom();
ss.setReferenceSpawner(spawner);
ss.setReferenceMarker(spawner.getReferenceMarker());
spawn(block, ss);
}
private Stream<IrisEntitySpawn> stream(IrisSpawner s, boolean initial) {
for (IrisEntitySpawn i : initial ? s.getInitialSpawns() : s.getSpawns()) {
i.setReferenceSpawner(s);
i.setReferenceMarker(s.getReferenceMarker());
KList<IrisEntitySpawn> entries = initial ? spawner.getInitialSpawns() : spawner.getSpawns();
IrisEntitySpawn selected = Rarity.expandWeighted(entries).getRandom(random);
if (selected == null) {
return;
}
int spawned = selected.spawn(manager.getEngine(), position, random, new SpawnContext(spawner, marker, initial));
actuallySpawned.addAndGet(spawned);
if (spawned > 0 && !initial) {
spawner.spawn(manager.getEngine(), chunkX, chunkZ);
}
return (initial ? s.getInitialSpawns() : s.getSpawns()).stream();
}
boolean isEntitySpawningEnabledForCurrentWorld() {
@@ -478,8 +485,11 @@ final class WorldEntitySpawner {
return IrisSettings.get().getStudio().isEntitySpawning();
}
private KList<IrisEntitySpawn> spawnRandomly(List<IrisEntitySpawn> types) {
return Rarity.expandWeighted(types);
record SpawnSelection(IrisEntitySpawn entry, IrisSpawner spawner) implements Rarity {
@Override
public int getRarity() {
return entry.getRarity();
}
}
static final class ChunkCounter {
@@ -493,9 +503,9 @@ final class WorldEntitySpawner {
}
}
int remainingCapacity(IrisEntitySpawn entry, Engine engine) {
int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Engine engine) {
IrisEntity entity = entry.getRealEntity(engine);
return entry.getReferenceSpawner().remainingCapacity(entity, counts);
return spawner.remainingCapacity(entity, counts);
}
}
}
@@ -0,0 +1,35 @@
package art.arcane.iris.world.entity;
import art.arcane.volmlib.util.math.RNG;
public final class EntitySpawnSeed {
private static final long CHUNK_SALT = 0xA0761D6478BD642FL;
private static final long MARKER_SALT = 0xE7037ED1A0B428DBL;
private static final long ENTITY_SALT = 0x8EBC6AF09C88C6E3L;
private EntitySpawnSeed() {
}
public static RNG chunk(long entitySeed, int x, int z) {
long seed = mix(entitySeed ^ CHUNK_SALT);
seed = mix(seed ^ (long) x * 0xD6E8FEB86659FD93L);
return new RNG(mix(seed ^ (long) z * 0x8CB92BA72F3D8DD7L));
}
public static RNG marker(long entitySeed, int x, int y, int z) {
long seed = mix(entitySeed ^ MARKER_SALT);
seed = mix(seed ^ (long) x * 0xD6E8FEB86659FD93L);
seed = mix(seed ^ (long) y * 0xA5A3564E27F886A7L);
return new RNG(mix(seed ^ (long) z * 0x8CB92BA72F3D8DD7L));
}
public static RNG entity(long batchSeed, int ordinal) {
return new RNG(mix(batchSeed ^ ENTITY_SALT ^ (long) ordinal * 0x9E3779B97F4A7C15L));
}
private static long mix(long seed) {
seed = (seed ^ seed >>> 30) * 0xBF58476D1CE4E5B9L;
seed = (seed ^ seed >>> 27) * 0x94D049BB133111EBL;
return seed ^ seed >>> 31;
}
}
@@ -40,7 +40,6 @@ import org.bukkit.inventory.meta.ItemMeta;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Snippet("attribute-modifier")
@@ -134,12 +133,12 @@ public class IrisAttributeModifier {
}
}
private AttributeModifier createModifier(RNG rng) {
NamespacedKey key = NamespacedKey.minecraft(generateModifierKey());
AttributeModifier createModifier(RNG rng) {
NamespacedKey key = NamespacedKey.minecraft(generateModifierKey(rng));
return new AttributeModifier(key, getAmount(rng), resolveOperation(), EquipmentSlotGroup.ANY);
}
private String generateModifierKey() {
private String generateModifierKey(RNG rng) {
String source = getName() == null ? "modifier" : getName();
String normalized = source.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9/._-]", "_");
if (normalized.isBlank()) {
@@ -148,7 +147,8 @@ public class IrisAttributeModifier {
if (normalized.length() > 32) {
normalized = normalized.substring(0, 32);
}
String random = UUID.randomUUID().toString().replace("-", "");
String random = Long.toUnsignedString(rng.nextLong(), 16)
+ "_" + Long.toUnsignedString(rng.nextLong(), 16);
return normalized + "_" + random;
}
}
@@ -268,7 +268,8 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
}
public Entity spawn(Engine gen, Location at) {
return spawn(gen, at, new RNG(at.hashCode()));
return spawn(gen, at, EntitySpawnSeed.marker(gen.getSeedManager().getEntity(),
at.getBlockX(), at.getBlockY(), at.getBlockZ()));
}
public Entity spawn(Engine gen, Location at, RNG rng) {
@@ -531,17 +532,19 @@ public class IrisEntity extends IrisRegistrant implements NativeEntityOptions {
return specialType != null && !specialType.equals("");
}
private static final class BukkitOps {
static final class BukkitOps {
private static void persistVillager(Villager villager) {
villager.setPersistent(true);
}
private static void bindLoot(IrisEntity entity, Engine gen, Lootable l, Location finalAt, RNG rng) {
static void bindLoot(IrisEntity entity, Engine gen, Lootable l, Location finalAt, RNG rng) {
IrisData definitions = gen.getData();
long lootSeed = EntitySpawnSeed.entity(rng.getSeed(), -2).getSeed();
NamespacedKey lootKey = new NamespacedKey("iris", "loot-" + Long.toUnsignedString(lootSeed, 16));
l.setLootTable(new LootTable() {
@Override
public NamespacedKey getKey() {
return new NamespacedKey("iris", "loot-" + entity.hashCode());
return lootKey;
}
@Override
@@ -54,6 +54,9 @@ import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Entity;
import java.util.Objects;
import java.util.Comparator;
@Snippet("entity-spawn")
@Accessors(chain = true)
@NoArgsConstructor
@@ -63,7 +66,6 @@ import org.bukkit.entity.Entity;
public class IrisEntitySpawn implements Rarity {
private static final int CAVE_COLUMN_ATTEMPTS = 8;
private static final int CAVE_VERTICAL_ATTEMPTS = 128;
private final transient AtomicCache<RNG> rng = new AtomicCache<>();
private final transient AtomicCache<IrisEntity> ent = new AtomicCache<>();
@RegistryListResource(IrisEntity.class)
@Required
@@ -78,10 +80,8 @@ public class IrisEntitySpawn implements Rarity {
@MinNumber(1)
@Description("The max of this entity to spawn")
private int maxSpawns = 1;
private transient IrisSpawner referenceSpawner;
private transient IrisMarker referenceMarker;
public int spawn(Engine gen, Chunk c, RNG rng, int remainingCapacity) {
public int spawn(Engine gen, Chunk chunk, RNG rng, int remainingCapacity, SpawnContext context) {
if (remainingCapacity <= 0) {
return 0;
}
@@ -89,42 +89,39 @@ public class IrisEntitySpawn implements Rarity {
if (definition == null) {
return 0;
}
int spawns = Math.min(remainingCapacity, LootResolver.inclusive(rng, minSpawns, maxSpawns));
int s = 0;
if (spawns > 0) {
for (int id = 0; id < spawns; id++) {
IrisSpawnGroup group = getReferenceSpawner().getGroup();
Location l;
if (group == IrisSpawnGroup.CAVE) {
l = findCaveSpawnLocation(gen, c, rng, definition.getSurface());
} else {
int x = (c.getX() << 4) + rng.i(16);
int z = (c.getZ() << 4) + rng.i(16);
World world = c.getWorld();
int h = world.getHighestBlockYAt(x, z, HeightMap.OCEAN_FLOOR);
int hf = world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE);
Integer y = selectSurfaceSpawnY(group, definition.getSurface(), h, hf, rng);
l = y == null ? null : new Location(world, x, y, z);
}
if (l != null) {
if (referenceSpawner.getAllowedLightLevels().getMin() > 0 || referenceSpawner.getAllowedLightLevels().getMax() < 15) {
if (referenceSpawner.getAllowedLightLevels().contains(l.getBlock().getLightLevel())) {
if (spawn100(gen, l) != null) {
s++;
}
}
} else {
if (spawn100(gen, l) != null) {
s++;
}
}
}
long batchSeed = rng.getSeed();
int count = Math.min(remainingCapacity, LootResolver.inclusive(rng, minSpawns, maxSpawns));
IrisSpawnGroup group = context.spawner().getGroup();
KList<IrisPosition> caveMarkers = null;
if (context.initial() && group == IrisSpawnGroup.CAVE && !J.isFolia()) {
caveMarkers = gen.getMantle().findMarkers(chunk.getX(), chunk.getZ(), MarkerMatter.CAVE_FLOOR);
caveMarkers.sort(Comparator.comparingInt(IrisPosition::getX)
.thenComparingInt(IrisPosition::getY).thenComparingInt(IrisPosition::getZ));
}
int spawned = 0;
for (int ordinal = 0; ordinal < count; ordinal++) {
RNG attemptRng = EntitySpawnSeed.entity(batchSeed, ordinal);
RNG positionRng = attemptRng.nextParallelRNG(0x632BE59BD9B4E019L);
Location location;
if (group == IrisSpawnGroup.CAVE) {
location = caveMarkers == null
? findCaveSpawnLocation(gen, chunk, positionRng, definition.getSurface())
: selectCaveSpawnLocation(caveMarkers, chunk.getWorld(), positionRng);
} else {
int x = (chunk.getX() << 4) + positionRng.i(16);
int z = (chunk.getZ() << 4) + positionRng.i(16);
World world = chunk.getWorld();
int floor = world.getHighestBlockYAt(x, z, HeightMap.OCEAN_FLOOR);
int top = world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE);
Integer y = selectSurfaceSpawnY(group, definition.getSurface(), floor, top, positionRng);
location = y == null ? null : new Location(world, x, y, z);
}
if (location != null && lightAllowed(location, context)
&& spawn100(gen, location, attemptRng, false) != null) {
spawned++;
}
}
return s;
return spawned;
}
public static Integer selectSurfaceSpawnY(IrisSpawnGroup group, IrisSurface surface,
@@ -138,43 +135,41 @@ public class IrisEntitySpawn implements Rarity {
return top > floor ? LootResolver.inclusive(rng, floor + 1, top) : null;
}
public int spawn(Engine gen, IrisPosition c, RNG rng) {
int spawns = LootResolver.inclusive(rng, minSpawns, maxSpawns);
int s = 0;
if (!BukkitWorldBinding.tryBind(gen.getWorld())) {
public int spawn(Engine gen, IrisPosition position, RNG rng, SpawnContext context) {
long batchSeed = rng.getSeed();
int count = LootResolver.inclusive(rng, minSpawns, maxSpawns);
if (count <= 0 || !BukkitWorldBinding.tryBind(gen.getWorld())) {
return 0;
}
World world = BukkitWorldBinding.world(gen.getWorld());
if (spawns > 0) {
if (referenceMarker != null && referenceMarker.shouldExhaust()) {
if (J.isFolia()) {
J.a(() -> gen.getMantle().getMantle().remove(c.getX(), c.getY() - gen.getWorld().minHeight(), c.getZ(), MatterMarker.class));
} else {
gen.getMantle().getMantle().remove(c.getX(), c.getY() - gen.getWorld().minHeight(), c.getZ(), MatterMarker.class);
}
}
for (int id = 0; id < spawns; id++) {
Location l = BukkitPlatform.toLocation(c, world).add(0, 1, 0);
if (referenceSpawner.getAllowedLightLevels().getMin() > 0 || referenceSpawner.getAllowedLightLevels().getMax() < 15) {
if (referenceSpawner.getAllowedLightLevels().contains(l.getBlock().getLightLevel())) {
if (spawn100(gen, l, true) != null) {
s++;
}
}
} else {
if (spawn100(gen, l, true) != null) {
s++;
}
}
if (context.marker() != null && context.marker().shouldExhaust(EntitySpawnSeed.entity(batchSeed, -1))) {
if (J.isFolia()) {
J.a(() -> gen.getMantle().getMantle().remove(position.getX(),
position.getY() - gen.getWorld().minHeight(), position.getZ(), MatterMarker.class));
} else {
gen.getMantle().getMantle().remove(position.getX(),
position.getY() - gen.getWorld().minHeight(), position.getZ(), MatterMarker.class);
}
}
int spawned = 0;
for (int ordinal = 0; ordinal < count; ordinal++) {
Location location = BukkitPlatform.toLocation(position, world).add(0, 1, 0);
if (lightAllowed(location, context)
&& spawn100(gen, location, EntitySpawnSeed.entity(batchSeed, ordinal), true) != null) {
spawned++;
}
}
return spawned;
}
return s;
private static boolean lightAllowed(Location location, SpawnContext context) {
if (context.initial()) {
return true;
}
IrisSpawner spawner = context.spawner();
return spawner.getAllowedLightLevels().getMin() <= 0
&& spawner.getAllowedLightLevels().getMax() >= 15
|| spawner.getAllowedLightLevels().contains(location.getBlock().getLightLevel());
}
public IrisEntity getRealEntity(Engine g) {
@@ -191,16 +186,13 @@ public class IrisEntitySpawn implements Rarity {
return entity == null || entity.isCompatExcluded() ? null : entity;
}
public Entity spawn(Engine g, Location at) {
if (getRealEntity(g) == null) {
public Entity spawn(Engine engine, Location at) {
if (getRealEntity(engine) == null) {
return null;
}
if (LootResolver.oneIn(rng.aquire(() -> new RNG(g.getSeedManager().getEntity())), getRarity())) {
return spawn100(g, at);
}
return null;
RNG rng = EntitySpawnSeed.marker(engine.getSeedManager().getEntity(),
at.getBlockX(), at.getBlockY(), at.getBlockZ());
return LootResolver.oneIn(rng, getRarity()) ? spawn100(engine, at, rng, false) : null;
}
static Location findCaveSpawnLocation(Engine engine, Chunk chunk, RNG rng, IrisSurface surface) {
@@ -249,11 +241,7 @@ public class IrisEntitySpawn implements Rarity {
return marker == null ? null : BukkitPlatform.toLocation(marker, world);
}
private Entity spawn100(Engine g, Location at) {
return spawn100(g, at, false);
}
private Entity spawn100(Engine g, Location at, boolean ignoreSurfaces) {
private Entity spawn100(Engine g, Location at, RNG rng, boolean ignoreSurfaces) {
try {
IrisEntity irisEntity = getRealEntity(g);
if (irisEntity == null) { // No entity
@@ -278,7 +266,7 @@ public class IrisEntitySpawn implements Rarity {
}
}
Entity e = irisEntity.spawn(g, at.clone().add(0.5, surface.isFluid() ? 0.5 : 0, 0.5), rng.aquire(() -> new RNG(g.getSeedManager().getEntity())));
Entity e = irisEntity.spawn(g, at.clone().add(0.5, surface.isFluid() ? 0.5 : 0, 0.5), rng);
if (e != null) {
IrisLogging.debug("Spawned " + C.DARK_AQUA + "Entity<" + getEntity() + "> " + C.GREEN + e.getType() + C.LIGHT_PURPLE + " @ " + C.GRAY + e.getLocation().getX() + ", " + e.getLocation().getY() + ", " + e.getLocation().getZ());
}
@@ -291,6 +279,12 @@ public class IrisEntitySpawn implements Rarity {
}
}
public record SpawnContext(IrisSpawner spawner, IrisMarker marker, boolean initial) {
public SpawnContext {
Objects.requireNonNull(spawner, "spawner");
}
}
private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR;
}
@@ -51,8 +51,8 @@ public class IrisMarker extends IrisRegistrant {
@Description("If this marker is used, what is the chance it removes itself. For example 25% (0.25) would mean that on average 4 uses will remove a specific marker. Set this below 0 (-1) to never exhaust & set this to 1 or higher to always exhaust on first use.")
private double exhaustionChance = 0;
public boolean shouldExhaust() {
return exhaustionChance > RNG.r.nextDouble();
public boolean shouldExhaust(RNG rng) {
return exhaustionChance > rng.nextDouble();
}
@Override
@@ -49,36 +49,33 @@ import java.util.Map;
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@Description("Represents an entity spawn during initial chunk generation")
@Description("Configures initial inhabitants and ongoing entity spawning")
@Data
public class IrisSpawner extends IrisRegistrant {
private transient IrisMarker referenceMarker;
@ArrayType(min = 1, type = IrisEntitySpawn.class)
@Description("The entity spawns to add")
private KList<IrisEntitySpawn> spawns = new KList<>();
@ArrayType(min = 1, type = IrisEntitySpawn.class)
@Description("The entity spawns to add initially. EXECUTES PER CHUNK!")
@Description("The seeded entity pool used once per chunk or marker for initial inhabitants. Live time, weather, light, population, and cooldowns do not restrict this pool.")
private KList<IrisEntitySpawn> initialSpawns = new KList<>();
@Description("Maximum ambient entities of the selected entity's native spawn category in one chunk. Spawn groups are limited to the remaining capacity.")
@Description("Maximum ambient entities of the selected entity's native spawn category in one chunk. Ongoing groups use remaining capacity; initial groups use this limit independently of existing mobs.")
private int maxEntitiesPerChunk = 1;
@Description("The block of 24 hour time to contain this spawn in.")
@Description("The 24-hour time window for ongoing spawns.")
private IrisTimeBlock timeBlock = new IrisTimeBlock();
@Description("The weather condition required for this spawner to fire.")
@Description("The weather condition required for ongoing spawns.")
private IrisWeather weather = IrisWeather.ANY;
@Description("The maximum rate this spawner can fire")
@Description("The maximum ongoing spawn rate")
private IrisRate maximumRate = new IrisRate();
@Description("The maximum rate this spawner can fire on a specific chunk")
@Description("The maximum ongoing spawn rate in a specific chunk")
private IrisRate maximumRatePerChunk = new IrisRate();
@Description("The light levels this spawn is allowed to run in (0-15 inclusive)")
@Description("The light levels allowed for ongoing spawns (0-15 inclusive). Initial inhabitants ignore light.")
private IrisRange allowedLightLevels = new IrisRange(0, 15);
@Description("Where should these spawns be placed")
@@ -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
@@ -1,7 +1,14 @@
package art.arcane.iris.testsupport;
import io.papermc.paper.registry.RegistryAccess;
import io.papermc.paper.registry.RegistryKey;
import net.kyori.adventure.key.Key;
import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.block.Biome;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.block.data.BlockData;
@@ -11,7 +18,10 @@ import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.mockito.invocation.InvocationOnMock;
import java.lang.reflect.Proxy;
import java.util.Locale;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;
import static org.mockito.ArgumentMatchers.any;
@@ -20,6 +30,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
public final class BukkitTestServer {
private static final String DEFAULT_NAME = "IrisTestServer";
@@ -71,6 +82,45 @@ public final class BukkitTestServer {
}
}
public static RegistryAccess registryAccess() {
Map<Object, Object> registries = new HashMap<>();
return mock(RegistryAccess.class, invocation -> {
if (!invocation.getMethod().getName().equals("getRegistry")) {
return RETURNS_DEFAULTS.answer(invocation);
}
Object key = invocation.getArgument(0);
return registries.computeIfAbsent(key, registryKey -> registryProxy(registryKey == RegistryKey.GAME_RULE
|| registryKey == GameRule.class));
});
}
private static Object registryProxy(boolean gameRules) {
Map<Key, Object> values = new HashMap<>();
return Proxy.newProxyInstance(Registry.class.getClassLoader(), new Class<?>[]{Registry.class},
(proxy, method, arguments) -> switch (method.getName()) {
case "getOrThrow", "get" -> values.computeIfAbsent((Key) arguments[0],
key -> gameRules ? mock(GameRule.class) : biome(key));
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == arguments[0];
case "toString" -> gameRules ? "iris-test-game-rule-registry" : "iris-test-biome-registry";
case "size" -> values.size();
case "hasTag" -> false;
default -> null;
});
}
private static Biome biome(Key key) {
NamespacedKey namespaced = new NamespacedKey(key.namespace(), key.value());
return (Biome) Proxy.newProxyInstance(Biome.class.getClassLoader(), new Class<?>[]{Biome.class},
(proxy, method, arguments) -> switch (method.getName()) {
case "getKey" -> namespaced;
case "hashCode" -> namespaced.hashCode();
case "equals" -> proxy == arguments[0];
case "toString", "name" -> key.value();
default -> null;
});
}
public static ServerBranding branding() {
return new ServerBranding();
}