Studio fixes.

- Fixes the Object and Jigsaw studio errors from the coretapper i made.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-07 15:23:25 -04:00
parent 0dcd1900be
commit 894cef73a6
15 changed files with 1628 additions and 83 deletions
@@ -159,6 +159,17 @@ public final class ObjectStudioLayout {
c.getInt("h"),
c.getInt("d")
);
IrisData source = sources.get(pack);
File objectFile = source == null ? null : source.getObjectLoader().findFile(cell.key());
if (objectFile == null) {
return null;
}
IrisBlockVector size = IrisObject.sampleSize(objectFile);
if (cell.w() != Math.max(1, size.getBlockX())
|| cell.h() != Math.max(1, size.getBlockY())
|| cell.d() != Math.max(1, size.getBlockZ())) {
return null;
}
stored.add(cell);
storedIds.add(cell.pack() + "/" + cell.key());
}
@@ -217,6 +228,26 @@ public final class ObjectStudioLayout {
return padding;
}
public ObjectStudioLayout atFloor(int floorY) {
int originY = Math.addExact(floorY, 1);
boolean unchanged = true;
for (GridCell cell : cells) {
if (cell.originY() != originY) {
unchanged = false;
break;
}
}
if (unchanged) {
return this;
}
List<GridCell> placed = new ArrayList<>(cells.size());
for (GridCell cell : cells) {
placed.add(new GridCell(cell.pack(), cell.key(), cell.originX(), originY, cell.originZ(),
cell.w(), cell.h(), cell.d()));
}
return new ObjectStudioLayout(padding, rowWidthCap, placed);
}
public List<GridCell> cells() {
return cells;
}
@@ -3815,16 +3815,15 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
message(player, "Iris Jigsaw Studio is not active in this world.");
return false;
}
List<JigsawStudioBounds> bounds = new ArrayList<>(pieces.size());
List<JigsawStudioPreviewRenderer.PreviewBounds> bounds = new ArrayList<>(pieces.size());
for (PlacedStructurePiece piece : pieces) {
int width = Math.addExact(Math.subtractExact(piece.getMaxX(), piece.getMinX()), 1);
int height = Math.addExact(Math.subtractExact(piece.getMaxY(), piece.getMinY()), 1);
int depth = Math.addExact(Math.subtractExact(piece.getMaxZ(), piece.getMinZ()), 1);
bounds.add(new JigsawStudioBounds(
bounds.add(new JigsawStudioPreviewRenderer.PreviewBounds(
piece.getMinX(),
piece.getMinY(),
piece.getMinZ(),
new JigsawStudioCellDimensions(width, height, depth)));
piece.getMaxX(),
piece.getMaxY(),
piece.getMaxZ()));
}
assemblyPreviews.put(player.getUniqueId(), new AssemblyPreview(
player.getWorld().getUID(),
@@ -7552,10 +7551,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
return;
}
JigsawStudioPreviewRenderer.PreviewBounds preview = evaluation.previewBounds();
JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(
preview.maximumX() - preview.minimumX() + 1,
preview.maximumY() - preview.minimumY() + 1,
preview.maximumZ() - preview.minimumZ() + 1);
Color color = switch (evaluation.state()) {
case VALID -> ASSEMBLY_PREVIEW_COLOR;
case PENDING, WARNING, STALE -> LIVE_PREVIEW_WARNING_COLOR;
@@ -7564,11 +7559,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
drawBounds(
player,
playerLocation,
new JigsawStudioBounds(
preview.minimumX(),
preview.minimumY(),
preview.minimumZ(),
dimensions),
preview,
color,
0.85F,
2.0D,
@@ -7589,7 +7580,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
assemblyPreviews.remove(player.getUniqueId(), preview);
return;
}
for (JigsawStudioBounds bounds : preview.bounds()) {
for (JigsawStudioPreviewRenderer.PreviewBounds bounds : preview.bounds()) {
drawBounds(player, playerLocation, bounds, ASSEMBLY_PREVIEW_COLOR, 0.85F, 2.0D, budget);
if (budget.empty()) {
return;
@@ -7682,12 +7673,26 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
double step,
ParticleBudget budget
) {
double minX = bounds.originX();
double minY = bounds.originY();
double minZ = bounds.originZ();
double maxX = bounds.maxX() + 1.0D;
double maxY = bounds.maxY() + 1.0D;
double maxZ = bounds.maxZ() + 1.0D;
drawBounds(player, playerLocation, new JigsawStudioPreviewRenderer.PreviewBounds(
bounds.originX(), bounds.originY(), bounds.originZ(), bounds.maxX(), bounds.maxY(), bounds.maxZ()),
color, size, step, budget);
}
private static void drawBounds(
Player player,
Location playerLocation,
JigsawStudioPreviewRenderer.PreviewBounds bounds,
Color color,
float size,
double step,
ParticleBudget budget
) {
double minX = bounds.minimumX();
double minY = bounds.minimumY();
double minZ = bounds.minimumZ();
double maxX = bounds.maximumX() + 1.0D;
double maxY = bounds.maximumY() + 1.0D;
double maxZ = bounds.maximumZ() + 1.0D;
drawLine(player, playerLocation, minX, minY, minZ, maxX, minY, minZ, color, size, step, budget);
drawLine(player, playerLocation, minX, minY, maxZ, maxX, minY, maxZ, color, size, step, budget);
drawLine(player, playerLocation, minX, maxY, minZ, maxX, maxY, minZ, color, size, step, budget);
@@ -8657,7 +8662,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private record AssemblyPreview(
UUID worldId,
long expiresAtMillis,
List<JigsawStudioBounds> bounds
List<JigsawStudioPreviewRenderer.PreviewBounds> bounds
) {
AssemblyPreview {
Objects.requireNonNull(worldId, "Jigsaw Studio preview world");
@@ -107,7 +107,7 @@ public class ObjectStudioSaveService implements IrisService {
}
String packKey = engine.getDimension() == null ? null : engine.getDimension().getLoadKey();
studios.put(world.getUID(), new ActiveStudio(world.getUID(), layout, objectsDirs, packKey));
studios.put(world.getUID(), new ActiveStudio(world.getUID(), generator, objectsDirs, packKey));
IrisLogging.debug("Object Studio live-save registered: world=%s cells=%d packs=%d",
world.getName(), layout.cells().size(), objectsDirs.size());
}
@@ -219,7 +219,7 @@ public class ObjectStudioSaveService implements IrisService {
return;
}
IrisObject snapshot = new IrisObject(cell.w(), cell.h(), cell.d());
IrisObject snapshot = studio.generator.createCapture(cell);
int originX = cell.originX();
int originY = cell.originY();
int originZ = cell.originZ();
@@ -324,14 +324,16 @@ public class ObjectStudioSaveService implements IrisService {
private static final class ActiveStudio {
final UUID worldId;
final ObjectStudioGenerator generator;
final ObjectStudioLayout layout;
final Map<String, File> objectsDirs;
final String packKey;
final Map<String, Long> hashes = new ConcurrentHashMap<>();
ActiveStudio(UUID worldId, ObjectStudioLayout layout, Map<String, File> objectsDirs, String packKey) {
ActiveStudio(UUID worldId, ObjectStudioGenerator generator, Map<String, File> objectsDirs, String packKey) {
this.worldId = worldId;
this.layout = layout;
this.generator = generator;
this.layout = generator.getLayout();
this.objectsDirs = objectsDirs;
this.packKey = packKey;
}
@@ -20,6 +20,7 @@ package art.arcane.iris.engine;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.data.cache.Cache;
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
import art.arcane.iris.engine.platform.EngineBukkitOps;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
@@ -262,7 +263,7 @@ final class WorldChunkMaintenance {
}
}
private void updateChunkRegion(World world, int chunkX, int chunkZ) {
void updateChunkRegion(World world, int chunkX, int chunkZ) {
if (world == null || !world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) {
return;
}
@@ -274,7 +275,14 @@ final class WorldChunkMaintenance {
warmupMantleChunkAsync(chunkX, chunkZ);
return;
}
EngineBukkitOps.updateChunk(manager.getEngine(), chunk);
try {
EngineBukkitOps.updateChunk(manager.getEngine(), chunk);
} catch (SavedBiomeUnavailableException unavailable) {
if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) {
throw unavailable;
}
return;
}
}
if (manager.entitySpawner.isEntitySpawningEnabledForCurrentWorld()) {
@@ -54,6 +54,7 @@ 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;
@@ -93,22 +94,22 @@ final class WorldEntitySpawner {
}
if (manager.cl.flip()) {
CompletableFuture<Integer> future = new CompletableFuture<>();
try {
World realWorld = BukkitWorldBinding.world(manager.getEngine().getWorld());
if (realWorld == null) {
manager.entityCount = 0;
manager.entityCountValid = false;
} else {
CompletableFuture<Integer> future = new CompletableFuture<>();
boolean scheduled = J.runGlobal(() -> {
try {
future.complete(livingEntityCount(realWorld));
} catch (Throwable ex) {
future.completeExceptionally(ex);
}
});
boolean scheduled = J.runGlobal(managedCompletion(
"bukkit_world_manager_entity_count", future, () -> livingEntityCount(realWorld)));
if (scheduled) {
manager.entityCount = future.get(2, TimeUnit.SECONDS);
Integer count = future.get(2, TimeUnit.SECONDS);
if (count == null || manager.getEngine().isClosing() || manager.getEngine().isClosed()) {
manager.entityCountValid = false;
return false;
}
manager.entityCount = count;
manager.entityCountValid = true;
resetEntityCountFailures();
} else {
@@ -126,6 +127,8 @@ final class WorldEntitySpawner {
reportEntityCountFailure("Failed to count entities; pausing Iris entity spawning until a complete count is available.", cause);
} catch (Throwable e) {
reportEntityCountFailure("Failed to count entities; pausing Iris entity spawning until a complete count is available.", e);
} finally {
future.complete(null);
}
}
@@ -193,7 +196,7 @@ final class WorldEntitySpawner {
return false;
}
CompletableFuture<Void> future = new CompletableFuture<>();
CompletableFuture<Boolean> future = new CompletableFuture<>();
AtomicBoolean failureReported = new AtomicBoolean();
future.whenComplete((ignored, failure) -> {
if (failure != null) {
@@ -202,32 +205,28 @@ final class WorldEntitySpawner {
});
boolean scheduled;
try {
scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
try {
if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) {
future.complete(null);
return;
}
spawnIn(world.getChunkAt(chunkX, chunkZ), initial);
future.complete(null);
} catch (Throwable e) {
future.completeExceptionally(e);
}
});
scheduled = J.runRegion(world, chunkX, chunkZ, managedCompletion(
"bukkit_world_manager_entity_spawn", future, () -> {
if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) {
return true;
}
spawnIn(world.getChunkAt(chunkX, chunkZ), initial);
return true;
}));
} catch (Throwable e) {
future.complete(null);
IrisLogging.reportError("Failed to schedule an Iris entity spawn for chunk " + chunkX + "," + chunkZ + ".", e);
return false;
}
if (!scheduled) {
future.complete(null);
IrisLogging.debug("Skipped Iris entity spawning because the region task was not accepted for chunk " + chunkX + "," + chunkZ + ".");
return false;
}
try {
future.get(5, TimeUnit.SECONDS);
return true;
return Boolean.TRUE.equals(future.get(5, TimeUnit.SECONDS));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
@@ -238,9 +237,28 @@ final class WorldEntitySpawner {
Throwable cause = e.getCause() == null ? e : e.getCause();
reportSpawnFailure(chunkX, chunkZ, cause, failureReported);
return false;
} finally {
future.complete(null);
}
}
private <T> Runnable managedCompletion(String operation, CompletableFuture<T> future, Supplier<T> task) {
Runnable managed = manager.managedTask(operation, () -> {
if (!future.isDone()) {
future.complete(task.get());
}
}, () -> future.complete(null));
return () -> {
try {
managed.run();
} catch (Throwable failure) {
if (!future.completeExceptionally(failure)) {
IrisLogging.reportError("Failed to finish " + operation + ".", failure);
}
}
};
}
private void reportEntityCountFailure(String message, Throwable error) {
manager.entityCountValid = false;
if (error != null) {
@@ -629,6 +629,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
lock.lock();
try {
throwIfInitializationFailed();
if (setup.get()) {
return getEngine();
}
@@ -636,6 +637,19 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
getWorld().setRawWorldSeed(world.getSeed());
setupEngine();
try {
computeStudioGenerator();
} catch (Throwable failure) {
initializationFailure = failure;
try {
engine.close();
} catch (Throwable closeFailure) {
if (failure != closeFailure) {
failure.addSuppressed(closeFailure);
}
}
throw propagateEngineSetupFailure(failure);
}
setup.set(true);
this.hotloader = shouldRunStudioHotload(studio, closing, jigsawStudioActive) ? new Looper() {
@Override
@@ -935,11 +949,11 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
if (engine != expectedEngine) {
throw new IllegalStateException("Iris noise generation belongs to a replaced engine runtime.");
}
computeStudioGenerator();
StudioGenerator selected = computeStudioGenerator();
if (engine != expectedEngine) {
throw new IllegalStateException("Iris noise generation changed engine runtime during Studio resolution.");
}
return studioGenerator;
return selected;
}
public void withExclusiveControl(Runnable r) {
@@ -1077,10 +1091,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
try {
Engine engine = getEngine(world);
computeStudioGenerator();
StudioGenerator selected = computeStudioGenerator();
TerrainChunk tc = TerrainChunk.create(d);
if (studioGenerator != null) {
studioGenerator.generateChunk(engine, tc, x, z);
if (selected != null) {
selected.generateChunk(engine, tc, x, z);
} else {
ChunkDataHunkHolder blocks = new ChunkDataHunkHolder(d);
Hunk<PlatformBiome> biomes = Hunk.viewBiomes(tc);
@@ -1191,7 +1205,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
}
}
private void computeStudioGenerator() {
private StudioGenerator computeStudioGenerator() {
if (!studio) {
return studioGenerator;
}
return computeStudioGeneratorForStudio();
}
private synchronized StudioGenerator computeStudioGeneratorForStudio() {
String packKey = getEngine().getDimension().getLoadKey();
JigsawStudioActivation.Request jigsawRequest = studio
? JigsawStudioActivation.getGeneratorRequest(packKey)
@@ -1204,7 +1225,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
lastJigsawStudioRequestId = jigsawRequest.requestId();
lastMode = null;
}
return;
return studioGenerator;
}
}
if (lastJigsawStudioRequestId != null) {
@@ -1221,9 +1242,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
desired = StudioMode.OBJECT_BUFFET;
}
if (!desired.equals(lastMode)) {
lastMode = desired;
desired.inject(this);
lastMode = desired;
}
return studioGenerator;
}
@NotNull
@@ -60,6 +60,7 @@ import art.arcane.volmlib.util.matter.MatterCavern;
import art.arcane.volmlib.util.matter.MatterUpdate;
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
import io.papermc.lib.PaperLib;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location;
@@ -228,12 +229,33 @@ public final class EngineBukkitOps {
}
}
mantleChunk.iterate(MatterUpdate.class, (x, yf, z, value) -> {
int y = yf + engine.getWorld().minHeight();
if (value != null && value.isUpdate()) {
dispatcher.update(x, y, z);
IntArrayList completed = new IntArrayList();
try {
mantleChunk.iterate(MatterUpdate.class, (x, yf, z, value) -> {
int y = yf + engine.getWorld().minHeight();
if (value != null && value.isUpdate()) {
dispatcher.update(x, y, z);
completed.add((yf << 8) | ((x & 15) << 4) | (z & 15));
}
});
} catch (RuntimeException | Error failure) {
try {
for (int index = 0; index < completed.size(); index++) {
int position = completed.getInt(index);
int y = position >>> 8;
Matter section = mantleChunk.get(y >> 4);
if (section != null && section.hasSlice(MatterUpdate.class)) {
section.<MatterUpdate>getSlice(MatterUpdate.class)
.set((position >> 4) & 15, y & 15, position & 15, null);
}
}
} catch (RuntimeException | Error cleanupFailure) {
if (cleanupFailure != failure) {
failure.addSuppressed(cleanupFailure);
}
}
});
throw failure;
}
mantleChunk.deleteSlices(MatterUpdate.class);
engine.getMetrics().getUpdates().put(stopwatch.getMilliseconds());
}
@@ -60,7 +60,7 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
private final PlatformBlockState marker;
private final AtomicBoolean layoutBuilt = new AtomicBoolean(false);
private final Object layoutLock = new Object();
private final Map<String, IrisObject> objectCache = new ConcurrentHashMap<>();
private final Map<String, DisplayObject> objectCache = new ConcurrentHashMap<>();
private final Map<String, IrisData> packData = new ConcurrentHashMap<>();
private volatile ObjectStudioLayout layout;
@@ -130,7 +130,7 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
paintFrame(cell, tc, plinthY, chunkWorldX, chunkWorldZ, minHeight, maxHeight);
IrisObject object = loadObject(cell);
DisplayObject object = loadObject(cell);
if (object != null) {
placeSlice(object, cell, tc, chunkWorldX, chunkWorldZ, minHeight, maxHeight);
}
@@ -196,24 +196,19 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
}
}
private void placeSlice(IrisObject object, GridCell cell, TerrainChunk tc, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) {
VectorMap<PlatformBlockState> blocks = object.getBlocks();
private void placeSlice(DisplayObject object, GridCell cell, TerrainChunk tc, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) {
VectorMap<PlatformBlockState> blocks = object.source().getBlocks();
if (blocks == null || blocks.isEmpty()) return;
Vector3i center = object.getCenter();
int centerX = center == null ? 0 : center.getBlockX();
int centerY = center == null ? 0 : center.getBlockY();
int centerZ = center == null ? 0 : center.getBlockZ();
int originX = cell.originX();
int originY = cell.originY();
int originZ = cell.originZ();
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : blocks) {
IrisBlockVector signed = entry.getKey();
int worldX = originX + signed.getBlockX() + centerX;
int worldY = originY + signed.getBlockY() + centerY;
int worldZ = originZ + signed.getBlockZ() + centerZ;
int worldX = originX + signed.getBlockX() - object.minimumX();
int worldY = originY + signed.getBlockY() - object.minimumY();
int worldZ = originZ + signed.getBlockZ() - object.minimumZ();
if (worldX < chunkWorldX || worldX > chunkWorldX + 15) continue;
if (worldZ < chunkWorldZ || worldZ > chunkWorldZ + 15) continue;
@@ -226,17 +221,29 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
}
}
private IrisObject loadObject(GridCell cell) {
public IrisObject createCapture(GridCell cell) {
DisplayObject displayed = loadObject(cell);
if (displayed == null) {
throw new IllegalStateException("Object Studio source is unavailable: " + cell.pack() + "/" + cell.key());
}
IrisObject capture = new IrisObject(cell.w(), cell.h(), cell.d());
capture.setCenter(new Vector3i(-displayed.minimumX(), -displayed.minimumY(), -displayed.minimumZ()));
return capture;
}
private DisplayObject loadObject(GridCell cell) {
String cacheKey = cell.pack() + "/" + cell.key();
IrisObject cached = objectCache.get(cacheKey);
DisplayObject cached = objectCache.get(cacheKey);
if (cached != null) return cached;
IrisData data = packData.get(cell.pack());
if (data == null) return null;
IrisObject loaded = data.getObjectLoader().load(cell.key());
if (loaded != null) {
objectCache.put(cacheKey, loaded);
DisplayObject displayed = DisplayObject.of(loaded);
DisplayObject existing = objectCache.putIfAbsent(cacheKey, displayed);
return existing == null ? displayed : existing;
}
return loaded;
return null;
}
public Map<String, IrisData> getPackData() {
@@ -257,6 +264,9 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
layout = resumed;
} else {
layout = ObjectStudioLayout.build(sources, padding);
}
layout = layout.atFloor(Math.max(engine.getMinHeight(), ObjectStudioLayout.FLOOR_Y));
if (layout != resumed) {
layout.save(layoutFile);
}
layoutBuilt.set(true);
@@ -300,4 +310,26 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
}
return new IrisBlockVector(maxX, 0, maxZ);
}
private record DisplayObject(IrisObject source, int minimumX, int minimumY, int minimumZ) {
private static DisplayObject of(IrisObject object) {
int minimumX = Integer.MAX_VALUE;
int minimumY = Integer.MAX_VALUE;
int minimumZ = Integer.MAX_VALUE;
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : object.getBlocks()) {
if (entry.getValue() == null) {
continue;
}
IrisBlockVector position = entry.getKey();
minimumX = Math.min(minimumX, position.getBlockX());
minimumY = Math.min(minimumY, position.getBlockY());
minimumZ = Math.min(minimumZ, position.getBlockZ());
}
if (minimumX == Integer.MAX_VALUE) {
Vector3i center = object.getCenter();
return new DisplayObject(object, -center.getBlockX(), -center.getBlockY(), -center.getBlockZ());
}
return new DisplayObject(object, minimumX, minimumY, minimumZ);
}
}
}
@@ -0,0 +1,104 @@
package art.arcane.iris.core.runtime;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.engine.object.IrisObject;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.DataOutputStream;
import java.io.File;
import java.nio.file.Files;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ObjectStudioLayoutTest {
@Rule
public final TemporaryFolder temporary = new TemporaryFolder();
@Test
public void changedObjectHeaderInvalidatesSavedLayoutWithoutChangingItsKey() throws Exception {
File objectFile = temporary.newFile("jungleclutt6.iob");
File layoutFile = temporary.newFile("layout.json");
IrisData data = source(objectFile);
Map<String, IrisData> sources = Map.of("overworld", data);
int[][] dimensions = {{6, 3, 4}, {5, 4, 4}, {5, 3, 5}};
for (int[] changed : dimensions) {
header(objectFile, 5, 3, 4);
ObjectStudioLayout.build(sources, 2).save(layoutFile);
assertNotNull(ObjectStudioLayout.load(layoutFile, sources, 2));
header(objectFile, changed[0], changed[1], changed[2]);
assertNull(ObjectStudioLayout.load(layoutFile, sources, 2));
ObjectStudioLayout.GridCell rebuilt = ObjectStudioLayout.build(sources, 2).get("jungleclutt6");
assertEquals(changed[0], rebuilt.w());
assertEquals(changed[1], rebuilt.h());
assertEquals(changed[2], rebuilt.d());
}
}
@Test
public void highFloorMovesCellsWithoutChangingTheirPackingAndPersists() throws Exception {
File objectFile = temporary.newFile("jungleclutt6.iob");
File layoutFile = temporary.newFile("layout.json");
header(objectFile, 5, 3, 4);
Map<String, IrisData> sources = Map.of("overworld", source(objectFile));
ObjectStudioLayout original = ObjectStudioLayout.build(sources, 3, 32);
ObjectStudioLayout.GridCell before = original.get("jungleclutt6");
ObjectStudioLayout raised = original.atFloor(200);
ObjectStudioLayout.GridCell after = raised.get("overworld/jungleclutt6");
assertEquals(201, after.originY());
assertEquals(65, before.originY());
assertEquals(before.originX(), after.originX());
assertEquals(before.originZ(), after.originZ());
assertEquals(before.w(), after.w());
assertEquals(before.h(), after.h());
assertEquals(before.d(), after.d());
assertSame(raised, raised.atFloor(200));
raised.save(layoutFile);
ObjectStudioLayout restored = ObjectStudioLayout.load(layoutFile, sources, 3);
assertNotNull(restored);
assertEquals(after, restored.get("jungleclutt6"));
}
@Test
public void removedSourceObjectInvalidatesItsSavedCell() throws Exception {
File objectFile = temporary.newFile("jungleclutt6.iob");
File layoutFile = temporary.newFile("layout.json");
header(objectFile, 5, 3, 4);
IrisData data = source(objectFile);
Map<String, IrisData> sources = Map.of("overworld", data);
ObjectStudioLayout.build(sources, 2).save(layoutFile);
when(data.getObjectLoader().findFile("jungleclutt6")).thenReturn(null);
assertNull(ObjectStudioLayout.load(layoutFile, sources, 2));
}
@SuppressWarnings("unchecked")
private static IrisData source(File objectFile) {
IrisData data = mock(IrisData.class);
ResourceLoader<IrisObject> loader = mock(ResourceLoader.class);
when(data.getObjectLoader()).thenReturn(loader);
when(loader.getPossibleKeys()).thenReturn(new String[]{"jungleclutt6"});
when(loader.findFile("jungleclutt6")).thenReturn(objectFile);
return data;
}
private static void header(File file, int width, int height, int depth) throws Exception {
try (DataOutputStream output = new DataOutputStream(Files.newOutputStream(file.toPath()))) {
output.writeInt(width);
output.writeInt(height);
output.writeInt(depth);
}
}
}
@@ -0,0 +1,222 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.Invocation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.mockingDetails;
import static org.mockito.Mockito.when;
public class JigsawStudioVisualizationBoundsTest {
@Test
public void livePreviewUsesFullAssemblyBoundsBeyondEveryCellLimit() throws Exception {
List<JigsawStudioPreviewRenderer.PreviewBounds> bounds = List.of(
new JigsawStudioPreviewRenderer.PreviewBounds(-300, 60, -400, 300, 80, 400),
new JigsawStudioPreviewRenderer.PreviewBounds(-10, -50, -10, 10, 220, 10),
new JigsawStudioPreviewRenderer.PreviewBounds(0, 0, 0, 127, 191, 127));
Fixture fixture = fixture();
for (JigsawStudioPreviewRenderer.PreviewBounds preview : bounds) {
Location viewer = new Location(fixture.world(),
preview.maximumX() + 1.0D, preview.maximumY() + 1.0D, preview.maximumZ() + 1.0D);
evaluate(fixture, preview);
clearInvocations(fixture.player());
drawLive(fixture, viewer, 384);
List<Emission> emissions = emissions(fixture.player());
assertFalse(emissions.isEmpty());
assertTrue(emissions.size() <= 384);
assertTrue(emissions.stream().anyMatch(emission ->
emission.x() == viewer.getX() && emission.y() == viewer.getY()
&& emission.z() == viewer.getZ()));
for (Emission emission : emissions) {
double x = emission.x() - viewer.getX();
double y = emission.y() - viewer.getY();
double z = emission.z() - viewer.getZ();
assertTrue(x * x + y * y + z * z <= 96.0D * 96.0D);
}
}
}
@Test
public void ordinaryPreviewKeepsCellOutlineParticleOrderAndAppearance() throws Exception {
Fixture fixture = fixture();
JigsawStudioBounds bounds = new JigsawStudioBounds(-7, 60, -11,
new JigsawStudioCellDimensions(13, 9, 17));
evaluate(fixture, new JigsawStudioPreviewRenderer.PreviewBounds(
bounds.originX(), bounds.originY(), bounds.originZ(), bounds.maxX(), bounds.maxY(), bounds.maxZ()));
Location viewer = new Location(fixture.world(), 0, 64, 0);
drawLive(fixture, viewer, 384);
List<Emission> preview = emissions(fixture.player());
clearInvocations(fixture.player());
Method draw = JigsawStudioService.class.getDeclaredMethod("drawBounds", Player.class, Location.class,
JigsawStudioBounds.class, Color.class, float.class, double.class, nested("ParticleBudget"));
draw.setAccessible(true);
Field color = JigsawStudioService.class.getDeclaredField("ASSEMBLY_PREVIEW_COLOR");
color.setAccessible(true);
invoke(draw, null, fixture.player(), viewer, bounds, color.get(null), 0.85F, 2.0D, budget(384));
assertFalse(preview.isEmpty());
assertEquals(preview, emissions(fixture.player()));
}
@Test
public void livePreviewRefreshesChangedBoundsAndHonorsExhaustedBudget() throws Exception {
Fixture fixture = fixture();
Location viewer = new Location(fixture.world(), 201, 65, 201);
evaluate(fixture, JigsawStudioPreviewRenderer.PreviewBounds.empty());
drawLive(fixture, viewer, 384);
assertTrue(emissions(fixture.player()).isEmpty());
evaluate(fixture, new JigsawStudioPreviewRenderer.PreviewBounds(0, 64, 0, 200, 64, 200));
drawLive(fixture, viewer, 0);
assertTrue(emissions(fixture.player()).isEmpty());
drawLive(fixture, viewer, 7);
assertEquals(7, emissions(fixture.player()).size());
}
@Test
public void explicitAssemblyPreviewAcceptsLargePlacedBounds() throws Exception {
Fixture fixture = fixture();
PlacedStructurePiece piece = mock(PlacedStructurePiece.class);
when(piece.getMinX()).thenReturn(-200);
when(piece.getMinY()).thenReturn(64);
when(piece.getMinZ()).thenReturn(-200);
when(piece.getMaxX()).thenReturn(200);
when(piece.getMaxY()).thenReturn(64);
when(piece.getMaxZ()).thenReturn(200);
try (MockedStatic<J> scheduling = mockStatic(J.class)) {
scheduling.when(() -> J.isOwnedByCurrentRegion(fixture.player())).thenReturn(true);
assertTrue(fixture.service().showAssemblyPreview(fixture.player(), List.of(piece)));
}
Method draw = JigsawStudioService.class.getDeclaredMethod("drawAssemblyPreview", Player.class,
Location.class, nested("ParticleBudget"));
draw.setAccessible(true);
invoke(draw, fixture.service(), fixture.player(), new Location(fixture.world(), 201, 65, 201), budget(384));
assertTrue(emissions(fixture.player()).stream().anyMatch(emission ->
emission.x() == 201 && emission.y() == 65 && emission.z() == 201));
}
@Test
public void authoredCellLimitsRemainEnforced() {
assertThrows(IllegalArgumentException.class, () -> new JigsawStudioCellDimensions(129, 1, 1));
assertThrows(IllegalArgumentException.class, () -> new JigsawStudioCellDimensions(1, 1, 129));
assertThrows(IllegalArgumentException.class, () -> new JigsawStudioCellDimensions(1, 193, 1));
assertThrows(IllegalArgumentException.class, () -> new JigsawStudioCellDimensions(128, 192, 128));
}
private static Fixture fixture() throws Exception {
UUID requestId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getWorld()).thenReturn(world);
JigsawStudioActivation.Request request = mock(JigsawStudioActivation.Request.class);
when(request.requestId()).thenReturn(requestId);
JigsawStudioGenerator generator = mock(JigsawStudioGenerator.class);
when(generator.getRequest()).thenReturn(request);
Constructor<?> constructor = nested("ActiveStudio").getDeclaredConstructor(UUID.class, World.class,
Engine.class, JigsawStudioGenerator.class, ConcurrentHashMap.class, Set.class, AtomicLong.class);
constructor.setAccessible(true);
Object studio = constructor.newInstance(worldId, world, mock(Engine.class), generator,
new ConcurrentHashMap<>(), ConcurrentHashMap.newKeySet(), new AtomicLong());
JigsawStudioService service = new JigsawStudioService();
map(service, "studios").put(worldId, studio);
return new Fixture(service, player, world, requestId, studio);
}
private static void evaluate(Fixture fixture, JigsawStudioPreviewRenderer.PreviewBounds bounds) throws Exception {
map(fixture.service(), "evaluations").put(fixture.requestId(), new JigsawStudioGraphEvaluation(
fixture.requestId(), 1, 1337, JigsawStudioEvaluationState.VALID, "", 2, "", bounds));
}
private static void drawLive(Fixture fixture, Location viewer, int remaining) throws Exception {
Method draw = JigsawStudioService.class.getDeclaredMethod("drawLivePreview", Player.class, Location.class,
nested("ActiveStudio"), nested("ParticleBudget"));
draw.setAccessible(true);
invoke(draw, fixture.service(), fixture.player(), viewer, fixture.studio(), budget(remaining));
}
private static Object budget(int remaining) throws Exception {
Constructor<?> constructor = nested("ParticleBudget").getDeclaredConstructor(int.class);
constructor.setAccessible(true);
return constructor.newInstance(remaining);
}
private static Class<?> nested(String name) throws ClassNotFoundException {
return Class.forName(JigsawStudioService.class.getName() + "$" + name);
}
@SuppressWarnings("unchecked")
private static Map<UUID, Object> map(JigsawStudioService service, String name) throws Exception {
Field field = JigsawStudioService.class.getDeclaredField(name);
field.setAccessible(true);
return (Map<UUID, Object>) field.get(service);
}
private static void invoke(Method method, Object target, Object... arguments) throws Exception {
try {
method.invoke(target, arguments);
} catch (InvocationTargetException failure) {
if (failure.getCause() instanceof Exception cause) {
throw cause;
}
throw failure;
}
}
private static List<Emission> emissions(Player player) {
List<Emission> emissions = new ArrayList<>();
for (Invocation invocation : mockingDetails(player).getInvocations()) {
if (!invocation.getMethod().getName().equals("spawnParticle")) {
continue;
}
Particle.DustOptions dust = invocation.getArgument(9);
emissions.add(new Emission(invocation.getArgument(1), invocation.getArgument(2),
invocation.getArgument(3), dust.getColor(), dust.getSize()));
}
return emissions;
}
private record Fixture(JigsawStudioService service, Player player, World world, UUID requestId, Object studio) {
}
private record Emission(double x, double y, double z, Color color, float size) {
}
}
@@ -0,0 +1,92 @@
package art.arcane.iris.engine;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
import art.arcane.iris.engine.platform.EngineBukkitOps;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.anyInt;
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 WorldChunkMaintenanceSavedBiomeTest {
@Test
public void loadingDefersInitialSpawnUntilTheNextSuccessfulPass() throws Exception {
verifyFailure(new SavedBiomeUnavailableException("Loading", true), true);
}
@Test
public void unavailableSavedDataRemainsAnOperatorFailure() throws Exception {
verifyFailure(new SavedBiomeUnavailableException("Missing biome", false), false);
}
@Test
public void unrelatedFailuresAreNotSuppressed() throws Exception {
verifyFailure(new IllegalStateException("Broken update"), false);
}
@Test
public void loadingWithFailedMarkerCleanupRemainsAnOperatorFailure() throws Exception {
SavedBiomeUnavailableException loading = new SavedBiomeUnavailableException("Loading", true);
IllegalStateException cleanup = new IllegalStateException("Marker cleanup failed");
loading.addSuppressed(cleanup);
verifyFailure(loading, false);
assertSame(cleanup, loading.getSuppressed()[0]);
}
@SuppressWarnings("unchecked")
private void verifyFailure(RuntimeException failure, boolean loading) throws Exception {
Engine engine = mock(Engine.class);
IrisWorldManager manager = mock(IrisWorldManager.class);
Mantle<Matter> mantle = mock(Mantle.class);
WorldEntitySpawner spawner = mock(WorldEntitySpawner.class);
Field field = IrisWorldManager.class.getDeclaredField("entitySpawner");
field.setAccessible(true);
field.set(manager, spawner);
when(manager.getEngine()).thenReturn(engine);
when(manager.getMantle()).thenReturn(mantle);
when(mantle.isChunkLoaded(2, -1)).thenReturn(true);
when(spawner.isEntitySpawningEnabledForCurrentWorld()).thenReturn(true);
World world = mock(World.class);
Chunk chunk = mock(Chunk.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(world.getChunkAt(2, -1)).thenReturn(chunk);
IrisSettings settings = new IrisSettings();
settings.getWorld().setPostLoadBlockUpdates(true);
WorldChunkMaintenance maintenance = new WorldChunkMaintenance(manager);
try (MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class);
MockedStatic<EngineBukkitOps> updates = mockStatic(EngineBukkitOps.class)) {
configured.when(IrisSettings::get).thenReturn(settings);
updates.when(() -> EngineBukkitOps.updateChunk(engine, chunk))
.thenThrow(failure).thenAnswer(invocation -> null);
if (loading) {
maintenance.updateChunkRegion(world, 2, -1);
} else {
assertSame(failure, assertThrows(RuntimeException.class,
() -> maintenance.updateChunkRegion(world, 2, -1)));
}
verify(spawner, never()).spawnInitially(chunk);
maintenance.updateChunkRegion(world, 2, -1);
verify(spawner).spawnInitially(chunk);
}
}
}
@@ -0,0 +1,320 @@
package art.arcane.iris.engine;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.gui.PregeneratorJob;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineAssignedWorldManager;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.framework.GenerationSessionException;
import art.arcane.iris.engine.framework.GenerationSessionLease;
import art.arcane.iris.engine.framework.GenerationSessionManager;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.iris.util.project.context.IrisContext;
import art.arcane.volmlib.util.scheduling.ChronoLatch;
import org.bukkit.World;
import org.bukkit.entity.LivingEntity;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
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.ArgumentMatchers.same;
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 WorldEntitySpawnerLifecycleTest {
@Test
public void queuedSpawnIsDiscardedWhenTheManagerCloses() throws Exception {
try (Fixture fixture = new Fixture()) {
Future<Boolean> waiting = fixture.spawn(false, true);
Runnable task = fixture.nextTask();
fixture.manager.close();
task.run();
assertFalse(waiting.get(1L, TimeUnit.SECONDS));
verify(fixture.world, never()).isChunkLoaded(anyInt(), anyInt());
assertEquals(0, fixture.sessions.activeLeases());
}
}
@Test
public void queuedSpawnIsDiscardedWhenEngineDrainBegins() throws Exception {
try (Fixture fixture = new Fixture()) {
Future<Boolean> waiting = fixture.spawn(false, true);
Runnable task = fixture.nextTask();
when(fixture.engine.isClosing()).thenReturn(true);
fixture.sessions.sealAndAwait("closing", 1_000L);
task.run();
assertFalse(waiting.get(1L, TimeUnit.SECONDS));
verify(fixture.world, never()).isChunkLoaded(anyInt(), anyInt());
}
}
@Test
public void interruptedSpawnWaitDiscardsItsQueuedWork() throws Exception {
try (Fixture fixture = new Fixture()) {
Future<Boolean> waiting = fixture.spawn(true, true);
Runnable task = fixture.nextTask();
assertFalse(waiting.get(1L, TimeUnit.SECONDS));
assertTrue(fixture.interrupted.get());
task.run();
verify(fixture.world, never()).isChunkLoaded(anyInt(), anyInt());
}
}
@Test
public void rejectedSpawnDoesNotLeaveExecutableWork() throws Exception {
try (Fixture fixture = new Fixture()) {
Future<Boolean> waiting = fixture.spawn(false, false);
Runnable task = fixture.nextTask();
assertFalse(waiting.get(1L, TimeUnit.SECONDS));
task.run();
verify(fixture.world, never()).isChunkLoaded(anyInt(), anyInt());
}
}
@Test
public void admittedSpawnHoldsTheEngineDrainOnItsOwnerThread() throws Exception {
try (Fixture fixture = new Fixture()) {
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicReference<Thread> observed = new AtomicReference<>();
when(fixture.world.isChunkLoaded(7, -4)).thenAnswer(invocation -> {
observed.set(Thread.currentThread());
assertSame(fixture.engine, IrisContext.get().getEngine());
assertEquals(1, fixture.sessions.activeLeases());
entered.countDown();
assertTrue(release.await(2L, TimeUnit.SECONDS));
return false;
});
Future<Boolean> waiting = fixture.spawn(false, true);
Runnable task = fixture.nextTask();
ExecutorService owner = Executors.newSingleThreadExecutor();
ExecutorService closer = Executors.newSingleThreadExecutor();
try {
Future<?> running = owner.submit(task);
assertTrue(entered.await(1L, TimeUnit.SECONDS));
CountDownLatch closing = new CountDownLatch(1);
Future<?> drained = closer.submit(() -> {
closing.countDown();
fixture.sessions.sealAndAwait("closing", 1_500L);
return null;
});
assertTrue(closing.await(1L, TimeUnit.SECONDS));
waitForSeal(fixture.sessions);
assertFalse(drained.isDone());
assertEquals(1, fixture.sessions.activeLeases());
release.countDown();
running.get(1L, TimeUnit.SECONDS);
drained.get(1L, TimeUnit.SECONDS);
assertTrue(waiting.get(1L, TimeUnit.SECONDS));
assertNotNull(observed.get());
assertFalse(observed.get() == fixture.callerThread.get());
assertEquals(0, fixture.sessions.activeLeases());
} finally {
release.countDown();
owner.shutdownNow();
closer.shutdownNow();
assertTrue(owner.awaitTermination(2L, TimeUnit.SECONDS));
assertTrue(closer.awaitTermination(2L, TimeUnit.SECONDS));
}
}
}
@Test
public void unexpectedSpawnFailureIsReportedWithItsCause() throws Exception {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("unexpected chunk failure");
when(fixture.world.isChunkLoaded(7, -4)).thenThrow(failure);
Future<Boolean> waiting = fixture.spawn(false, true);
Runnable task = fixture.nextTask();
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
task.run();
logging.verify(() -> IrisLogging.reportError(
eq("Failed to spawn Iris entities in chunk 7,-4."), same(failure)));
}
assertFalse(waiting.get(1L, TimeUnit.SECONDS));
assertEquals(0, fixture.sessions.activeLeases());
}
}
@Test
public void queuedCountIsDiscardedWithoutPublishingAfterClose() throws Exception {
try (Fixture fixture = new Fixture()) {
fixture.manager.entityCount = 19;
fixture.manager.entityCountValid = true;
Future<Boolean> waiting = fixture.count();
Runnable task = fixture.nextTask();
fixture.manager.close();
task.run();
assertFalse(waiting.get(1L, TimeUnit.SECONDS));
assertFalse(fixture.manager.entityCountValid);
assertEquals(19, fixture.manager.entityCount);
verify(fixture.world, never()).getLivingEntities();
}
}
@Test
public void timedOutCountCannotRunLaterAndANewCountRecovers() throws Exception {
try (Fixture fixture = new Fixture()) {
Future<Boolean> waiting = fixture.count();
Runnable expired = fixture.nextTask();
assertFalse(waiting.get(3L, TimeUnit.SECONDS));
assertFalse(fixture.manager.entityCountValid);
expired.run();
verify(fixture.world, never()).getLivingEntities();
when(fixture.world.getLivingEntities()).thenReturn(List.of(mock(LivingEntity.class)));
setField(IrisWorldManager.class, fixture.manager, "cl", new ChronoLatch(1L));
Future<Boolean> retry = fixture.count();
fixture.nextTask().run();
assertFalse(retry.get(1L, TimeUnit.SECONDS));
assertTrue(fixture.manager.entityCountValid);
assertEquals(1, fixture.manager.entityCount);
assertEquals(0, fixture.sessions.activeLeases());
}
}
private static void waitForSeal(GenerationSessionManager sessions) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1L);
while (System.nanoTime() < deadline) {
try (GenerationSessionLease ignored = sessions.acquire("seal_probe")) {
Thread.onSpinWait();
} catch (GenerationSessionException expected) {
return;
}
}
throw new AssertionError("Generation session did not seal within one second.");
}
private static void setField(Class<?> owner, Object target, String name, Object value) throws Exception {
Field field = owner.getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
private static final class Fixture implements AutoCloseable {
private final Engine engine = mock(Engine.class);
private final World world = mock(World.class);
private final IrisWorld irisWorld = mock(IrisWorld.class);
private final GenerationSessionManager sessions = new GenerationSessionManager();
private final IrisWorldManager manager;
private final WorldEntitySpawner spawner;
private final ExecutorService caller = Executors.newSingleThreadExecutor();
private final BlockingQueue<Runnable> queued = new LinkedBlockingQueue<>();
private final AtomicBoolean interrupted = new AtomicBoolean();
private final AtomicReference<Thread> callerThread = new AtomicReference<>();
private Fixture() throws Exception {
EngineTarget target = mock(EngineTarget.class);
when(irisWorld.name()).thenReturn("spawner-lifecycle");
when(irisWorld.hasPlatformWorld()).thenReturn(true);
when(target.getWorld()).thenReturn(irisWorld);
when(engine.getTarget()).thenReturn(target);
when(engine.getWorld()).thenReturn(irisWorld);
when(engine.getGenerationSessions()).thenReturn(sessions);
manager = new IrisWorldManager(engine);
setField(EngineAssignedWorldManager.class, manager, "started", new AtomicBoolean(true));
spawner = manager.entitySpawner;
}
private Future<Boolean> spawn(boolean interrupt, boolean accepted) {
return caller.submit(() -> {
callerThread.set(Thread.currentThread());
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(() -> J.runRegion(eq(world), eq(7), eq(-4), any(Runnable.class)))
.thenAnswer(invocation -> {
queued.add(invocation.getArgument(3));
return accepted;
});
if (interrupt) {
Thread.currentThread().interrupt();
}
Method method = WorldEntitySpawner.class.getDeclaredMethod(
"spawnChunkSafely", World.class, int.class, int.class, boolean.class);
method.setAccessible(true);
try {
return (Boolean) method.invoke(spawner, world, 7, -4, false);
} catch (InvocationTargetException failure) {
throw new IllegalStateException(failure.getCause());
} finally {
interrupted.set(Thread.interrupted());
}
}
});
}
private Future<Boolean> count() {
return caller.submit(() -> {
IrisSettings settings = new IrisSettings();
settings.getWorld().setTargetSpawnEntitiesPerChunk(0.0D);
try (MockedStatic<J> scheduler = mockStatic(J.class);
MockedStatic<BukkitWorldBinding> bindings = mockStatic(BukkitWorldBinding.class);
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
MockedStatic<PregeneratorJob> pregenerator = mockStatic(PregeneratorJob.class);
MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
bindings.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world);
configured.when(IrisSettings::get).thenReturn(settings);
scheduler.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> {
queued.add(invocation.getArgument(0));
return true;
});
return spawner.onAsyncTick();
}
});
}
private Runnable nextTask() throws InterruptedException {
Runnable task = queued.poll(5L, TimeUnit.SECONDS);
assertNotNull("Owner callback was not scheduled", task);
return task;
}
@Override
public void close() throws Exception {
caller.shutdownNow();
assertTrue(caller.awaitTermination(2L, TimeUnit.SECONDS));
manager.close();
}
}
}
@@ -0,0 +1,215 @@
package art.arcane.iris.engine.platform;
import art.arcane.iris.engine.IrisEngine;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisDimensionRuntimeContract;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.object.StudioMode;
import art.arcane.iris.engine.platform.studio.StudioGenerator;
import art.arcane.volmlib.util.collection.KList;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class BukkitChunkGeneratorStudioSelectionTest {
@Test
public void concurrentInitialSelectionWaitsForPublishedGenerator() throws Exception {
Fixture fixture = new Fixture();
CountDownLatch injecting = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CountDownLatch secondAttempting = new CountDownLatch(1);
doAnswer(invocation -> {
injecting.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
fixture.generator.setStudioGenerator(fixture.selected);
return null;
}).when(fixture.mode).inject(fixture.generator);
ExecutorService workers = Executors.newFixedThreadPool(2);
try {
Future<StudioGenerator> first = workers.submit(() -> resolve(fixture.generator));
assertTrue(injecting.await(5L, TimeUnit.SECONDS));
Future<StudioGenerator> second = workers.submit(() -> {
secondAttempting.countDown();
return resolve(fixture.generator);
});
assertTrue(secondAttempting.await(5L, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> second.get(150L, TimeUnit.MILLISECONDS));
release.countDown();
assertSame(fixture.selected, first.get(5L, TimeUnit.SECONDS));
assertSame(fixture.selected, second.get(5L, TimeUnit.SECONDS));
verify(fixture.mode).inject(fixture.generator);
} finally {
release.countDown();
workers.shutdownNow();
assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS));
}
}
@Test
public void failedSelectionDoesNotPublishTheModeOrLoseThePreviousGenerator() throws Exception {
Fixture fixture = new Fixture();
StudioGenerator previous = mock(StudioGenerator.class);
fixture.generator.setStudioGenerator(previous);
IllegalStateException failure = new IllegalStateException("studio selection failed");
doAnswer(invocation -> {
throw failure;
}).doAnswer(invocation -> {
fixture.generator.setStudioGenerator(fixture.selected);
return null;
}).when(fixture.mode).inject(fixture.generator);
assertSame(failure, assertThrows(IllegalStateException.class, () -> resolve(fixture.generator)));
assertSame(StudioMode.NORMAL, fixture.generator.getLastMode());
assertSame(previous, fixture.generator.getStudioGenerator());
assertSame(fixture.selected, resolve(fixture.generator));
assertSame(fixture.mode, fixture.generator.getLastMode());
verify(fixture.mode, times(2)).inject(fixture.generator);
}
@Test
public void initialWorldTouchSelectsStudioBeforePublishingSetup() throws Exception {
Fixture fixture = new Fixture();
doAnswer(invocation -> {
assertFalse(fixture.setup.get());
fixture.generator.setStudioGenerator(fixture.selected);
return null;
}).when(fixture.mode).inject(fixture.generator);
try (MockedStatic<BukkitChunkGenerator> methods = mockStatic(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
MockedConstruction<IrisEngine> engines = mockConstruction(IrisEngine.class,
(created, context) -> when(created.getDimension()).thenReturn(fixture.dimension))) {
methods.when(() -> BukkitChunkGenerator.shouldRunStudioHotload(true, false, false)).thenReturn(false);
fixture.generator.touch(mock(World.class));
assertTrue(fixture.setup.get());
assertSame(fixture.selected, fixture.generator.getStudioGenerator());
assertSame(fixture.mode, fixture.generator.getLastMode());
assertEquals(1, engines.constructed().size());
verify(fixture.mode).inject(fixture.generator);
}
}
@Test
public void failedInitialSelectionClosesItsEngineAndRejectsSubsequentTouches() throws Exception {
Fixture fixture = new Fixture();
IllegalStateException failure = new IllegalStateException("initial studio selection failed");
IllegalStateException closeFailure = new IllegalStateException("engine close failed");
doAnswer(invocation -> {
throw failure;
}).when(fixture.mode).inject(fixture.generator);
try (MockedStatic<BukkitChunkGenerator> methods = mockStatic(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
MockedConstruction<IrisEngine> engines = mockConstruction(IrisEngine.class, (created, context) -> {
when(created.getDimension()).thenReturn(fixture.dimension);
doAnswer(invocation -> {
throw closeFailure;
}).when(created).close();
})) {
methods.when(() -> BukkitChunkGenerator.shouldRunStudioHotload(true, false, false)).thenReturn(false);
assertSame(failure, assertThrows(IllegalStateException.class,
() -> fixture.generator.touch(mock(World.class))));
assertFalse(fixture.setup.get());
assertSame(failure, assertThrows(IllegalStateException.class,
() -> fixture.generator.touch(mock(World.class))));
assertEquals(1, engines.constructed().size());
assertEquals(1, failure.getSuppressed().length);
assertSame(closeFailure, failure.getSuppressed()[0]);
verify(engines.constructed().getFirst()).close();
}
}
@Test
public void productionTerrainIgnoresPackStudioMode() throws Exception {
Fixture fixture = new Fixture();
field(fixture.generator, "studio", false);
assertNull(resolve(fixture.generator));
assertSame(StudioMode.NORMAL, fixture.generator.getLastMode());
verify(fixture.mode, never()).inject(fixture.generator);
}
private static StudioGenerator resolve(BukkitChunkGenerator generator) throws Exception {
Method method = BukkitChunkGenerator.class.getDeclaredMethod("computeStudioGenerator");
method.setAccessible(true);
try {
method.invoke(generator);
return generator.getStudioGenerator();
} catch (InvocationTargetException failure) {
if (failure.getCause() instanceof RuntimeException runtimeFailure) {
throw runtimeFailure;
}
throw failure;
}
}
private static void field(BukkitChunkGenerator generator, String name, Object value) throws Exception {
Field field = BukkitChunkGenerator.class.getDeclaredField(name);
field.setAccessible(true);
field.set(generator, value);
}
private static final class Fixture {
private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
private final IrisDimension dimension = mock(IrisDimension.class);
private final StudioMode mode = mock(StudioMode.class);
private final StudioGenerator selected = mock(StudioGenerator.class);
private final AtomicBoolean setup = new AtomicBoolean();
private Fixture() throws Exception {
Engine engine = mock(Engine.class);
EngineTarget target = mock(EngineTarget.class);
IrisWorld world = mock(IrisWorld.class);
when(world.hasPlatformWorld()).thenReturn(true);
when(engine.getDimension()).thenReturn(dimension);
when(target.getDimension()).thenReturn(dimension);
when(dimension.getLoadKey()).thenReturn("studio-selection-test");
when(dimension.getStudioMode()).thenReturn(mode);
doReturn(target).when(generator).getTarget();
field(generator, "studio", true);
field(generator, "world", world);
field(generator, "setup", setup);
field(generator, "lock", new ReentrantLock());
field(generator, "targetCache", new AtomicCache<EngineTarget>());
field(generator, "populators", new KList<>());
field(generator, "studioEntryBootstrapActive", new AtomicBoolean(true));
field(generator, "validatedDimension", dimension);
field(generator, "validatedWorldContract", new IrisDimensionRuntimeContract("iris:test", 0, 256, 256));
generator.setEngine(engine);
generator.setLastMode(StudioMode.NORMAL);
}
}
}
@@ -1,10 +1,18 @@
package art.arcane.iris.engine.platform;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisEngineMantle;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineMetrics;
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.util.project.matter.TileWrapper;
import art.arcane.iris.util.project.matter.slices.PreObjectMatterTest;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
@@ -22,10 +30,13 @@ import org.mockito.InOrder;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -35,10 +46,129 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class EngineBukkitOpsDeferredMaterializationTest {
@Test(timeout = 5000)
public void loadingRetryPreservesPendingUpdatesWithoutRepeatingCompletedUpdates() {
MantleChunk<Matter> mantleChunk = pendingUpdates();
IrisWorld irisWorld = mock(IrisWorld.class);
when(irisWorld.minHeight()).thenReturn(-64);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(irisWorld);
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
Chunk chunk = mock(Chunk.class);
AtomicInteger tileRuns = new AtomicInteger();
AtomicInteger customRuns = new AtomicInteger();
AtomicInteger attempts = new AtomicInteger();
List<Integer> completed = new ArrayList<>();
SavedBiomeUnavailableException loading = new SavedBiomeUnavailableException("Loading", true);
Runnable updates = () -> EngineBukkitOps.materializeUpdates(engine, chunk, mantleChunk, (x, y, z) -> {
if (y == -43 && attempts.incrementAndGet() <= 2) {
throw loading;
}
completed.add(y);
});
Runnable pass = () -> EngineBukkitOps.runMaterializationPasses(mantleChunk,
tileRuns::incrementAndGet, customRuns::incrementAndGet, updates);
for (int retry = 0; retry < 2; retry++) {
assertSame(loading, assertThrows(SavedBiomeUnavailableException.class, pass::run));
assertEquals(List.of(-59), completed);
assertNull(mantleChunk.get(0).getSlice(MatterUpdate.class).get(3, 5, 7));
assertTrue(mantleChunk.get(1).getSlice(MatterUpdate.class).get(3, 5, 7).isUpdate());
assertTrue(mantleChunk.get(2).getSlice(MatterUpdate.class).get(3, 5, 7).isUpdate());
assertTrue(mantleChunk.isFlagged(MantleFlag.TILE));
assertTrue(mantleChunk.isFlagged(MantleFlag.CUSTOM));
assertFalse(mantleChunk.isFlagged(MantleFlag.UPDATE));
assertFalse(mantleChunk.isFlagged(MantleFlag.ETCHED));
}
pass.run();
assertEquals(List.of(-59, -43, -27), completed);
assertEquals(1, tileRuns.get());
assertEquals(1, customRuns.get());
assertTrue(mantleChunk.isFlagged(MantleFlag.UPDATE));
assertTrue(mantleChunk.isFlagged(MantleFlag.ETCHED));
for (int section = 0; section < 3; section++) {
assertFalse(mantleChunk.get(section).hasSlice(MatterUpdate.class));
}
}
@Test
public void permanentAndUnrelatedFailuresKeepTheirCauseAndPendingMarkers() {
for (RuntimeException failure : List.of(
new SavedBiomeUnavailableException("Saved biome is missing", false),
new IllegalStateException("Broken loot"))) {
MantleChunk<Matter> mantleChunk = pendingUpdates();
IrisWorld irisWorld = mock(IrisWorld.class);
when(irisWorld.minHeight()).thenReturn(-64);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(irisWorld);
List<Integer> completed = new ArrayList<>();
Runnable updates = () -> EngineBukkitOps.materializeUpdates(engine, mock(Chunk.class), mantleChunk,
(x, y, z) -> {
if (y == -43) {
throw failure;
}
completed.add(y);
});
assertSame(failure, assertThrows(RuntimeException.class,
() -> EngineBukkitOps.runMaterializationPasses(mantleChunk, () -> {}, () -> {}, updates)));
assertEquals(List.of(-59), completed);
assertNull(mantleChunk.get(0).getSlice(MatterUpdate.class).get(3, 5, 7));
assertSame(UpdateMatter.ON, mantleChunk.get(1).getSlice(MatterUpdate.class).get(3, 5, 7));
assertSame(UpdateMatter.ON, mantleChunk.get(2).getSlice(MatterUpdate.class).get(3, 5, 7));
assertTrue(mantleChunk.isFlagged(MantleFlag.TILE));
assertTrue(mantleChunk.isFlagged(MantleFlag.CUSTOM));
assertFalse(mantleChunk.isFlagged(MantleFlag.UPDATE));
assertFalse(mantleChunk.isFlagged(MantleFlag.ETCHED));
}
}
@Test
public void cleanupFailurePreservesLoadingCauseAndUnfinishedFlags() {
MantleChunk<Matter> mantleChunk = spy(pendingUpdates());
SavedBiomeUnavailableException loading = new SavedBiomeUnavailableException("Loading", true);
IllegalStateException cleanup = new IllegalStateException("Marker cleanup failed");
AtomicBoolean failed = new AtomicBoolean();
doAnswer(invocation -> {
if (failed.get()) {
throw cleanup;
}
return invocation.callRealMethod();
}).when(mantleChunk).get(0);
IrisWorld irisWorld = mock(IrisWorld.class);
when(irisWorld.minHeight()).thenReturn(-64);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(irisWorld);
List<Integer> completed = new ArrayList<>();
Runnable updates = () -> EngineBukkitOps.materializeUpdates(engine, mock(Chunk.class), mantleChunk,
(x, y, z) -> {
if (y == -43) {
failed.set(true);
throw loading;
}
completed.add(y);
});
assertSame(loading, assertThrows(SavedBiomeUnavailableException.class,
() -> EngineBukkitOps.runMaterializationPasses(mantleChunk, () -> {}, () -> {}, updates)));
assertEquals(List.of(-59), completed);
assertEquals(1, loading.getSuppressed().length);
assertSame(cleanup, loading.getSuppressed()[0]);
assertTrue(mantleChunk.isFlagged(MantleFlag.TILE));
assertTrue(mantleChunk.isFlagged(MantleFlag.CUSTOM));
assertFalse(mantleChunk.isFlagged(MantleFlag.UPDATE));
assertFalse(mantleChunk.isFlagged(MantleFlag.ETCHED));
}
@Test
@SuppressWarnings("unchecked")
public void updateMarkerUsesWorldHeightAndDeletesSliceAfterDispatch() {
@@ -167,4 +297,28 @@ public class EngineBukkitOpsDeferredMaterializationTest {
verify(mantleChunk, never()).deleteSlices(Identifier.class);
}
private static MantleChunk<Matter> pendingUpdates() {
PreObjectMatterTest.setUpBukkit();
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatform platform = mock(IrisPlatform.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.block("AIR")).thenReturn(mock(PlatformBlockState.class));
when(platform.registries()).thenReturn(registries);
IrisPlatforms.unbind();
IrisPlatforms.bind(platform);
try {
MantleChunk<Matter> chunk = new MantleChunk<>(4, 2, -1,
IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class)), MantleHooks.NONE);
for (int section = 0; section < 3; section++) {
chunk.getOrCreate(section).slice(MatterUpdate.class).set(3, 5, 7, UpdateMatter.ON);
}
return chunk;
} finally {
IrisPlatforms.unbind();
if (previous != null) {
IrisPlatforms.bind(previous);
}
}
}
}
@@ -0,0 +1,298 @@
package art.arcane.iris.engine.platform.studio.generators;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.runtime.ObjectStudioLayout;
import art.arcane.iris.core.service.ObjectStudioSaveService;
import art.arcane.iris.engine.data.chunk.TerrainChunk;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.framework.GenerationSessionLease;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.math.IrisBlockVector;
import art.arcane.iris.util.project.matter.slices.PreObjectMatterTest;
import art.arcane.volmlib.util.collection.KMap;
import io.papermc.paper.InternalAPIBridge;
import io.papermc.paper.registry.RegistryAccess;
import net.kyori.adventure.key.Key;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.block.Biome;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.lang.reflect.Constructor;
import java.lang.reflect.Proxy;
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;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class ObjectStudioGeometryTest {
@Rule
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) {
Registry<Biome> biomes = mock(Registry.class, lookup -> {
if (lookup.getMethod().getName().equals("getOrThrow")) {
Key key = lookup.getArgument(0);
return biome(key);
}
return RETURNS_DEFAULTS.answer(lookup);
});
registered.set(biomes);
}
return registered.get();
}
return RETURNS_DEFAULTS.answer(invocation);
});
InternalAPIBridge bridge = mock(InternalAPIBridge.class, invocation -> {
if (invocation.getMethod().getName().equals("constructLegacyCustomBiome")) {
return biome(Key.key("minecraft:custom"));
}
return RETURNS_DEFAULTS.answer(invocation);
});
try (MockedStatic<RegistryAccess> registries = mockStatic(RegistryAccess.class);
MockedStatic<InternalAPIBridge> internals = mockStatic(InternalAPIBridge.class)) {
registries.when(RegistryAccess::registryAccess).thenReturn(access);
internals.when(InternalAPIBridge::get).thenReturn(bridge);
Class.forName(ObjectStudioGenerator.class.getName(), true, ObjectStudioGenerator.class.getClassLoader());
}
}
@Test
public void jungleClutterSignedBoundsStayInsideTheEditableCellAboveTheFloor() throws Exception {
IrisObject source = filled(5, 3, 4, new Position(-3, -2, -2), new Position(1, -1, 1));
Fixture fixture = fixture(source, 64, new Position(14, 65, 14));
Map<Position, PlatformBlockState> rendered = render(fixture);
assertPlacement(fixture, rendered, new Position(-3, -2, -2));
assertEquals(40, source.getBlocks().size());
assertTrue(rendered.containsKey(new Position(14, 65, 14)));
assertTrue(rendered.containsKey(new Position(18, 66, 17)));
}
@Test
public void oddEvenAndNegativeChunkPlacementPreservesSignedBlocksAndTileBytes() throws Exception {
int[][] dimensions = {{5, 3, 4}, {4, 4, 6}, {1, 1, 1}};
for (int[] size : dimensions) {
Position minimum = new Position(-3, -2, -4);
IrisObject source = filled(size[0], size[1], size[2], minimum,
new Position(minimum.x() + size[0] - 1, minimum.y() + size[1] - 1,
minimum.z() + size[2] - 1));
KMap<String, Object> properties = new KMap<>();
properties.put("CustomName", "Stored tile");
TileData tile = new TileData("minecraft:chest", properties);
source.getStates().put(new IrisBlockVector(minimum.x(), minimum.y(), minimum.z()), tile);
Fixture fixture = fixture(source, 64, new Position(-18, 65, -17));
Map<Position, PlatformBlockState> rendered = render(fixture);
assertPlacement(fixture, rendered, minimum);
IrisObject captured = fixture.generator().createCapture(fixture.cell());
for (Map.Entry<Position, PlatformBlockState> entry : rendered.entrySet()) {
Position position = entry.getKey();
captured.setUnsigned(position.x() - fixture.cell().originX(),
position.y() - fixture.cell().originY(), position.z() - fixture.cell().originZ(),
entry.getValue());
}
captured.setUnsignedTile(0, 0, 0, tile);
assertSame(tile, captured.getStates().get(new IrisBlockVector(minimum.x(), minimum.y(), minimum.z())));
assertEquals(serialized(source), serialized(captured));
}
}
@Test
public void highWorldFloorRaisesBothObjectAndCaptureCell() throws Exception {
IrisObject source = filled(5, 3, 4, new Position(-3, -2, -2), new Position(1, -1, 1));
Fixture fixture = fixture(source, 160, new Position(2, 65, 2));
Map<Position, PlatformBlockState> rendered = render(fixture);
assertEquals(161, fixture.cell().originY());
assertPlacement(fixture, rendered, new Position(-3, -2, -2));
assertTrue(rendered.containsKey(new Position(2, 161, 2)));
}
private static IrisObject filled(int width, int height, int depth, Position minimum, Position maximum) {
PlatformBlockState block = mock(PlatformBlockState.class);
when(block.key()).thenReturn("minecraft:oak_leaves[persistent=true]");
IrisObject object = new IrisObject(width, height, depth);
for (int x = minimum.x(); x <= maximum.x(); x++) {
for (int y = minimum.y(); y <= maximum.y(); y++) {
for (int z = minimum.z(); z <= maximum.z(); z++) {
object.getBlocks().put(new IrisBlockVector(x, y, z), block);
}
}
}
return object;
}
@SuppressWarnings("unchecked")
private Fixture fixture(IrisObject source, int minHeight, Position origin) throws Exception {
File worldFolder = temporary.newFolder();
File sourceFolder = temporary.newFolder();
File objectFile = new File(sourceFolder, "jungleclutt6.iob");
try (DataOutputStream header = new DataOutputStream(Files.newOutputStream(objectFile.toPath()))) {
header.writeInt(source.getW());
header.writeInt(source.getH());
header.writeInt(source.getD());
}
IrisData data = mock(IrisData.class);
ResourceLoader<IrisObject> loader = mock(ResourceLoader.class);
when(data.getDataFolder()).thenReturn(sourceFolder);
when(data.getObjectLoader()).thenReturn(loader);
when(loader.getPossibleKeys()).thenReturn(new String[]{"jungleclutt6"});
when(loader.findFile("jungleclutt6")).thenReturn(objectFile);
when(loader.load("jungleclutt6")).thenReturn(source);
ObjectStudioLayout.GridCell cell = new ObjectStudioLayout.GridCell(sourceFolder.getName(), "jungleclutt6",
origin.x(), origin.y(), origin.z(), source.getW(), source.getH(), source.getD());
Constructor<ObjectStudioLayout> constructor = ObjectStudioLayout.class.getDeclaredConstructor(
int.class, int.class, List.class);
constructor.setAccessible(true);
constructor.newInstance(2, 160, List.of(cell)).save(new File(worldFolder, ".iris/object-studio-layout.json"));
Engine engine = mock(Engine.class);
EngineTarget target = mock(EngineTarget.class);
IrisWorld world = mock(IrisWorld.class);
when(world.worldFolder()).thenReturn(worldFolder);
when(target.getWorld()).thenReturn(world);
when(engine.getTarget()).thenReturn(target);
when(engine.getData()).thenReturn(data);
when(engine.getMinHeight()).thenReturn(minHeight);
when(engine.getMaxHeight()).thenReturn(320);
when(engine.acquireGenerationLease(anyString())).thenAnswer(invocation -> GenerationSessionLease.noop());
ObjectStudioGenerator generator = new ObjectStudioGenerator(engine, 2,
mock(PlatformBlockState.class), mock(PlatformBlockState.class), mock(PlatformBlockState.class));
TerrainChunk initial = mock(TerrainChunk.class);
when(initial.getMaxHeight()).thenReturn(320);
try (MockedStatic<ObjectStudioSaveService> saves = mockStatic(ObjectStudioSaveService.class)) {
saves.when(ObjectStudioSaveService::get).thenReturn(mock(ObjectStudioSaveService.class));
generator.generateChunk(engine, initial, cell.chunkMinX(), cell.chunkMinZ());
}
ObjectStudioLayout.GridCell placed = generator.getLayout().get("jungleclutt6");
assertNotNull(placed);
return new Fixture(generator, engine, placed, source);
}
private static Map<Position, PlatformBlockState> render(Fixture fixture) throws Exception {
Map<Position, PlatformBlockState> rendered = new HashMap<>();
PlatformBlockState objectBlock = fixture.source().getBlocks().values().iterator().next();
for (int chunkX = fixture.cell().chunkMinX(); chunkX <= fixture.cell().chunkMaxX(); chunkX++) {
for (int chunkZ = fixture.cell().chunkMinZ(); chunkZ <= fixture.cell().chunkMaxZ(); chunkZ++) {
int originX = chunkX << 4;
int originZ = chunkZ << 4;
TerrainChunk chunk = mock(TerrainChunk.class);
when(chunk.getMaxHeight()).thenReturn(320);
doAnswer(invocation -> {
int x = invocation.getArgument(0);
int y = invocation.getArgument(1);
int z = invocation.getArgument(2);
assertTrue(x >= 0 && x < 16 && z >= 0 && z < 16);
PlatformBlockState block = invocation.getArgument(3);
if (block == objectBlock) {
rendered.put(new Position(originX + x, y, originZ + z), block);
}
return null;
}).when(chunk).setBlock(anyInt(), anyInt(), anyInt(), any());
fixture.generator().generateChunk(fixture.engine(), chunk, chunkX, chunkZ);
}
}
return rendered;
}
private static void assertPlacement(Fixture fixture, Map<Position, PlatformBlockState> rendered, Position minimum) {
assertEquals(fixture.source().getBlocks().size(), rendered.size());
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : fixture.source().getBlocks()) {
IrisBlockVector signed = entry.getKey();
Position expected = new Position(fixture.cell().originX() + signed.getBlockX() - minimum.x(),
fixture.cell().originY() + signed.getBlockY() - minimum.y(),
fixture.cell().originZ() + signed.getBlockZ() - minimum.z());
assertSame(entry.getValue(), rendered.get(expected));
}
}
private static SerializedObject serialized(IrisObject object) throws Exception {
ByteArrayOutputStream output = new ByteArrayOutputStream();
object.write(output);
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(output.toByteArray()))) {
int width = input.readInt();
int height = input.readInt();
int depth = input.readInt();
assertEquals("Iris V2 IOB;", input.readUTF());
String[] palette = new String[input.readShort()];
for (int index = 0; index < palette.length; index++) {
palette[index] = input.readUTF();
}
int blockCount = input.readInt();
Map<Position, String> blocks = new HashMap<>();
for (int index = 0; index < blockCount; index++) {
Position position = new Position(input.readShort(), input.readShort(), input.readShort());
blocks.put(position, palette[input.readShort()]);
}
int tileCount = input.readInt();
Map<Position, TileData> tiles = new HashMap<>();
for (int index = 0; index < tileCount; index++) {
Position position = new Position(input.readShort(), input.readShort(), input.readShort());
tiles.put(position, TileData.read(input));
}
assertEquals(-1, input.read());
return new SerializedObject(width, height, depth, blocks, tiles);
}
}
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;
});
}
private record Position(int x, int y, int z) {
}
private record Fixture(ObjectStudioGenerator generator, Engine engine, ObjectStudioLayout.GridCell cell,
IrisObject source) {
}
private record SerializedObject(int width, int height, int depth, Map<Position, String> blocks,
Map<Position, TileData> tiles) {
}
}