diff --git a/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioLayout.java b/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioLayout.java index f3669c505..6fe7ff778 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioLayout.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/ObjectStudioLayout.java @@ -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 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 cells() { return cells; } diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java index ea1c84397..f40fe1426 100644 --- a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java @@ -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 bounds = new ArrayList<>(pieces.size()); + List 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 bounds + List bounds ) { AssemblyPreview { Objects.requireNonNull(worldId, "Jigsaw Studio preview world"); diff --git a/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java index f91200350..d741f4f5e 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java +++ b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java @@ -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 objectsDirs; final String packKey; final Map hashes = new ConcurrentHashMap<>(); - ActiveStudio(UUID worldId, ObjectStudioLayout layout, Map objectsDirs, String packKey) { + ActiveStudio(UUID worldId, ObjectStudioGenerator generator, Map objectsDirs, String packKey) { this.worldId = worldId; - this.layout = layout; + this.generator = generator; + this.layout = generator.getLayout(); this.objectsDirs = objectsDirs; this.packKey = packKey; } diff --git a/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java b/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java index 79de44fa0..31b6a779f 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldChunkMaintenance.java @@ -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()) { diff --git a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java index 04f7dc12a..9b096b5de 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java @@ -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 future = new CompletableFuture<>(); try { World realWorld = BukkitWorldBinding.world(manager.getEngine().getWorld()); if (realWorld == null) { manager.entityCount = 0; manager.entityCountValid = false; } else { - CompletableFuture 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 future = new CompletableFuture<>(); + CompletableFuture 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 Runnable managedCompletion(String operation, CompletableFuture future, Supplier 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) { diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 36f45bad0..889e62227 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -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 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 diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index a1fe52aa7..a10ba2f3c 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -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.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()); } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java index e2cd6b87c..64d1038f7 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java @@ -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 objectCache = new ConcurrentHashMap<>(); + private final Map objectCache = new ConcurrentHashMap<>(); private final Map 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 blocks = object.getBlocks(); + private void placeSlice(DisplayObject object, GridCell cell, TerrainChunk tc, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) { + VectorMap 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 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 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 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); + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/runtime/ObjectStudioLayoutTest.java b/core/src/test/java/art/arcane/iris/core/runtime/ObjectStudioLayoutTest.java new file mode 100644 index 000000000..2deb89fd3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/ObjectStudioLayoutTest.java @@ -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 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 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 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 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); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioVisualizationBoundsTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioVisualizationBoundsTest.java new file mode 100644 index 000000000..d6f97d0b0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioVisualizationBoundsTest.java @@ -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 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 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 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 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 map(JigsawStudioService service, String name) throws Exception { + Field field = JigsawStudioService.class.getDeclaredField(name); + field.setAccessible(true); + return (Map) 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 emissions(Player player) { + List 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) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceSavedBiomeTest.java b/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceSavedBiomeTest.java new file mode 100644 index 000000000..74ba1191a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/WorldChunkMaintenanceSavedBiomeTest.java @@ -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 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 configured = mockStatic(IrisSettings.class); + MockedStatic 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); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/WorldEntitySpawnerLifecycleTest.java b/core/src/test/java/art/arcane/iris/engine/WorldEntitySpawnerLifecycleTest.java new file mode 100644 index 000000000..46ddab962 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/WorldEntitySpawnerLifecycleTest.java @@ -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 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 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 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 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 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 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 waiting = fixture.spawn(false, true); + Runnable task = fixture.nextTask(); + + try (MockedStatic 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 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 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 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 queued = new LinkedBlockingQueue<>(); + private final AtomicBoolean interrupted = new AtomicBoolean(); + private final AtomicReference 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 spawn(boolean interrupt, boolean accepted) { + return caller.submit(() -> { + callerThread.set(Thread.currentThread()); + try (MockedStatic 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 count() { + return caller.submit(() -> { + IrisSettings settings = new IrisSettings(); + settings.getWorld().setTargetSpawnEntitiesPerChunk(0.0D); + try (MockedStatic scheduler = mockStatic(J.class); + MockedStatic bindings = mockStatic(BukkitWorldBinding.class); + MockedStatic toolbelt = mockStatic(IrisToolbelt.class); + MockedStatic pregenerator = mockStatic(PregeneratorJob.class); + MockedStatic configured = mockStatic(IrisSettings.class); + MockedStatic 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(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStudioSelectionTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStudioSelectionTest.java new file mode 100644 index 000000000..9296d87ba --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorStudioSelectionTest.java @@ -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 first = workers.submit(() -> resolve(fixture.generator)); + assertTrue(injecting.await(5L, TimeUnit.SECONDS)); + Future 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 methods = mockStatic(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + MockedConstruction 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 methods = mockStatic(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + MockedConstruction 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()); + 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); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java index f29b7d741..d3e58ed2d 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java @@ -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 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 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 mantleChunk = pendingUpdates(); + IrisWorld irisWorld = mock(IrisWorld.class); + when(irisWorld.minHeight()).thenReturn(-64); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(irisWorld); + List 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 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 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 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 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); + } + } + } } diff --git a/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGeometryTest.java b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGeometryTest.java new file mode 100644 index 000000000..f6daf7696 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGeometryTest.java @@ -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> registered = new AtomicReference<>(); + RegistryAccess access = mock(RegistryAccess.class, invocation -> { + if (invocation.getMethod().getName().equals("getRegistry")) { + if (registered.get() == null) { + Registry 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 registries = mockStatic(RegistryAccess.class); + MockedStatic 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 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 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 rendered = render(fixture); + assertPlacement(fixture, rendered, minimum); + + IrisObject captured = fixture.generator().createCapture(fixture.cell()); + for (Map.Entry 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 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 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 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 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 render(Fixture fixture) throws Exception { + Map 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 rendered, Position minimum) { + assertEquals(fixture.source().getBlocks().size(), rendered.size()); + for (Map.Entry 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 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 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 blocks, + Map tiles) { + } +}