From 4de1aea8b9a2326ecd0a3068dd9cbcb126d73a50 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 8 Sep 2026 19:22:55 -0400 Subject: [PATCH] CrantEngine Tweak and a bit more. - Fixed CE support - Added some checks for things like paint and so on in the 3d Generator. --- .../core/nms/v26_2_R1/IrisChunkGenerator.java | 12 +- .../nms/v26_2_R1/NmsSavedTerrainCapture.java | 93 ++++- .../IrisChunkGeneratorSpawnAdmissionTest.java | 144 +++++++ .../NmsSavedTerrainCheckpointTest.java | 114 ++++++ .../NmsSavedTerrainWriteBarrierTest.java | 135 +++++++ .../NativeStructureVolumeIndexTest.java | 85 ++++ .../iris/core/commands/CommandObject.java | 15 +- .../src/main/resources/paper-plugin.yml | 8 +- .../plugin/src/main/resources/plugin.yml | 4 - .../arcane/iris/PaperPluginMetadataTest.java | 6 +- ...CommandObjectCustomBlockPlacementTest.java | 80 ++++ .../nativegen/NativeStructureVolumeIndex.java | 50 ++- core/build.gradle | 12 + .../data/CraftEngineDataProviderTest.java | 371 ++++++++++++++++++ .../iris/core/link/ExternalDataProvider.java | 9 + .../link/data/CraftEngineDataProvider.java | 308 ++++++++++----- .../arcane/iris/core/service/BoardSVC.java | 8 +- .../iris/core/service/ExternalDataSVC.java | 23 ++ .../arcane/iris/core/service/ObjectSVC.java | 21 +- .../iris/engine/EngineRuntimeBuilder.java | 8 +- .../art/arcane/iris/engine/IrisEngine.java | 7 + .../arcane/iris/engine/framework/Engine.java | 4 + .../framework/EngineAssignedWorldManager.java | 9 +- .../history/GenerationBoundaryStore.java | 24 +- .../TerrainBoundarySignatureStore.java | 90 +++-- .../history/TransitionBoundarySampler.java | 84 ++-- .../arcane/iris/engine/object/IrisObject.java | 34 +- .../iris/engine/object/IrisObjectIO.java | 21 +- .../engine/object/IrisObjectRotation.java | 19 +- .../engine/platform/BukkitChunkGenerator.java | 72 ++-- .../platform/bukkit/BukkitBlockState.java | 105 ++++- .../core/service/BoardSVCSavedBiomeTest.java | 73 ++++ .../ExternalDataSVCBlockRoutingTest.java | 38 ++ .../service/ObjectSVCUndoCustomBlockTest.java | 67 ++++ ...EngineRuntimeBuilderStudioServiceTest.java | 287 ++++++++++++++ .../engine/IrisEngineLifecycleGateTest.java | 30 ++ ...ngineAssignedWorldManagerTeleportTest.java | 96 ++++- .../BoundarySamplingConcurrencyTest.java | 224 +++++++++++ .../history/GenerationBoundaryStoreTest.java | 70 ++++ .../object/IrisObjectDirectPlacementTest.java | 72 ++++ .../object/IrisObjectTransientBlockTest.java | 158 ++++++++ ...ChunkGeneratorGenerationStageGateTest.java | 154 +++++++- .../BukkitCustomBlockTransformTest.java | 159 ++++++++ gradle/libs.versions.toml | 2 +- 44 files changed, 3161 insertions(+), 244 deletions(-) create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorSpawnAdmissionTest.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCheckpointTest.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainWriteBarrierTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectCustomBlockPlacementTest.java create mode 100644 core/src/externalProviderTest/java/art/arcane/iris/core/link/data/CraftEngineDataProviderTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/ObjectSVCUndoCustomBlockTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/EngineRuntimeBuilderStudioServiceTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/BoundarySamplingConcurrencyTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectDirectPlacementTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransientBlockTest.java create mode 100644 core/src/test/java/art/arcane/iris/platform/bukkit/BukkitCustomBlockTransformTest.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 014aa6196..ff518b044 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -8,6 +8,7 @@ import java.util.function.LongPredicate; import art.arcane.iris.engine.DimensionStackContext; import art.arcane.iris.engine.DimensionStackLayout; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineLifecycleTasks; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.IrisStructureLocator; @@ -930,6 +931,13 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred @Override public WeightedList getMobsAt(Holder holder, StructureManager structuremanager, MobCategory enumcreaturetype, BlockPos blockposition) { + return EngineLifecycleTasks.call(engine, "bukkit_nms_mob_spawns", + () -> getAdmittedMobsAt(holder, structuremanager, enumcreaturetype, blockposition), + WeightedList.of(List.of())); + } + + private WeightedList getAdmittedMobsAt( + Holder holder, StructureManager structuremanager, MobCategory enumcreaturetype, BlockPos blockposition) { NativeBiomeSpawnSelection selection = NativeBiomeSpawnSelection.at( engine, blockposition.getX(), blockposition.getY(), blockposition.getZ(), holder.unwrapKey().map(key -> key.identifier().toString()).orElse("")); @@ -937,9 +945,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred return WeightedList.of(List.of()); } try (GenerationHistoryRuntimeRouter.CoordinateScope route = openHistoryCoordinateScope( - blockposition.getX(), blockposition.getZ(), "bukkit_nms_mob_spawns"); - GenerationSessionLease lease = requireGenerationLease("bukkit_nms_mob_spawns"); - IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + blockposition.getX(), blockposition.getZ(), "bukkit_nms_mob_spawns")) { return getMobsAtWithActiveRuntime(holder, structuremanager, enumcreaturetype, blockposition, selection); } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCapture.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCapture.java index 33c08922b..4a874481d 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCapture.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCapture.java @@ -3,13 +3,16 @@ package art.arcane.iris.core.nms.v26_2_R1; import art.arcane.iris.engine.history.NativeTerrainReceipt; import art.arcane.iris.engine.history.SavedTerrainChunk; import art.arcane.iris.util.common.scheduling.J; +import ca.spottedleaf.concurrentutil.util.Priority; import ca.spottedleaf.moonrise.common.PlatformHooks; import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; +import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler; import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.NewChunkHolder; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtIo; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.storage.SerializableChunkData; import org.bukkit.World; @@ -18,12 +21,17 @@ import org.bukkit.craftbukkit.CraftWorld; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; final class NmsSavedTerrainCapture { + private static final long WRITE_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(30L); + private NmsSavedTerrainCapture() { } @@ -53,9 +61,12 @@ final class NmsSavedTerrainCapture { }); return snapshot.thenApplyAsync(data -> { try { + Checkpoint checkpoint = checkpoint(chunkX, chunkZ, data); + awaitWrite(level, checkpoint.chunk(), System.nanoTime() + WRITE_TIMEOUT_NANOS); if (data == null) { return SavedTerrainChunk.read(world.getWorldFolder().toPath(), chunkX, chunkZ, minimumY, height); } + verifyCheckpoint(world.getWorldFolder().toPath(), checkpoint); ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (DataOutputStream output = new DataOutputStream(bytes)) { NbtIo.write(data, output); @@ -77,17 +88,8 @@ final class NmsSavedTerrainCapture { J.runRegionFuture(world, holder.chunkX, holder.chunkZ, () -> { try { ChunkAccess chunk = holder.getCurrentChunk(); - if (chunk == null || !chunk.isUnsaved()) { - saved.complete(null); - return; - } - CompoundTag data = saveSnapshot(level, chunk); - byte[] receipt = data.getCompound("ChunkBukkitValues") - .flatMap(values -> values.getByteArray(NativeTerrainReceipt.NBT_KEY)).orElse(null); - saved.complete(new Checkpoint(holder.chunkX, holder.chunkZ, - BuiltInRegistries.CHUNK_STATUS.getKey(chunk.getPersistedStatus()).toString(), receipt, - data.getCompound("ChunkBukkitValues").map(values -> values.getLongOr( - NativeTerrainReceipt.STRUCTURE_ACTIVATION_KEY, 0)).orElse(0L))); + saved.complete(checkpoint(holder.chunkX, holder.chunkZ, + chunk != null && chunk.isUnsaved() ? saveSnapshot(level, chunk) : null)); } catch (Throwable failure) { saved.completeExceptionally(failure); } @@ -99,22 +101,70 @@ final class NmsSavedTerrainCapture { } return CompletableFuture.allOf(saves.toArray(CompletableFuture[]::new)).thenRunAsync(() -> { try { - MoonriseRegionFileIO.flush(level, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA); - MoonriseRegionFileIO.flushRegionStorages(level, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA); + List checkpoints = new ArrayList<>(saves.size()); for (CompletableFuture future : saves) { - Checkpoint saved = future.join(); - if (saved == null) { - continue; - } - SavedTerrainChunk.verifyCheckpoint(world.getWorldFolder().toPath(), saved.chunkX(), saved.chunkZ(), - saved.status(), saved.receipt(), saved.structureActivation()); + checkpoints.add(future.join()); } + finishCheckpoint(level, world.getWorldFolder().toPath(), checkpoints); } catch (IOException failure) { throw new CompletionException(failure); } }); } + static void finishCheckpoint(ServerLevel level, Path worldFolder, List checkpoints) throws IOException { + long deadline = System.nanoTime() + WRITE_TIMEOUT_NANOS; + for (Checkpoint checkpoint : checkpoints) { + awaitWrite(level, checkpoint.chunk(), deadline); + } + MoonriseRegionFileIO.flushRegionStorages(level, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA); + for (Checkpoint checkpoint : checkpoints) { + verifyCheckpoint(worldFolder, checkpoint); + } + } + + static Checkpoint checkpoint(int chunkX, int chunkZ, CompoundTag data) { + if (data == null) { + return new Checkpoint(new ChunkPos(chunkX, chunkZ), null); + } + byte[] receipt = data.getCompound("ChunkBukkitValues") + .flatMap(values -> values.getByteArray(NativeTerrainReceipt.NBT_KEY)).orElse(null); + long structureActivation = data.getCompound("ChunkBukkitValues").map(values -> values.getLongOr( + NativeTerrainReceipt.STRUCTURE_ACTIVATION_KEY, 0)).orElse(0L); + return new Checkpoint(new ChunkPos(chunkX, chunkZ), new Verification( + data.getStringOr("Status", ""), receipt, structureActivation)); + } + + private static void verifyCheckpoint(Path worldFolder, Checkpoint checkpoint) throws IOException { + Verification expected = checkpoint.verification(); + if (expected == null) { + return; + } + SavedTerrainChunk.verifyCheckpoint(worldFolder, checkpoint.chunk().x(), checkpoint.chunk().z(), + expected.status(), expected.receipt(), expected.structureActivation()); + } + + static void awaitWrite(ServerLevel level, ChunkPos chunk, long deadlineNanos) throws IOException { + ChunkTaskScheduler scheduler = level.moonrise$getChunkTaskScheduler(); + while (true) { + if (Thread.currentThread().isInterrupted()) { + throw new IOException("Interrupted while saving terrain checkpoint chunk " + chunk); + } + if (System.nanoTime() - deadlineNanos >= 0L) { + throw new IOException("Timed out saving terrain checkpoint chunk " + chunk); + } + if (MoonriseRegionFileIO.getPriority(level, chunk.x(), chunk.z(), + MoonriseRegionFileIO.RegionFileType.CHUNK_DATA) == Priority.COMPLETING) { + return; + } + boolean helped = scheduler.saveExecutor.executeTask(); + helped |= scheduler.compressionExecutor.executeTask(); + if (!helped) { + LockSupport.parkNanos(1_000_000L); + } + } + } + private static CompoundTag saveSnapshot(ServerLevel level, ChunkAccess chunk) { SerializableChunkData snapshot = SerializableChunkData.copyOf(level, chunk); PlatformHooks.get().chunkSyncSave(level, chunk, snapshot); @@ -124,6 +174,9 @@ final class NmsSavedTerrainCapture { return data; } - private record Checkpoint(int chunkX, int chunkZ, String status, byte[] receipt, long structureActivation) { + record Checkpoint(ChunkPos chunk, Verification verification) { + } + + record Verification(String status, byte[] receipt, long structureActivation) { } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorSpawnAdmissionTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorSpawnAdmissionTest.java new file mode 100644 index 000000000..ca3cff99e --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorSpawnAdmissionTest.java @@ -0,0 +1,144 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.engine.framework.GenerationTransitionGate; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.NativeBiomeSpawnSelection; +import art.arcane.iris.engine.history.SavedBiomeRuntime; +import art.arcane.iris.util.project.context.IrisContext; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.random.WeightedList; +import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.MobSpawnSettings; +import net.minecraft.world.level.chunk.ChunkGenerator; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisChunkGeneratorSpawnAdmissionTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void tickSpawnQueryLeavesOwnerThreadAvailableForBoundaryCheckpoint() throws Exception { + Fixture fixture = new Fixture(); + ExecutorService owner = Executors.newSingleThreadExecutor(); + ExecutorService reloader = Executors.newSingleThreadExecutor(); + AtomicInteger snapshots = new AtomicInteger(); + long previousSession = fixture.sessions.currentSessionId(); + try { + assertSame(fixture.expected, owner.submit(fixture::query).get(2L, TimeUnit.SECONDS)); + assertEquals(previousSession, fixture.readSession.get()); + CompletableFuture cutover = CompletableFuture.runAsync(() -> { + try (GenerationTransitionGate.Transition ignored = fixture.sessions.transitionGate().beginTransition(2_000L)) { + fixture.sessions.sealAndAwait("Studio generation cutover", 2_000L); + Future query = owner.submit(() -> { + assertTrue(fixture.query().isEmpty()); + assertNull(IrisContext.get()); + }); + owner.submit(snapshots::incrementAndGet).get(2L, TimeUnit.SECONDS); + query.get(2L, TimeUnit.SECONDS); + assertEquals(1, fixture.savedReads.get()); + assertEquals(1, fixture.routes.get()); + fixture.sessions.activateNextSession(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }, reloader); + + cutover.get(3L, TimeUnit.SECONDS); + assertEquals(1, snapshots.get()); + assertEquals(0, fixture.sessions.activeLeases()); + assertSame(fixture.expected, owner.submit(fixture::query).get(2L, TimeUnit.SECONDS)); + assertEquals(fixture.sessions.currentSessionId(), fixture.readSession.get()); + assertTrue(fixture.readSession.get() > previousSession); + assertEquals(2, fixture.savedReads.get()); + assertEquals(2, fixture.routes.get()); + assertEquals(0, fixture.sessions.activeLeases()); + } finally { + owner.shutdownNow(); + reloader.shutdownNow(); + assertTrue(owner.awaitTermination(2L, TimeUnit.SECONDS)); + assertTrue(reloader.awaitTermination(2L, TimeUnit.SECONDS)); + } + } + + private static void setField(IrisChunkGenerator generator, String name, Object value) throws Exception { + Field field = IrisChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + field.set(generator, value); + } + + private static final class Fixture { + private final GenerationSessionManager sessions = new GenerationSessionManager(true); + private final IrisEngine engine = mock(IrisEngine.class); + private final IrisChunkGenerator generator = mock(IrisChunkGenerator.class, CALLS_REAL_METHODS); + private final Holder biome = Holder.direct(mock(Biome.class)); + private final StructureManager structures = mock(StructureManager.class); + private final WeightedList expected = mock(WeightedList.class); + private final AtomicInteger savedReads = new AtomicInteger(); + private final AtomicInteger routes = new AtomicInteger(); + private final AtomicLong readSession = new AtomicLong(); + + private Fixture() throws Exception { + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + ChunkGenerator delegate = mock(ChunkGenerator.class); + CustomBiomeSource source = mock(CustomBiomeSource.class); + when(engine.getGenerationSessions()).thenReturn(sessions); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.biomes()).thenReturn(saved); + when(saved.nativeSpawnSelection(0, 64, 0, "")).thenAnswer(invocation -> { + assertAdmitted(); + savedReads.incrementAndGet(); + return new NativeBiomeSpawnSelection(NativeBiomeSpawnSelection.Mode.RETAINED, "minecraft:plains"); + }); + when(engine.openGenerationHistoryCoordinateScope(0, 0)).thenAnswer(invocation -> { + assertAdmitted(); + routes.incrementAndGet(); + return null; + }); + when(delegate.getMobsAt(biome, structures, MobCategory.MONSTER, new BlockPos(0, 64, 0))) + .thenReturn(expected); + setField(generator, "engine", engine); + setField(generator, "delegate", delegate); + setField(generator, "customBiomeSource", source); + } + + private WeightedList query() { + return generator.getMobsAt(biome, structures, MobCategory.MONSTER, new BlockPos(0, 64, 0)); + } + + private void assertAdmitted() { + assertEquals(1, sessions.activeLeases()); + assertSame(engine, IrisContext.get().getEngine()); + readSession.set(IrisContext.get().getGenerationSessionId()); + } + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCheckpointTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCheckpointTest.java new file mode 100644 index 000000000..359bcc3f9 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCheckpointTest.java @@ -0,0 +1,114 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import art.arcane.iris.engine.history.SavedTerrainChunk; +import ca.spottedleaf.concurrentutil.util.Priority; +import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; + +public class NmsSavedTerrainCheckpointTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void checkpointWaitsForDirtyAndUnloadedChunkWritesBeforeFlushAndVerification() throws Exception { + ServerLevel level = mock(ServerLevel.class); + Path root = Path.of("checkpoint-world"); + byte[] receipt = new byte[]{1, 2, 3}; + NmsSavedTerrainCapture.Checkpoint saved = new NmsSavedTerrainCapture.Checkpoint(new ChunkPos(4, -5), + new NmsSavedTerrainCapture.Verification("minecraft:full", receipt, 7L)); + NmsSavedTerrainCapture.Checkpoint unloaded = NmsSavedTerrainCapture.checkpoint(-8, 9, null); + Set completed = new HashSet<>(); + AtomicBoolean flushed = new AtomicBoolean(); + try (MockedStatic io = mockStatic(MoonriseRegionFileIO.class); + MockedStatic terrain = mockStatic(SavedTerrainChunk.class)) { + io.when(() -> MoonriseRegionFileIO.getPriority(level, 4, -5, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA)) + .thenAnswer(invocation -> { + completed.add(saved.chunk()); + return Priority.COMPLETING; + }); + io.when(() -> MoonriseRegionFileIO.getPriority(level, -8, 9, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA)) + .thenAnswer(invocation -> { + completed.add(unloaded.chunk()); + return Priority.COMPLETING; + }); + io.when(() -> MoonriseRegionFileIO.flushRegionStorages(level, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA)) + .thenAnswer(invocation -> { + assertEquals(Set.of(saved.chunk(), unloaded.chunk()), completed); + flushed.set(true); + return null; + }); + terrain.when(() -> SavedTerrainChunk.verifyCheckpoint(root, 4, -5, "minecraft:full", receipt, 7L)) + .thenAnswer(invocation -> { + assertTrue(flushed.get()); + return null; + }); + + NmsSavedTerrainCapture.finishCheckpoint(level, root, List.of(saved, unloaded)); + + terrain.verify(() -> SavedTerrainChunk.verifyCheckpoint(root, 4, -5, "minecraft:full", receipt, 7L)); + io.verify(() -> MoonriseRegionFileIO.flush(level, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA), never()); + io.verify(() -> MoonriseRegionFileIO.getPriority(level, 100, 100, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA), never()); + terrain.verifyNoMoreInteractions(); + } + } + + @Test + public void storageFlushFailureCannotCompleteTheCheckpoint() throws Exception { + ServerLevel level = mock(ServerLevel.class); + IOException failure = new IOException("storage flush failed"); + try (MockedStatic io = mockStatic(MoonriseRegionFileIO.class); + MockedStatic terrain = mockStatic(SavedTerrainChunk.class)) { + io.when(() -> MoonriseRegionFileIO.flushRegionStorages(level, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA)) + .thenThrow(failure); + + assertSame(failure, assertThrows(IOException.class, + () -> NmsSavedTerrainCapture.finishCheckpoint(level, Path.of("checkpoint-world"), List.of()))); + terrain.verifyNoInteractions(); + } + } + + @Test + public void persistedReceiptMismatchCannotCompleteTheCheckpoint() throws Exception { + ServerLevel level = mock(ServerLevel.class); + Path root = Path.of("checkpoint-world"); + byte[] receipt = new byte[]{4, 5}; + IOException failure = new IOException("saved terrain receipt mismatch"); + NmsSavedTerrainCapture.Checkpoint saved = new NmsSavedTerrainCapture.Checkpoint(new ChunkPos(1, 2), + new NmsSavedTerrainCapture.Verification("minecraft:noise", receipt, 3L)); + try (MockedStatic io = mockStatic(MoonriseRegionFileIO.class); + MockedStatic terrain = mockStatic(SavedTerrainChunk.class)) { + io.when(() -> MoonriseRegionFileIO.getPriority(level, 1, 2, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA)) + .thenReturn(Priority.COMPLETING); + terrain.when(() -> SavedTerrainChunk.verifyCheckpoint(root, 1, 2, "minecraft:noise", receipt, 3L)) + .thenThrow(failure); + + assertSame(failure, assertThrows(IOException.class, + () -> NmsSavedTerrainCapture.finishCheckpoint(level, root, List.of(saved)))); + io.verify(() -> MoonriseRegionFileIO.flushRegionStorages(level, MoonriseRegionFileIO.RegionFileType.CHUNK_DATA)); + } + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainWriteBarrierTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainWriteBarrierTest.java new file mode 100644 index 000000000..f22281a63 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainWriteBarrierTest.java @@ -0,0 +1,135 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import ca.spottedleaf.concurrentutil.executor.thread.BalancedPrioritisedThreadPool; +import ca.spottedleaf.concurrentutil.util.Priority; +import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; +import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class NmsSavedTerrainWriteBarrierTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void checkpointHelpsSaveAndCompressionWhileNativeGenerationOccupiesTheWorker() throws Exception { + BalancedPrioritisedThreadPool pool = new BalancedPrioritisedThreadPool( + TimeUnit.MILLISECONDS.toNanos(1), Executors.defaultThreadFactory()); + BalancedPrioritisedThreadPool.OrderedStreamGroup group = pool.createOrderedStreamGroup(); + BalancedPrioritisedThreadPool.OrderedStreamGroup.Queue generation = group.createExecutor(); + BalancedPrioritisedThreadPool.OrderedStreamGroup.Queue saves = group.createExecutor(); + BalancedPrioritisedThreadPool.OrderedStreamGroup.Queue compression = group.createExecutor(); + ServerLevel level = level(saves, compression); + ChunkPos chunk = new ChunkPos(4, -9); + CountDownLatch generationEntered = new CountDownLatch(1); + CountDownLatch releaseGeneration = new CountDownLatch(1); + AtomicBoolean pendingWrite = new AtomicBoolean(true); + AtomicInteger serialized = new AtomicInteger(); + AtomicInteger compressed = new AtomicInteger(); + AtomicReference saveThread = new AtomicReference<>(); + AtomicReference compressionThread = new AtomicReference<>(); + try (MockedStatic io = mockStatic(MoonriseRegionFileIO.class)) { + io.when(() -> MoonriseRegionFileIO.getPriority(level, chunk.x(), chunk.z(), + MoonriseRegionFileIO.RegionFileType.CHUNK_DATA)) + .thenAnswer(invocation -> pendingWrite.get() ? Priority.NORMAL : Priority.COMPLETING); + generation.queueTask(() -> { + generationEntered.countDown(); + try { + assertTrue(releaseGeneration.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new AssertionError(interruption); + } + }); + pool.adjustThreadCount(1); + assertTrue(generationEntered.await(5, TimeUnit.SECONDS)); + saves.queueTask(() -> { + saveThread.set(Thread.currentThread()); + serialized.incrementAndGet(); + compression.queueTask(() -> { + compressionThread.set(Thread.currentThread()); + compressed.incrementAndGet(); + pendingWrite.set(false); + }); + }); + + NmsSavedTerrainCapture.awaitWrite(level, chunk, System.nanoTime() + TimeUnit.SECONDS.toNanos(3)); + + assertEquals(1L, releaseGeneration.getCount()); + assertEquals(1, serialized.get()); + assertEquals(1, compressed.get()); + assertFalse(pendingWrite.get()); + assertSame(Thread.currentThread(), saveThread.get()); + assertSame(Thread.currentThread(), compressionThread.get()); + } finally { + releaseGeneration.countDown(); + pool.halt(true); + assertTrue(pool.joinInterruptable(5_000)); + } + } + + @Test + public void waitingForNativeWriteHonorsTheSharedDeadline() throws Exception { + ServerLevel level = mock(ServerLevel.class); + ChunkPos chunk = new ChunkPos(2, 3); + IOException failure = assertThrows(IOException.class, + () -> NmsSavedTerrainCapture.awaitWrite(level, chunk, System.nanoTime() - 1L)); + assertTrue(failure.getMessage().contains("Timed out")); + } + + @Test + public void interruptedNativeWriteWaitPreservesInterruption() throws Exception { + ServerLevel level = mock(ServerLevel.class); + ChunkPos chunk = new ChunkPos(2, 3); + Thread.currentThread().interrupt(); + try { + IOException failure = assertThrows(IOException.class, + () -> NmsSavedTerrainCapture.awaitWrite(level, chunk, + System.nanoTime() + TimeUnit.SECONDS.toNanos(3))); + assertTrue(failure.getMessage().contains("Interrupted")); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + private static ServerLevel level(BalancedPrioritisedThreadPool.OrderedStreamGroup.Queue saves, + BalancedPrioritisedThreadPool.OrderedStreamGroup.Queue compression) throws Exception { + ServerLevel level = mock(ServerLevel.class); + ChunkTaskScheduler scheduler = mock(ChunkTaskScheduler.class); + Field savesField = ChunkTaskScheduler.class.getField("saveExecutor"); + Field compressionField = ChunkTaskScheduler.class.getField("compressionExecutor"); + savesField.setAccessible(true); + compressionField.setAccessible(true); + savesField.set(scheduler, saves); + compressionField.set(scheduler, compression); + when(level.moonrise$getChunkTaskScheduler()).thenReturn(scheduler); + return level; + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java index 50ad1b4c1..5d04ddd8c 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java @@ -26,12 +26,16 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; import java.util.function.IntConsumer; @@ -291,6 +295,87 @@ public class NativeStructureVolumeIndexTest { } } + @Test + public void contendedNativeWindowsAllowQueuedTerrainWorkToRun() throws Exception { + assertQueuedTerrainCanComplete(false); + } + + @Test + public void interruptedNativeWindowWaitStillAcquiresAndReleasesOnce() throws Exception { + assertQueuedTerrainCanComplete(true); + } + + private static void assertQueuedTerrainCanComplete(boolean interruptWaiter) throws Exception { + CountDownLatch ownerEntered = new CountDownLatch(1); + CompletableFuture terrain = new CompletableFuture<>(); + AtomicBoolean firstOrigin = new AtomicBoolean(true); + AtomicBoolean interruptedAfterQuery = new AtomicBoolean(); + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> { + if (firstOrigin.compareAndSet(true, false)) { + ownerEntered.countDown(); + terrain.join(); + } + return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, + -256, 0, -256, 256, 100, 256)); + }); + ExecutorService owner = Executors.newSingleThreadExecutor(); + ForkJoinPool workers = new ForkJoinPool(2); + AtomicReference firstWaiter = new AtomicReference<>(); + AtomicReference secondWaiter = new AtomicReference<>(); + try { + Future> original = owner.submit( + () -> index.resolve(null, 0, 0, 15, 15)); + assertTrue(ownerEntered.await(5, TimeUnit.SECONDS)); + Future> adjacent = workers.submit(() -> { + firstWaiter.set(Thread.currentThread()); + KList result = index.resolve(null, 16, 0, 31, 15); + interruptedAfterQuery.set(Thread.currentThread().isInterrupted()); + return result; + }); + Future> next = workers.submit(() -> { + secondWaiter.set(Thread.currentThread()); + return index.resolve(null, 32, 0, 47, 15); + }); + awaitOriginWindowWaiter(firstWaiter); + awaitOriginWindowWaiter(secondWaiter); + if (interruptWaiter) { + firstWaiter.get().interrupt(); + } + + Future terrainTask = workers.submit(() -> terrain.complete(null)); + terrainTask.get(3, TimeUnit.SECONDS); + assertEquals(289, original.get(3, TimeUnit.SECONDS).size()); + KList adjacentVolumes = adjacent.get(3, TimeUnit.SECONDS); + assertEquals(289, adjacentVolumes.size()); + assertEquals(adjacentVolumes, index.resolve(null, 16, 0, 31, 15)); + assertEquals(289, next.get(3, TimeUnit.SECONDS).size()); + assertEquals(interruptWaiter, interruptedAfterQuery.get()); + } finally { + terrain.complete(null); + owner.shutdownNow(); + workers.shutdownNow(); + assertTrue(owner.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static void awaitOriginWindowWaiter(AtomicReference reference) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + Thread thread = reference.get(); + if (thread != null && thread.getState() == Thread.State.WAITING) { + for (StackTraceElement frame : thread.getStackTrace()) { + if (frame.getClassName().equals(NativeStructureVolumeIndex.class.getName()) + && frame.getMethodName().equals("lockOriginWindow")) { + return; + } + } + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + throw new AssertionError("Native query did not wait for the occupied origin window"); + } + @Test public void distantColdQueriesCanResolveOriginsConcurrently() throws Exception { BlockingResolver resolver = new BlockingResolver(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java index 3e7ef65e4..eb7686363 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java @@ -26,6 +26,7 @@ import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.runtime.ObjectStudioActivation; import art.arcane.iris.core.runtime.StudioOpenCoordinator; import art.arcane.iris.core.runtime.WorldRuntimeControlService; +import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.core.service.ObjectSVC; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.service.WandSVC; @@ -41,6 +42,8 @@ import art.arcane.iris.engine.object.IrisObjectRotation; import art.arcane.iris.engine.object.IrisObjectScale; import art.arcane.iris.engine.object.TileData; import art.arcane.iris.platform.bukkit.BukkitBlockState; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.data.Cuboid; import art.arcane.iris.util.common.data.IrisCustomData; @@ -223,11 +226,19 @@ public class CommandObject implements DirectorExecutor { //Prevent blocks being set in or bellow bedrock if (y <= world.getMinHeight() || block.getType() == Material.BEDROCK) return; - futureBlockChanges.putIfAbsent(block, block.getBlockData()); + ExternalDataSVC external = IrisServices.getOrNull(ExternalDataSVC.class); + futureBlockChanges.computeIfAbsent(block, target -> { + BlockData previous = target.getBlockData(); + return external == null ? previous : external.captureBlockData(previous); + }); if (d instanceof IrisCustomData data) { + if (external != null && external.placeBlock(block, data.getCustom())) { + return; + } block.setBlockData(data.getBase(), false); - Iris.warn("Tried to place custom block at " + x + ", " + y + ", " + z + " which is not supported!"); + IrisLogging.warnOnce("object-paste:external:" + data.getCustom(), + "Direct placement of %s is unavailable; only its base block was placed.", data.getCustom()); } else block.setBlockData(d, false); } diff --git a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml index b23f9db72..e19c61888 100644 --- a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml @@ -53,11 +53,11 @@ dependencies: required: false join-classpath: false MythicLib: - load: BEFORE + load: OMIT required: false join-classpath: true MMOItems: - load: BEFORE + load: OMIT required: false join-classpath: true eco: @@ -69,11 +69,11 @@ dependencies: required: false join-classpath: true MythicMobs: - load: BEFORE + load: OMIT required: false join-classpath: true MythicCrucible: - load: BEFORE + load: OMIT required: false join-classpath: true KGenerators: diff --git a/adapters/bukkit/plugin/src/main/resources/plugin.yml b/adapters/bukkit/plugin/src/main/resources/plugin.yml index cc5c3df03..b70dbf69a 100644 --- a/adapters/bukkit/plugin/src/main/resources/plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/plugin.yml @@ -11,12 +11,8 @@ softdepend: - ItemsAdder - SCore - ExecutableItems - - MythicLib - - MMOItems - eco - EcoItems - - MythicMobs - - MythicCrucible - KGenerators - WorldEdit loadbefore: [ Multiverse-Core ] diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java index 4fa463c9d..44034556a 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java @@ -41,8 +41,11 @@ public class PaperPluginMetadataTest { private static final List JOINED_PLUGIN_IDS = OPTIONAL_PLUGIN_IDS.stream() .filter(pluginId -> !"ExecutableItems".equals(pluginId) && !"Multiverse-Core".equals(pluginId)) .toList(); + private static final List UNORDERED_PLUGIN_IDS = List.of( + "MythicLib", "MMOItems", "MythicMobs", "MythicCrucible"); private static final List BUKKIT_SOFT_DEPEND_IDS = OPTIONAL_PLUGIN_IDS.stream() .filter(pluginId -> !"Multiverse-Core".equals(pluginId)) + .filter(pluginId -> !UNORDERED_PLUGIN_IDS.contains(pluginId)) .toList(); @Test @@ -72,7 +75,8 @@ public class PaperPluginMetadataTest { + " description: Allows survival players to fell Iris-managed trees with an axe.\n" + " default: op")); for (String pluginId : JOINED_PLUGIN_IDS) { - assertTrue(metadata.contains(optionalDependencyBlock(pluginId, "BEFORE", true))); + String loadOrder = UNORDERED_PLUGIN_IDS.contains(pluginId) ? "OMIT" : "BEFORE"; + assertTrue(metadata.contains(optionalDependencyBlock(pluginId, loadOrder, true))); } assertTrue(metadata.contains(optionalDependencyBlock("ExecutableItems", "BEFORE", false))); assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true))); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectCustomBlockPlacementTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectCustomBlockPlacementTest.java new file mode 100644 index 000000000..140fffb09 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectCustomBlockPlacementTest.java @@ -0,0 +1,80 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.service.ExternalDataSVC; +import art.arcane.iris.engine.object.IObjectPlacer; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.IrisCustomData; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CommandObjectCustomBlockPlacementTest { + @Test + public void customPasteUsesProviderAndKeepsOriginalSemanticUndoState() { + World world = mock(World.class); + Block target = mock(Block.class); + when(world.getBlockAt(7, 80, -9)).thenReturn(target); + when(target.getType()).thenReturn(Material.STONE); + BlockData previous = mock(BlockData.class); + when(target.getBlockData()).thenReturn(previous); + BlockData previousCustom = IrisCustomData.of(previous, new Identifier("craftengine", "test/lamp[lit=false]")); + Identifier id = new Identifier("craftengine", "test/lamp[lit=true]"); + BlockData base = mock(BlockData.class); + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.nativeHandle()).thenReturn(IrisCustomData.of(base, id)); + ExternalDataSVC external = mock(ExternalDataSVC.class); + when(external.captureBlockData(previous)).thenReturn(previousCustom); + when(external.placeBlock(target, id)).thenReturn(true); + Map changes = new HashMap<>(); + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.getOrNull(ExternalDataSVC.class)).thenReturn(external); + IObjectPlacer placer = CommandObject.createPlacer(world, changes, null); + + placer.set(7, 80, -9, state); + placer.set(7, 80, -9, state); + + assertSame(previousCustom, changes.get(target)); + verify(external).captureBlockData(previous); + verify(external, times(2)).placeBlock(target, id); + verify(target, times(0)).setBlockData(any(BlockData.class), anyBoolean()); + } + } + + @Test + public void unsupportedFurniturePreservesBaseOnlyPreview() { + World world = mock(World.class); + Block target = mock(Block.class); + when(world.getBlockAt(7, 80, -9)).thenReturn(target); + when(target.getType()).thenReturn(Material.STONE); + BlockData base = mock(BlockData.class); + Identifier id = new Identifier("craftengine", "test/chair[variant=alpha]"); + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.nativeHandle()).thenReturn(IrisCustomData.of(base, id)); + ExternalDataSVC external = mock(ExternalDataSVC.class); + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.getOrNull(ExternalDataSVC.class)).thenReturn(external); + + CommandObject.createPlacer(world, new HashMap<>(), null).set(7, 80, -9, state); + + verify(external).placeBlock(target, id); + verify(target).setBlockData(base, false); + } + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java index 1ea867763..aac977adb 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java @@ -39,6 +39,9 @@ import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.locks.ReentrantLock; import java.util.function.IntBinaryOperator; import java.util.function.IntConsumer; @@ -516,11 +519,30 @@ public final class NativeStructureVolumeIndex { long pending = stripeMask; while (pending != 0L) { int stripe = Long.numberOfTrailingZeros(pending); - originWindowStripes[stripe].lock(); + lockOriginStripe(originWindowStripes[stripe]); pending &= pending - 1L; } } + private static void lockOriginStripe(ReentrantLock lock) { + if (lock.tryLock()) { + return; + } + if (!ForkJoinTask.inForkJoinPool()) { + lock.lock(); + return; + } + OriginLockBlocker blocker = new OriginLockBlocker(lock); + try { + ForkJoinPool.managedBlock(blocker); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + blocker.block(); + } catch (RejectedExecutionException exhaustedPool) { + blocker.block(); + } + } + private void unlockOriginWindow(long stripeMask) { long pending = stripeMask; while (pending != 0L) { @@ -530,6 +552,32 @@ public final class NativeStructureVolumeIndex { } } + private static final class OriginLockBlocker implements ForkJoinPool.ManagedBlocker { + private final ReentrantLock lock; + private boolean acquired; + + private OriginLockBlocker(ReentrantLock lock) { + this.lock = lock; + } + + @Override + public boolean block() { + if (!acquired) { + lock.lock(); + acquired = true; + } + return true; + } + + @Override + public boolean isReleasable() { + if (!acquired) { + acquired = lock.tryLock(); + } + return acquired; + } + } + private static GenerationHistoryRuntimeRouter.CoordinateScope openHistoryCoordinateScope( Engine engine, int chunkX, diff --git a/core/build.gradle b/core/build.gradle index 9b3772ca3..b1774f756 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -453,6 +453,18 @@ sourceSets { } dependencies { + externalProviderTestImplementation(libs.craftengine.core) { + transitive = false + } + externalProviderTestImplementation(libs.craftengine.bukkit) { + transitive = false + } + externalProviderTestImplementation('net.momirealms:sparrow-nbt:0.22') + externalProviderTestImplementation('net.momirealms:sparrow-util:0.121') + externalProviderTestImplementation('net.momirealms:sparrow-reflection:0.33') + externalProviderTestImplementation('net.momirealms:sparrow-yaml:1.0.18') + externalProviderTestImplementation('net.momirealms:antigrieflib:1.0.17') + externalProviderTestImplementation('org.incendo:cloud-core:2.0.0') externalProviderTestImplementation(libs.oraxen) { transitive = false } diff --git a/core/src/externalProviderTest/java/art/arcane/iris/core/link/data/CraftEngineDataProviderTest.java b/core/src/externalProviderTest/java/art/arcane/iris/core/link/data/CraftEngineDataProviderTest.java new file mode 100644 index 000000000..0cd4566de --- /dev/null +++ b/core/src/externalProviderTest/java/art/arcane/iris/core/link/data/CraftEngineDataProviderTest.java @@ -0,0 +1,371 @@ +package art.arcane.iris.core.link.data; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.service.ExternalDataSVC; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.platform.bukkit.BukkitBlockResolution; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.testsupport.BukkitTestServer; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.iris.util.common.data.registry.RegistryUtil; +import art.arcane.volmlib.util.collection.KMap; +import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks; +import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture; +import net.momirealms.craftengine.bukkit.api.CraftEngineItems; +import net.momirealms.craftengine.bukkit.api.event.CraftEngineReloadEvent; +import net.momirealms.craftengine.bukkit.item.BukkitItemDefinition; +import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine; +import net.momirealms.craftengine.core.block.BlockDefinition; +import net.momirealms.craftengine.core.block.ImmutableBlockState; +import net.momirealms.craftengine.core.block.property.BooleanProperty; +import net.momirealms.craftengine.core.entity.furniture.FurnitureDefinition; +import net.momirealms.craftengine.core.entity.furniture.FurnitureVariant; +import net.momirealms.craftengine.core.registry.Holder; +import net.momirealms.craftengine.core.util.Key; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.List; +import java.util.Map; +import java.util.MissingResourceException; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CraftEngineDataProviderTest { + @Test + public void startupWaitsForReloadEventAndEmptyLoadedRegistriesAreReady() { + CraftEngineDataProvider provider = spy(new CraftEngineDataProvider()); + Plugin plugin = mock(Plugin.class); + doReturn(plugin).when(provider).getPlugin(); + when(plugin.isEnabled()).thenReturn(true); + try (MockedStatic engine = mockStatic(BukkitCraftEngine.class); + MockedStatic blocks = mockStatic(CraftEngineBlocks.class); + MockedStatic furniture = mockStatic(CraftEngineFurniture.class); + MockedStatic items = mockStatic(CraftEngineItems.class)) { + provider.init(); + + assertFalse(provider.isReady()); + assertTrue(provider.getTypes(DataType.BLOCK).isEmpty()); + assertFalse(provider.isValidProvider(new Identifier("test", "lamp"), DataType.BLOCK)); + blocks.verifyNoInteractions(); + furniture.verifyNoInteractions(); + items.verifyNoInteractions(); + + provider.onReload(mock(CraftEngineReloadEvent.class)); + + assertTrue(provider.isReady()); + when(plugin.isEnabled()).thenReturn(false); + assertFalse(provider.isReady()); + } + } + + @Test + public void lateInitializationUsesFullyLoadedFlagAndRefreshesExactClaims() { + CraftEngineDataProvider provider = new CraftEngineDataProvider(); + BukkitCraftEngine plugin = mock(BukkitCraftEngine.class); + when(plugin.isFullyLoaded()).thenReturn(true); + Key oldKey = Key.of("test:old"); + Key newKey = Key.of("test:new"); + try (MockedStatic engine = mockStatic(BukkitCraftEngine.class); + MockedStatic blocks = mockStatic(CraftEngineBlocks.class); + MockedStatic furniture = mockStatic(CraftEngineFurniture.class); + MockedStatic items = mockStatic(CraftEngineItems.class); + MockedStatic services = mockStatic(IrisServices.class)) { + ExternalDataSVC service = mock(ExternalDataSVC.class); + services.when(() -> IrisServices.getOrNull(ExternalDataSVC.class)).thenReturn(service); + engine.when(BukkitCraftEngine::instance).thenReturn(plugin); + blocks.when(CraftEngineBlocks::loadedBlocks).thenReturn(Map.of(oldKey, mock(BlockDefinition.class))); + items.when(CraftEngineItems::loadedItems).thenReturn(Map.of(oldKey, mock(BukkitItemDefinition.class))); + provider.init(); + + assertTrue(provider.isValidProvider(new Identifier("test", "old"), DataType.BLOCK)); + assertTrue(provider.isValidProvider(new Identifier("test", "old"), DataType.ITEM)); + assertFalse(provider.isValidProvider(new Identifier("test", "missing"), DataType.ITEM)); + assertFalse(provider.isValidProvider(new Identifier("test", "old"), DataType.ENTITY)); + + blocks.when(CraftEngineBlocks::loadedBlocks).thenReturn(Map.of()); + items.when(CraftEngineItems::loadedItems).thenReturn(Map.of(newKey, mock(BukkitItemDefinition.class))); + furniture.when(CraftEngineFurniture::loadedFurniture).thenReturn(Map.of(newKey, mock(FurnitureDefinition.class))); + when(plugin.isFullyLoaded()).thenReturn(false); + when(plugin.isReloading()).thenReturn(true); + provider.onReload(mock(CraftEngineReloadEvent.class)); + + assertFalse(provider.isValidProvider(new Identifier("test", "old"), DataType.BLOCK)); + assertFalse(provider.isValidProvider(new Identifier("test", "old"), DataType.ITEM)); + assertEquals(Set.of(new Identifier("test", "new")), provider.getTypes(DataType.BLOCK)); + assertEquals(Set.of(new Identifier("test", "new")), provider.getTypes(DataType.ITEM)); + verify(service).notifyContentChanged(); + } + } + + @Test + public void blockPropertiesProduceNativeCarrierAndCanonicalIdentifier() { + Identifier id = new Identifier("test", "lamp"); + BooleanProperty property = BooleanProperty.create("lit", false); + BlockDefinition definition = mock(BlockDefinition.class); + ImmutableBlockState initial = blockState(definition, "test:lamp[lit=false]"); + ImmutableBlockState lit = blockState(definition, "test:lamp[lit=true]"); + BlockData data = mock(BlockData.class); + when(definition.defaultState()).thenReturn(initial); + doReturn(property).when(definition).getProperty("lit"); + when(initial.with(property, true)).thenReturn(lit); + KMap properties = new KMap<>(); + properties.put("lit", "true"); + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class)) { + blocks.when(() -> CraftEngineBlocks.byId(Key.of("test:lamp"))).thenReturn(definition); + blocks.when(() -> CraftEngineBlocks.getBukkitBlockData(lit)).thenReturn(data); + + IrisCustomData result = (IrisCustomData) new CraftEngineDataProvider().getBlockData(id, properties); + + assertSame(data, result.getBase()); + assertEquals(new Identifier("test", "lamp[lit=true]"), result.getCustom()); + verify(initial).with(property, true); + } + } + + @Test + public void unknownOrInvalidBlockPropertiesFailBeforeNativeConversion() { + Identifier id = new Identifier("test", "lamp"); + BlockDefinition definition = mock(BlockDefinition.class); + ImmutableBlockState initial = blockState(definition, "test:lamp[lit=false]"); + when(definition.defaultState()).thenReturn(initial); + doReturn(BooleanProperty.create("lit", false)).when(definition).getProperty("lit"); + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class)) { + blocks.when(() -> CraftEngineBlocks.byId(Key.of("test:lamp"))).thenReturn(definition); + for (Map.Entry entry : List.of(Map.entry("missing", "true"), Map.entry("lit", "yes"))) { + KMap properties = new KMap<>(); + properties.put(entry.getKey(), entry.getValue()); + assertThrows(MissingResourceException.class, () -> new CraftEngineDataProvider().getBlockData(id, properties)); + } + blocks.verify(() -> CraftEngineBlocks.getBukkitBlockData(any()), times(0)); + } + } + + @Test + public void reverseCaptureUsesSemanticStateInsteadOfSyntheticNativeName() { + BlockData nativeData = mock(BlockData.class); + when(nativeData.getAsString()).thenReturn("craftengine:custom_12"); + ImmutableBlockState state = blockState(mock(BlockDefinition.class), "test:lamp[lit=true]"); + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class)) { + blocks.when(() -> CraftEngineBlocks.getCustomBlockState(nativeData)).thenReturn(state); + CraftEngineDataProvider provider = new CraftEngineDataProvider(); + + assertEquals(new Identifier("test", "lamp[lit=true]"), provider.identifyBlock(nativeData).orElseThrow()); + assertTrue(provider.identifyBlock(mock(BlockData.class)).isEmpty()); + } + } + + @Test + public void currentSyntheticSlotResolvesOnlyWhenBoundToCustomContent() { + CraftEngineDataProvider provider = new CraftEngineDataProvider(); + Identifier id = new Identifier("craftengine", "custom_12"); + BlockData data = mock(BlockData.class); + ImmutableBlockState state = blockState(mock(BlockDefinition.class), "test:lamp[lit=true]"); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic blocks = mockStatic(CraftEngineBlocks.class); + MockedStatic furniture = mockStatic(CraftEngineFurniture.class); + MockedStatic items = mockStatic(CraftEngineItems.class)) { + bukkit.when(() -> Bukkit.createBlockData("craftengine:custom_12")).thenReturn(data); + blocks.when(() -> CraftEngineBlocks.getCustomBlockState(data)).thenReturn(state); + blocks.when(() -> CraftEngineBlocks.getBukkitBlockData(state)).thenReturn(data); + provider.onReload(mock(CraftEngineReloadEvent.class)); + + assertTrue(provider.isValidProvider(id, DataType.BLOCK)); + IrisCustomData result = (IrisCustomData) provider.getBlockData(id, new KMap<>()); + assertEquals(new Identifier("test", "lamp[lit=true]"), result.getCustom()); + + blocks.when(() -> CraftEngineBlocks.getCustomBlockState(data)).thenReturn(null); + assertFalse(provider.isValidProvider(id, DataType.BLOCK)); + assertThrows(MissingResourceException.class, () -> provider.getBlockData(id, new KMap<>())); + bukkit.when(() -> Bukkit.createBlockData("craftengine:custom_12")).thenThrow(new IllegalArgumentException("Unknown block")); + assertFalse(provider.isValidProvider(id, DataType.BLOCK)); + } + } + + @Test + public void deferredPlacementUsesCraftEngineAndAcceptsAlreadyMatchingState() { + Identifier id = new Identifier("test", "lamp"); + BlockDefinition definition = mock(BlockDefinition.class); + ImmutableBlockState state = blockState(definition, "test:lamp"); + when(definition.defaultState()).thenReturn(state); + Block block = mock(Block.class); + Location location = new Location(null, 17, 80, -12); + when(block.getLocation()).thenReturn(location); + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class)) { + blocks.when(() -> CraftEngineBlocks.byId(Key.of("test:lamp"))).thenReturn(definition); + blocks.when(() -> CraftEngineBlocks.getCustomBlockState(block)).thenReturn(state); + + new CraftEngineDataProvider().processUpdate(mock(Engine.class), block, id); + + blocks.verify(() -> CraftEngineBlocks.place(location, state, false)); + blocks.when(() -> CraftEngineBlocks.getCustomBlockState(block)).thenReturn(null); + assertThrows(MissingResourceException.class, () -> new CraftEngineDataProvider().processUpdate(mock(Engine.class), block, id)); + } + } + + @Test + public void missingBlockAndFurnitureCannotBePlaced() { + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class); + MockedStatic furniture = mockStatic(CraftEngineFurniture.class)) { + assertThrows(MissingResourceException.class, () -> new CraftEngineDataProvider().processUpdate( + mock(Engine.class), mock(Block.class), new Identifier("test", "missing"))); + } + } + + @Test + public void furnitureValidatesPropertiesAndStoresDeterministicDefaultVariant() { + BukkitTestServer.install(); + FurnitureDefinition definition = furnitureDefinition(); + Identifier id = new Identifier("test", "chair"); + BlockData air = mock(BlockData.class); + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class); + MockedStatic furniture = mockStatic(CraftEngineFurniture.class); + MockedStatic registry = materialRegistry(); + MockedStatic resolution = mockStatic(BukkitBlockResolution.class)) { + furniture.when(() -> CraftEngineFurniture.byId(Key.of("test:chair"))).thenReturn(definition); + resolution.when(BukkitBlockResolution::getAir).thenReturn(air); + CraftEngineDataProvider provider = new CraftEngineDataProvider(); + IrisCustomData result = (IrisCustomData) provider.getBlockData(id, new KMap<>()); + + assertSame(air, result.getBase()); + assertEquals("test:chair[variant=alpha]", result.getCustom().toString()); + assertEquals("alpha", provider.getBlockProperties(id).stream() + .filter(property -> property.name().equals("variant")).findFirst().orElseThrow().defaultValue()); + for (Map.Entry invalid : List.of(Map.entry("variant", "missing"), Map.entry("randomYaw", "yes"), + Map.entry("randomPitch", "1"), Map.entry("yaw", "NaN"), Map.entry("yaw", "Infinity"), + Map.entry("pitch", "360"), Map.entry("pitch", "-1"), Map.entry("yaw", "word"), Map.entry("unknown", "true"))) { + KMap properties = new KMap<>(); + properties.put(invalid.getKey(), invalid.getValue()); + assertThrows(MissingResourceException.class, () -> provider.getBlockData(id, properties)); + } + } + } + + @Test + public void furniturePlacementUsesRepeatablePositionSeededRotation() { + FurnitureDefinition definition = furnitureDefinition(); + Engine engine = mock(Engine.class); + SeedManager seeds = mock(SeedManager.class); + when(engine.getSeedManager()).thenReturn(seeds); + when(seeds.getSeed()).thenReturn(1234L); + Block block = mock(Block.class); + when(block.getX()).thenReturn(7); + when(block.getY()).thenReturn(80); + when(block.getZ()).thenReturn(-9); + when(block.getLocation()).thenAnswer(invocation -> new Location(null, 7, 80, -9)); + Identifier id = new Identifier("test", "chair[randomYaw=true,randomPitch=true,variant=beta]"); + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class); + MockedStatic furniture = mockStatic(CraftEngineFurniture.class)) { + furniture.when(() -> CraftEngineFurniture.byId(Key.of("test:chair"))).thenReturn(definition); + Location[] observed = new Location[2]; + int[] count = new int[1]; + furniture.when(() -> CraftEngineFurniture.place(any(Location.class), eq(definition), eq("beta"), eq(false))) + .thenAnswer(invocation -> { + observed[count[0]++] = invocation.getArgument(0, Location.class).clone(); + return null; + }); + CraftEngineDataProvider provider = new CraftEngineDataProvider(); + assertThrows(MissingResourceException.class, () -> provider.processUpdate(engine, block, id)); + assertThrows(MissingResourceException.class, () -> provider.processUpdate(engine, block, id)); + + assertEquals(observed[0], observed[1]); + assertTrue(observed[0].getYaw() >= 0 && observed[0].getYaw() < 360); + assertTrue(observed[0].getPitch() >= 0 && observed[0].getPitch() < 360); + } + } + + @Test + public void itemsAreBuiltThroughCraftEngineAndMissingItemsFail() { + BukkitItemDefinition definition = mock(BukkitItemDefinition.class); + ItemStack stack = mock(ItemStack.class); + when(definition.buildBukkitItem()).thenReturn(stack); + try (MockedStatic items = mockStatic(CraftEngineItems.class)) { + items.when(() -> CraftEngineItems.byId(Key.of("test:lamp"))).thenReturn(definition); + CraftEngineDataProvider provider = new CraftEngineDataProvider(); + + assertSame(stack, provider.getItemStack(new Identifier("test", "lamp"), new KMap<>())); + assertThrows(MissingResourceException.class, () -> provider.getItemStack(new Identifier("test", "missing"), new KMap<>())); + } + } + + @Test + public void directPlacementUsesCustomBlockPropertiesWithoutAnEngine() { + BlockDefinition definition = mock(BlockDefinition.class); + BooleanProperty property = BooleanProperty.create("lit", false); + ImmutableBlockState initial = blockState(definition, "test:lamp[lit=false]"); + ImmutableBlockState lit = blockState(definition, "test:lamp[lit=true]"); + when(definition.defaultState()).thenReturn(initial); + doReturn(property).when(definition).getProperty("lit"); + when(initial.with(property, true)).thenReturn(lit); + Block block = mock(Block.class); + Location location = new Location(null, 7, 80, -9); + when(block.getLocation()).thenReturn(location); + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class)) { + blocks.when(() -> CraftEngineBlocks.byId(Key.of("test:lamp"))).thenReturn(definition); + blocks.when(() -> CraftEngineBlocks.place(location, lit, false)).thenReturn(true); + + assertTrue(new CraftEngineDataProvider().placeBlock(block, new Identifier("test", "lamp[lit=true]"))); + + blocks.verify(() -> CraftEngineBlocks.place(location, lit, false)); + } + } + + @Test + public void directFurniturePlacementDoesNotSpawnEntities() { + FurnitureDefinition definition = furnitureDefinition(); + Block block = mock(Block.class); + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class); + MockedStatic furniture = mockStatic(CraftEngineFurniture.class)) { + furniture.when(() -> CraftEngineFurniture.byId(Key.of("test:chair"))).thenReturn(definition); + + assertFalse(new CraftEngineDataProvider().placeBlock(block, new Identifier("test", "chair[variant=alpha]"))); + assertThrows(MissingResourceException.class, () -> new CraftEngineDataProvider().placeBlock( + block, new Identifier("test", "chair[variant=missing]"))); + + furniture.verify(() -> CraftEngineFurniture.place(any(Location.class), any(FurnitureDefinition.class), any(String.class), eq(false)), times(0)); + verify(block, times(0)).getLocation(); + } + } + + private static MockedStatic materialRegistry() { + MockedStatic registry = mockStatic(RegistryUtil.class); + registry.when(() -> RegistryUtil.find(Material.class, "grass", "short_grass")).thenReturn(Material.SHORT_GRASS); + return registry; + } + + private static ImmutableBlockState blockState(BlockDefinition definition, String semanticId) { + ImmutableBlockState state = mock(ImmutableBlockState.class); + when(state.owner()).thenReturn(Holder.direct(definition)); + when(state.toString()).thenReturn(semanticId); + return state; + } + + private static FurnitureDefinition furnitureDefinition() { + FurnitureDefinition definition = mock(FurnitureDefinition.class); + when(definition.id()).thenReturn(Key.of("test:chair")); + when(definition.variants()).thenReturn(Map.of("beta", mock(FurnitureVariant.class), "alpha", mock(FurnitureVariant.class))); + return definition; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java index 9b4269a3b..8abcf40d1 100644 --- a/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java @@ -27,6 +27,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.MissingResourceException; +import java.util.Optional; @Getter @RequiredArgsConstructor @@ -46,6 +47,14 @@ public abstract class ExternalDataProvider { public abstract void init(); + public Optional identifyBlock(@NotNull BlockData blockData) { + return Optional.empty(); + } + + public boolean placeBlock(@NotNull Block block, @NotNull Identifier blockId) { + return false; + } + /** * @see ExternalDataProvider#getBlockData(Identifier, KMap) */ diff --git a/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java index 4882e9866..e369e9d61 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java @@ -1,20 +1,24 @@ package art.arcane.iris.core.link.data; -import art.arcane.iris.platform.bukkit.BukkitBlockResolution; - import art.arcane.iris.core.link.ExternalDataProvider; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.nms.container.BlockProperty; +import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.platform.bukkit.BukkitBlockResolution; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks; import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture; import net.momirealms.craftengine.bukkit.api.CraftEngineItems; +import net.momirealms.craftengine.bukkit.api.event.CraftEngineReloadEvent; import net.momirealms.craftengine.bukkit.item.BukkitItemDefinition; +import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine; import net.momirealms.craftengine.core.block.BlockDefinition; import net.momirealms.craftengine.core.block.ImmutableBlockState; import net.momirealms.craftengine.core.block.property.BooleanProperty; @@ -22,27 +26,36 @@ import net.momirealms.craftengine.core.block.property.IntegerProperty; import net.momirealms.craftengine.core.block.property.Property; import net.momirealms.craftengine.core.entity.furniture.FurnitureDefinition; import net.momirealms.craftengine.core.util.Key; +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; -import java.util.Arrays; +import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.MissingResourceException; +import java.util.Optional; +import java.util.Set; import java.util.function.Function; -import java.util.stream.Stream; +import java.util.stream.Collectors; -public class CraftEngineDataProvider extends ExternalDataProvider { - private static final BlockProperty[] FURNITURE_PROPERTIES = new BlockProperty[]{ +public class CraftEngineDataProvider extends ExternalDataProvider implements Listener { + private static final List FURNITURE_PROPERTIES = List.of( BlockProperty.ofBoolean("randomYaw", false), BlockProperty.ofDouble("yaw", 0, 0, 360f, false, true), BlockProperty.ofBoolean("randomPitch", false), - BlockProperty.ofDouble("pitch", 0, 0, 360f, false, true), - }; + BlockProperty.ofDouble("pitch", 0, 0, 360f, false, true) + ); + private static final ContentRegistry EMPTY_CONTENT = new ContentRegistry(Set.of(), Set.of(), false); + + private volatile ContentRegistry content = EMPTY_CONTENT; public CraftEngineDataProvider() { super("CraftEngine"); @@ -50,130 +63,244 @@ public class CraftEngineDataProvider extends ExternalDataProvider { @Override public void init() { + content = EMPTY_CONTENT; + BukkitCraftEngine plugin = BukkitCraftEngine.instance(); + if (plugin != null && plugin.isFullyLoaded()) { + updateContent(); + } + } + + @Override + public boolean isReady() { + return content.ready() && super.isReady(); + } + + @EventHandler + public void onReload(CraftEngineReloadEvent event) { + if (!updateContent()) { + return; + } + ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class); + if (service != null) { + service.notifyContentChanged(); + } } @Override public @NotNull List getBlockProperties(@NotNull Identifier blockId) throws MissingResourceException { - Key key = Key.of(blockId.namespace(), blockId.key()); - BlockDefinition block = CraftEngineBlocks.byId(key); - if (block != null) { - return block.properties().stream().map(CraftEngineDataProvider::convert).toList(); + ImmutableBlockState blockState = findBlockState(blockId); + if (blockState != null) { + return blockState.owner().value().properties().stream().map(CraftEngineDataProvider::convert).toList(); } - - FurnitureDefinition furniture = CraftEngineFurniture.byId(key); - if (furniture != null) { - BlockProperty[] properties = Arrays.copyOf(FURNITURE_PROPERTIES, 5); - properties[4] = new BlockProperty( - "variant", - String.class, - furniture.anyVariantName(), - furniture.variants().keySet(), - Function.identity() - ); - return List.of(properties); - } - - throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key()); + FurnitureDefinition furniture = requireFurniture(blockId); + List properties = new ArrayList<>(FURNITURE_PROPERTIES); + properties.add(new BlockProperty("variant", String.class, defaultVariant(furniture), + furniture.variants().keySet(), Function.identity())); + return List.copyOf(properties); } @Override public @NotNull ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap customNbt) throws MissingResourceException { BukkitItemDefinition item = CraftEngineItems.byId(Key.of(itemId.namespace(), itemId.key())); if (item == null) { - throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key()); + throw missing(itemId, "Failed to find CraftEngine item."); } - return item.buildBukkitItem(); } @Override public @NotNull BlockData getBlockData(@NotNull Identifier blockId, @NotNull KMap state) throws MissingResourceException { - Key key = Key.of(blockId.namespace(), blockId.key()); - if (CraftEngineBlocks.byId(key) == null && CraftEngineFurniture.byId(key) == null) { - throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key()); + ImmutableBlockState blockState = findBlockState(blockId); + if (blockState != null) { + ImmutableBlockState resolved = applyProperties(blockId, blockState, state); + return IrisCustomData.of(CraftEngineBlocks.getBukkitBlockData(resolved), Identifier.fromString(resolved.toString())); } + FurnitureDefinition furniture = requireFurniture(blockId); + KMap properties = validateFurnitureProperties(blockId, furniture, state); + return IrisCustomData.of(BukkitBlockResolution.getAir(), ExternalDataSVC.buildState(blockId, properties)); + } - return IrisCustomData.of(BukkitBlockResolution.getAir(), ExternalDataSVC.buildState(blockId, state)); + @Override + public Optional identifyBlock(@NotNull BlockData blockData) { + BlockData nativeData = blockData instanceof IrisCustomData custom ? custom.getBase() : blockData; + ImmutableBlockState state = CraftEngineBlocks.getCustomBlockState(nativeData); + return state == null || state.isEmpty() ? Optional.empty() : Optional.of(Identifier.fromString(state.toString())); + } + + @Override + public boolean placeBlock(@NotNull Block block, @NotNull Identifier blockId) { + Pair> parsed = ExternalDataSVC.parseState(blockId); + ImmutableBlockState state = findBlockState(parsed.getA()); + if (state == null) { + validateFurnitureProperties(parsed.getA(), requireFurniture(parsed.getA()), parsed.getB()); + return false; + } + placeBlockState(block, applyProperties(parsed.getA(), state, parsed.getB()), blockId); + return true; } @Override public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { - art.arcane.iris.core.nms.container.Pair> statePair = ExternalDataSVC.parseState(blockId); + Pair> statePair = ExternalDataSVC.parseState(blockId); Identifier baseBlockId = statePair.getA(); KMap state = statePair.getB(); - Key key = Key.of(baseBlockId.namespace(), baseBlockId.key()); + ImmutableBlockState blockState = findBlockState(baseBlockId); + if (blockState != null) { + placeBlockState(block, applyProperties(baseBlockId, blockState, state), blockId); + return; + } + FurnitureDefinition furniture = requireFurniture(baseBlockId); + KMap properties = validateFurnitureProperties(baseBlockId, furniture, state); + Location location = parseYawAndPitch(engine, block, properties); + if (CraftEngineFurniture.place(location, furniture, properties.get("variant"), false) == null) { + throw missing(blockId, "Failed to place CraftEngine furniture."); + } + } - BlockDefinition customBlock = CraftEngineBlocks.byId(key); - if (customBlock != null) { - ImmutableBlockState blockState = customBlock.defaultState(); + @Override + public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) { + return switch (dataType) { + case ENTITY -> List.of(); + case ITEM -> content.items(); + case BLOCK -> content.blocks(); + }; + } - for (Map.Entry entry : state.entrySet()) { - Property property = customBlock.getProperty(entry.getKey()); - if (property == null) { - continue; - } + @Override + public boolean isValidProvider(@NotNull Identifier id, DataType dataType) { + ContentRegistry registry = content; + if (!registry.ready()) { + return false; + } + return switch (dataType) { + case ENTITY -> false; + case ITEM -> registry.items().contains(id); + case BLOCK -> registry.blocks().contains(id) || findRuntimeBlockState(id) != null; + }; + } - Comparable tag = property.optional(entry.getValue()).orElse(null); - if (tag == null) { - continue; - } - - blockState = ImmutableBlockState.with(blockState, property, tag); + private boolean updateContent() { + try { + Set blocks = new LinkedHashSet<>(); + for (Key key : CraftEngineBlocks.loadedBlocks().keySet()) { + blocks.add(new Identifier(key.namespace(), key.value())); } - - CraftEngineBlocks.place(block.getLocation(), blockState, false); - return; + for (Key key : CraftEngineFurniture.loadedFurniture().keySet()) { + blocks.add(new Identifier(key.namespace(), key.value())); + } + Set items = CraftEngineItems.loadedItems().keySet().stream() + .map(key -> new Identifier(key.namespace(), key.value())).collect(Collectors.toUnmodifiableSet()); + content = new ContentRegistry(items, Set.copyOf(blocks), true); + return true; + } catch (RuntimeException | LinkageError error) { + content = EMPTY_CONTENT; + IrisLogging.reportError("Failed to update CraftEngine content registry.", error); + return false; } + } - FurnitureDefinition furniture = CraftEngineFurniture.byId(key); + private static ImmutableBlockState findBlockState(Identifier blockId) { + BlockDefinition block = CraftEngineBlocks.byId(Key.of(blockId.namespace(), blockId.key())); + return block != null ? block.defaultState() : findRuntimeBlockState(blockId); + } + + private static ImmutableBlockState findRuntimeBlockState(Identifier blockId) { + if (!Key.CRAFTENGINE_NAMESPACE.equals(blockId.namespace())) { + return null; + } + BlockData data; + try { + data = Bukkit.createBlockData(blockId.toString()); + } catch (IllegalArgumentException error) { + return null; + } + ImmutableBlockState state = CraftEngineBlocks.getCustomBlockState(data); + return state == null || state.isEmpty() ? null : state; + } + + private static ImmutableBlockState applyProperties(Identifier id, ImmutableBlockState state, Map properties) { + BlockDefinition block = state.owner().value(); + for (Map.Entry entry : properties.entrySet()) { + Property property = block.getProperty(entry.getKey()); + if (property == null) { + throw missing(id, "Unknown CraftEngine block property '" + entry.getKey() + "'."); + } + Comparable value = property.optional(entry.getValue()).orElse(null); + if (value == null) { + throw missing(id, "Invalid CraftEngine block property '" + entry.getKey() + "=" + entry.getValue() + "'."); + } + state = ImmutableBlockState.with(state, property, value); + } + return state; + } + + private static void placeBlockState(Block block, ImmutableBlockState state, Identifier id) { + if (!CraftEngineBlocks.place(block.getLocation(), state, false) + && !state.equals(CraftEngineBlocks.getCustomBlockState(block))) { + throw missing(id, "Failed to place CraftEngine block."); + } + } + + private static FurnitureDefinition requireFurniture(Identifier id) { + FurnitureDefinition furniture = CraftEngineFurniture.byId(Key.of(id.namespace(), id.key())); if (furniture == null) { - return; + throw missing(id, "Failed to find CraftEngine block or furniture."); } + return furniture; + } - Location location = parseYawAndPitch(engine, block, state); - String variant = state.getOrDefault("variant", furniture.anyVariantName()); - CraftEngineFurniture.place(location, furniture, variant, false); + private static KMap validateFurnitureProperties(Identifier id, FurnitureDefinition furniture, Map state) { + KMap properties = new KMap<>(); + for (Map.Entry entry : state.entrySet()) { + String value = entry.getValue(); + switch (entry.getKey()) { + case "randomYaw", "randomPitch" -> { + if (!"true".equals(value) && !"false".equals(value)) { + throw missing(id, "Invalid CraftEngine furniture boolean '" + entry.getKey() + "=" + value + "'."); + } + } + case "yaw", "pitch" -> validateAngle(id, entry.getKey(), value); + case "variant" -> { + if (!furniture.variants().containsKey(value)) { + throw missing(id, "Unknown CraftEngine furniture variant '" + value + "'."); + } + } + default -> throw missing(id, "Unknown CraftEngine furniture property '" + entry.getKey() + "'."); + } + properties.put(entry.getKey(), value); + } + properties.putIfAbsent("variant", defaultVariant(furniture)); + return properties; + } + + private static void validateAngle(Identifier id, String property, String value) { + float angle; + try { + angle = Float.parseFloat(value); + } catch (NumberFormatException error) { + throw missing(id, "Invalid CraftEngine furniture angle '" + property + "=" + value + "'."); + } + if (!Float.isFinite(angle) || angle < 0 || angle >= 360) { + throw missing(id, "CraftEngine furniture angle '" + property + "' must be at least 0 and below 360."); + } + } + + private static String defaultVariant(FurnitureDefinition furniture) { + return furniture.variants().keySet().stream().sorted().findFirst() + .orElseThrow(() -> missing(Identifier.fromString(furniture.id().toString()), "CraftEngine furniture has no variants.")); } private static Location parseYawAndPitch(@NotNull Engine engine, @NotNull Block block, @NotNull Map state) { Location location = block.getLocation(); long seed = engine.getSeedManager().getSeed() + Cache.key(block.getX(), block.getZ()) + block.getY(); RNG rng = new RNG(seed); - - if ("true".equals(state.get("randomYaw"))) { - location.setYaw(rng.f(0, 360)); - } else if (state.containsKey("yaw")) { - location.setYaw(Float.parseFloat(state.get("yaw"))); - } - - if ("true".equals(state.get("randomPitch"))) { - location.setPitch(rng.f(0, 360)); - } else if (state.containsKey("pitch")) { - location.setPitch(Float.parseFloat(state.get("pitch"))); - } - + location.setYaw("true".equals(state.get("randomYaw")) ? rng.f(0, 360) : Float.parseFloat(state.getOrDefault("yaw", "0"))); + location.setPitch("true".equals(state.get("randomPitch")) ? rng.f(0, 360) : Float.parseFloat(state.getOrDefault("pitch", "0"))); return location; } - @Override - public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) { - Stream keys = switch (dataType) { - case ENTITY -> Stream.empty(); - case ITEM -> CraftEngineItems.loadedItems().keySet().stream(); - case BLOCK -> Stream.concat(CraftEngineBlocks.loadedBlocks().keySet().stream(), - CraftEngineFurniture.loadedFurniture().keySet().stream()); - }; - return keys.map(key -> new Identifier(key.namespace(), key.value())).toList(); - } - - @Override - public boolean isValidProvider(@NotNull Identifier id, DataType dataType) { - Key key = Key.of(id.namespace(), id.key()); - return switch (dataType) { - case ENTITY -> false; - case ITEM -> CraftEngineItems.byId(key) != null; - case BLOCK -> CraftEngineBlocks.byId(key) != null || CraftEngineFurniture.byId(key) != null; - }; + private static MissingResourceException missing(Identifier id, String message) { + return new MissingResourceException(message, id.namespace(), id.key()); } private static > BlockProperty convert(Property raw) { @@ -183,4 +310,7 @@ public class CraftEngineDataProvider extends ExternalDataProvider { default -> new BlockProperty(raw.name(), raw.valueClass(), raw.defaultValue(), raw.possibleValues(), raw::valueName); }; } + + private record ContentRegistry(Set items, Set blocks, boolean ready) { + } } diff --git a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java index 0872d7b7b..956b29cb2 100644 --- a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java @@ -27,6 +27,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.BiomeEnvironment; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineLifecycleTasks; import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.board.Board; @@ -498,8 +499,13 @@ public class BoardSVC implements IrisService, BoardProvider { return; } + EngineLifecycleTasks.run(engine, "bukkit_studio_scoreboard", + () -> updateOrdinary(engine, loc, world.getMinHeight())); + } + + private void updateOrdinary(Engine engine, Location loc, int minimumY) { int x = loc.getBlockX(); - int y = loc.getBlockY() - world.getMinHeight(); + int y = loc.getBlockY() - minimumY; int z = loc.getBlockZ(); List lines = new ArrayList<>(this.lines.size()); diff --git a/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java b/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java index bf590f3db..dd8364c4f 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java @@ -180,6 +180,20 @@ public class ExternalDataSVC implements IrisService { } } + public BlockData captureBlockData(BlockData blockData) { + Objects.requireNonNull(blockData); + if (blockData instanceof IrisCustomData) { + return blockData; + } + for (ExternalDataProvider provider : activeProviders) { + Optional identifier = provider.identifyBlock(blockData); + if (identifier.isPresent()) { + return IrisCustomData.of(blockData, qualifyBlockId(provider, identifier.get())); + } + } + return blockData; + } + public Optional getBlockData(final Identifier key) { Pair> pair; try { @@ -244,6 +258,15 @@ public class ExternalDataSVC implements IrisService { match.provider().processUpdate(engine, block, buildState(match.identifier(), state.getB())); } + public boolean placeBlock(Block block, Identifier blockId) { + Pair> state = parseState(blockId); + BlockProvider match = findBlockProvider(state.getA()); + if (match == null) { + throw new MissingResourceException("No matching provider found for external block placement.", blockId.namespace(), blockId.key()); + } + return match.provider().placeBlock(block, buildState(match.identifier(), state.getB())); + } + public Entity spawnMob(Location location, Identifier mobId) { Optional provider = activeProviders.stream().filter(p -> p.isValidProvider(mobId, DataType.ENTITY)).findFirst(); if (provider.isEmpty()) { diff --git a/core/src/main/java/art/arcane/iris/core/service/ObjectSVC.java b/core/src/main/java/art/arcane/iris/core/service/ObjectSVC.java index e52870b87..310d4711c 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ObjectSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/ObjectSVC.java @@ -18,6 +18,9 @@ package art.arcane.iris.core.service; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.iris.util.common.scheduling.J; import lombok.Getter; @@ -81,7 +84,11 @@ public class ObjectSVC implements IrisService { int amount = 0; while (it.hasNext()) { Map.Entry entry = it.next(); - entry.getKey().setBlockData(entry.getValue(), false); + Block block = entry.getKey(); + BlockData data = entry.getValue(); + if (!J.runAt(block.getLocation(), () -> restoreBlock(block, data))) { + IrisLogging.warn("Could not schedule object undo at %d, %d, %d.", block.getX(), block.getY(), block.getZ()); + } it.remove(); if (++amount >= 200) { @@ -94,4 +101,16 @@ public class ObjectSVC implements IrisService { } }); } + + private static void restoreBlock(Block block, BlockData data) { + if (data instanceof IrisCustomData custom) { + ExternalDataSVC external = IrisServices.getOrNull(ExternalDataSVC.class); + if (external != null && external.placeBlock(block, custom.getCustom())) { + return; + } + block.setBlockData(custom.getBase(), false); + return; + } + block.setBlockData(data, false); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java index 2dd1cb789..0bc69eb32 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java @@ -277,6 +277,13 @@ final class EngineRuntimeBuilder { throw propagate(failure); } EngineRuntime next = new EngineRuntime(previous.generation(), effects, worldManager); + try { + worldManager.start(); + } catch (Throwable failure) { + failure = EngineShutdownSequence.runCleanup(failure, worldManager::close); + failure = EngineShutdownSequence.runCleanup(failure, effects::close); + throw new IllegalStateException("Failed to start the replacement Studio world manager.", failure); + } Throwable retirementFailure = EngineShutdownSequence.runCleanup(null, previous.worldManager()::close); retirementFailure = EngineShutdownSequence.runCleanup(retirementFailure, previous.effects()::close); if (retirementFailure != null) { @@ -285,7 +292,6 @@ final class EngineRuntimeBuilder { throw new IllegalStateException("Failed to retire Studio runtime services.", retirementFailure); } engine.runtime = next; - worldManager.start(); engine.getGenerationSessions().activateNextSession(); engine.lifecycleState = LifecycleState.RUNNING; engine.getClosing().set(false); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 2fc42a221..4bbef2de6 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -1459,6 +1459,13 @@ public class IrisEngine implements Engine { return closing.get(); } + @Override + public boolean isShuttingDown() { + LifecycleState state = lifecycleState; + return closed || state == LifecycleState.CLOSING + || state == LifecycleState.CLOSED || state == LifecycleState.FAILED; + } + @Override public void recycle() { if (closing.get() || closed) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index 767747d16..3bd66544c 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -195,6 +195,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return isClosed(); } + default boolean isShuttingDown() { + return isClosed() || isClosing(); + } + double getMaxBiomeObjectDensity(); double getMaxBiomeDecoratorDensity(); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java index 3e32a4d16..f2d4bc105 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java @@ -131,8 +131,9 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void on(PlayerTeleportEvent e) { + Engine engine = getEngine(); if (!BukkitPlatform.isPaperServer() - || !getEngine().isStudio() + || !engine.isStudio() || e.getCause() != PlayerTeleportEvent.TeleportCause.COMMAND) { return; } @@ -142,6 +143,10 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent if (destination == null || targetWorld == null || !targetWorld.equals(destination.getWorld())) { return; } + if (engine.isShuttingDown()) { + e.setCancelled(true); + return; + } int chunkX = destination.getBlockX() >> 4; int chunkZ = destination.getBlockZ() >> 4; @@ -149,7 +154,7 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent return; } - runManagerTask("bukkit_world_manager_teleport_event", () -> teleportAsync(e)); + teleportAsync(e); } @EventHandler diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundaryStore.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundaryStore.java index b3e20596b..63d0932c5 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundaryStore.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationBoundaryStore.java @@ -619,6 +619,7 @@ public final class GenerationBoundaryStore { private final long[] regionKeys; private final Long2ObjectOpenHashMap shardHashes; private final LinkedHashMap masks; + private final ThreadLocal lastRegion = new ThreadLocal<>(); private MaskSource( Path directory, @@ -634,13 +635,21 @@ public final class GenerationBoundaryStore { @Override public boolean contains(int chunkX, int chunkZ) throws IOException { long regionKey = packRegion(chunkX >> 5, chunkZ >> 5); - String hash = shardHashes.get(regionKey); - if (hash == null) { - return false; + RegionMask region = lastRegion.get(); + if (region == null || region.key() != regionKey) { + String hash = shardHashes.get(regionKey); + byte[] loaded = null; + if (hash != null) { + synchronized (this) { + loaded = load(regionKey, hash); + } + } + region = new RegionMask(regionKey, loaded); + lastRegion.set(region); } - byte[] mask; - synchronized (this) { - mask = load(regionKey, hash); + byte[] mask = region.mask(); + if (mask == null) { + return false; } int localIndex = (Math.floorMod(chunkZ, REGION_SIDE) << 5) | Math.floorMod(chunkX, REGION_SIDE); @@ -686,6 +695,9 @@ public final class GenerationBoundaryStore { } return loaded; } + + private record RegionMask(long key, byte[] mask) { + } } private static final class LimitedInputStream extends InputStream { diff --git a/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStore.java b/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStore.java index 417adcf61..8d9223d36 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStore.java +++ b/core/src/main/java/art/arcane/iris/engine/history/TerrainBoundarySignatureStore.java @@ -46,6 +46,9 @@ import java.util.Optional; import java.util.OptionalInt; import java.util.PriorityQueue; import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.LongAdder; import java.util.zip.CRC32; public final class TerrainBoundarySignatureStore { @@ -936,8 +939,9 @@ public final class TerrainBoundarySignatureStore { private final Long2ObjectOpenHashMap cells; private final Long2ObjectOpenHashMap cellsBySuperCell; private final LinkedHashMap> cache; - private long catalogProbes; - private long shardLoads; + private final Map>> pendingLoads = new HashMap<>(); + private final LongAdder catalogProbes = new LongAdder(); + private final LongAdder shardLoads = new LongAdder(); private CellSource( Path directory, @@ -951,7 +955,7 @@ public final class TerrainBoundarySignatureStore { this.cache = new LinkedHashMap<>(MAXIMUM_CACHED_SHARDS, 0.75F, true); } - private synchronized TerrainBoundarySignature signatureAt(int blockX, int blockZ) throws IOException { + private TerrainBoundarySignature signatureAt(int blockX, int blockZ) throws IOException { int cellX = Math.floorDiv(blockX, CELL_SIZE); int cellZ = Math.floorDiv(blockZ, CELL_SIZE); long cellKey = pack(cellX, cellZ); @@ -967,7 +971,7 @@ public final class TerrainBoundarySignatureStore { return null; } - private synchronized List all() throws IOException { + private List all() throws IOException { ArrayList signatures = new ArrayList<>(); for (long cellKey : cellKeys) { signatures.addAll(load(cellKey, cells.get(cellKey))); @@ -978,7 +982,7 @@ public final class TerrainBoundarySignatureStore { return List.copyOf(signatures); } - private synchronized List nearestCandidatesForChunk( + private List nearestCandidatesForChunk( int chunkX, int chunkZ, int searchWidth @@ -1019,7 +1023,7 @@ public final class TerrainBoundarySignatureStore { return nearest.candidates(); } - private synchronized boolean intersectsTerrainBand(BlockBounds footprint, int width) throws IOException { + private boolean intersectsTerrainBand(BlockBounds footprint, int width) throws IOException { double distanceSquared = (double) width * width; return visitSuperCells(footprint.expanded(width - 1L), superKey -> { if (footprint.distanceSquared(BlockBounds.cell(superKey, CELL_SIZE * SUPER_CELL_SIZE)) @@ -1050,7 +1054,7 @@ public final class TerrainBoundarySignatureStore { long height = (long) maximumZ - minimumZ + 1L; if (width > cellsBySuperCell.size() / height) { for (long key : cellsBySuperCell.keySet()) { - catalogProbes++; + catalogProbes.increment(); if (cellX(key) >= minimumX && cellX(key) <= maximumX && cellZ(key) >= minimumZ && cellZ(key) <= maximumZ && visitor.visit(key)) { @@ -1061,7 +1065,7 @@ public final class TerrainBoundarySignatureStore { } for (long x = minimumX; x <= maximumX; x++) { for (long z = minimumZ; z <= maximumZ; z++) { - catalogProbes++; + catalogProbes.increment(); long key = pack((int) x, (int) z); if (cellsBySuperCell.containsKey(key) && visitor.visit(key)) { return true; @@ -1072,33 +1076,61 @@ public final class TerrainBoundarySignatureStore { } private List load(long cellKey, CellReference reference) throws IOException { - List cached = cache.get(cellKey); - if (cached != null) { - return cached; + CompletableFuture> pending; + boolean loader; + synchronized (this) { + List cached = cache.get(cellKey); + if (cached != null) { + return cached; + } + pending = pendingLoads.get(cellKey); + loader = pending == null; + if (loader) { + pending = new CompletableFuture<>(); + pendingLoads.put(cellKey, pending); + } } - Path shard = directory.resolve(shardFileName(reference.hash())); - requireRegularFile(shard, "Terrain boundary shard"); - List loaded = CellShard.read( - shard, - reference.hash(), - cellX(cellKey), - cellZ(cellKey) - ); - if (loaded.size() != reference.count()) { - throw new IOException("Terrain boundary shard count does not match its catalog entry"); + if (!loader) { + try { + return pending.join(); + } catch (CompletionException failure) { + if (failure.getCause() instanceof IOException io) { + throw io; + } + throw failure; + } + } + try { + Path shard = directory.resolve(shardFileName(reference.hash())); + requireRegularFile(shard, "Terrain boundary shard"); + List loaded = CellShard.read( + shard, reference.hash(), cellX(cellKey), cellZ(cellKey)); + if (loaded.size() != reference.count()) { + throw new IOException("Terrain boundary shard count does not match its catalog entry"); + } + synchronized (this) { + shardLoads.increment(); + cache.put(cellKey, loaded); + trim(cache); + } + pending.complete(loaded); + return loaded; + } catch (IOException | RuntimeException | Error failure) { + pending.completeExceptionally(failure); + throw failure; + } finally { + synchronized (this) { + pendingLoads.remove(cellKey, pending); + } } - shardLoads++; - cache.put(cellKey, loaded); - trim(cache); - return loaded; } - private synchronized long catalogProbeCount() { - return catalogProbes; + private long catalogProbeCount() { + return catalogProbes.sum(); } - private synchronized long shardLoadCount() { - return shardLoads; + private long shardLoadCount() { + return shardLoads.sum(); } private synchronized int cachedShardCount() { diff --git a/core/src/main/java/art/arcane/iris/engine/history/TransitionBoundarySampler.java b/core/src/main/java/art/arcane/iris/engine/history/TransitionBoundarySampler.java index a4f807c38..5247556c2 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/TransitionBoundarySampler.java +++ b/core/src/main/java/art/arcane/iris/engine/history/TransitionBoundarySampler.java @@ -3,21 +3,26 @@ package art.arcane.iris.engine.history; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; final class TransitionBoundarySampler { private static final int NEIGHBOUR_COUNT = 4; - private static final int MAXIMUM_CACHED_CHUNKS = 8; + private static final int MAXIMUM_CACHED_CHUNKS = 512; + private static final int MAXIMUM_CACHED_CANDIDATES = 32_768; private final int terrainWidth; private final int searchWidth; private final TerrainBoundarySignatureStore.Snapshot signatures; private final LinkedHashMap chunkCandidates; + private final Map> pendingCandidates = new HashMap<>(); private final ThreadLocal cache; + private int cachedCandidates; private long candidateBuildCount; TransitionBoundarySampler( @@ -35,12 +40,7 @@ final class TransitionBoundarySampler { } TransitionGenerationPlan.TerrainSample sample(int blockX, int blockZ) { - ChunkCache chunkCache = cache.get(); - int chunkX = Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE); - int chunkZ = Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE); - if (chunkCache.chunkX != chunkX || chunkCache.chunkZ != chunkZ) { - chunkCache.reset(chunkX, chunkZ, candidatesForChunk(chunkX, chunkZ)); - } + ChunkCache chunkCache = chunkCache(blockX, blockZ); int localX = Math.floorMod(blockX, GenerationBoundary.CHUNK_SIZE); int localZ = Math.floorMod(blockZ, GenerationBoundary.CHUNK_SIZE); int index = localX * GenerationBoundary.CHUNK_SIZE + localZ; @@ -58,9 +58,7 @@ final class TransitionBoundarySampler { } BoundaryGeometryInfluence geometryAt(int blockX, int blockZ) { - CandidateIndex candidates = candidatesForChunk( - Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE), - Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE)); + CandidateIndex candidates = chunkCache(blockX, blockZ).candidates; Nearest nearest = candidates.nearest(blockX, blockZ, square(terrainWidth)); if (nearest.count == 0 || nearest.distancesSquared[0] >= square(terrainWidth)) { return BoundaryGeometryInfluence.none(); @@ -132,33 +130,59 @@ final class TransitionBoundarySampler { private CandidateIndex candidatesForChunk(int chunkX, int chunkZ) { long chunkKey = pack(chunkX, chunkZ); + CompletableFuture pending; + boolean builder; synchronized (this) { CandidateIndex existing = chunkCandidates.get(chunkKey); if (existing != null) { return existing; } + pending = pendingCandidates.get(chunkKey); + builder = pending == null; + if (builder) { + pending = new CompletableFuture<>(); + pendingCandidates.put(chunkKey, pending); + } } - CandidateIndex built = CandidateIndex.build(signatures.nearestCandidatesForChunk( - chunkX, - chunkZ, - searchWidth - )); - synchronized (this) { - CandidateIndex raced = chunkCandidates.get(chunkKey); - if (raced != null) { - return raced; - } - chunkCandidates.put(chunkKey, built); - candidateBuildCount++; - while (chunkCandidates.size() > MAXIMUM_CACHED_CHUNKS) { - Iterator> entries = chunkCandidates.entrySet().iterator(); - entries.next(); - entries.remove(); + if (!builder) { + return pending.join(); + } + try { + CandidateIndex built = CandidateIndex.build(signatures.nearestCandidatesForChunk( + chunkX, chunkZ, searchWidth)); + synchronized (this) { + chunkCandidates.put(chunkKey, built); + cachedCandidates += built.size; + candidateBuildCount++; + while (chunkCandidates.size() > MAXIMUM_CACHED_CHUNKS + || cachedCandidates > MAXIMUM_CACHED_CANDIDATES) { + Iterator> entries = chunkCandidates.entrySet().iterator(); + cachedCandidates -= entries.next().getValue().size; + entries.remove(); + } } + pending.complete(built); return built; + } catch (RuntimeException | Error failure) { + pending.completeExceptionally(failure); + throw failure; + } finally { + synchronized (this) { + pendingCandidates.remove(chunkKey, pending); + } } } + private ChunkCache chunkCache(int blockX, int blockZ) { + ChunkCache chunkCache = cache.get(); + int chunkX = Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE); + int chunkZ = Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE); + if (chunkCache.chunkX != chunkX || chunkCache.chunkZ != chunkZ) { + chunkCache.reset(chunkX, chunkZ, candidatesForChunk(chunkX, chunkZ)); + } + return chunkCache; + } + private static double weightedHeight(Nearest nearest, boolean surface) { if (nearest.distancesSquared[0] <= 1D) { return surface @@ -208,12 +232,14 @@ final class TransitionBoundarySampler { } private static final class CandidateIndex { - private static final CandidateIndex EMPTY = new CandidateIndex(null); + private static final CandidateIndex EMPTY = new CandidateIndex(null, 0); private final Node root; + private final int size; - private CandidateIndex(Node root) { + private CandidateIndex(Node root, int size) { this.root = root; + this.size = size; } private static CandidateIndex build(List candidates) { @@ -221,7 +247,7 @@ final class TransitionBoundarySampler { return EMPTY; } TerrainBoundarySignature[] values = candidates.toArray(new TerrainBoundarySignature[0]); - return new CandidateIndex(build(values, 0, values.length, 0)); + return new CandidateIndex(build(values, 0, values.length, 0), values.length); } private Nearest nearest(int blockX, int blockZ, double maximumDistanceSquared) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index 96de95d44..3b95d49b0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -22,13 +22,16 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.compat.CompatStatus; import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.common.data.VectorMap; import art.arcane.iris.util.common.math.AxisAlignedBB; import art.arcane.iris.util.common.math.IrisBlockVector; @@ -288,6 +291,10 @@ public class IrisObject extends IrisRegistrant { states.remove(v); } else { BlockData data = block.getBlockData(); + ExternalDataSVC externalData = IrisServices.getOrNull(ExternalDataSVC.class); + if (externalData != null) { + data = externalData.captureBlockData(data); + } blocks.put(v, BukkitBlockState.of(data)); TileData state = TileData.getTileState(block, legacy); if (state != null) { @@ -363,10 +370,10 @@ public class IrisObject extends IrisRegistrant { public void place(Location at) { readLock.lock(); try { - for (var entry : blocks) { - var i = entry.getKey(); + for (Map.Entry entry : blocks) { + IrisBlockVector i = entry.getKey(); Block b = at.clone().add(0, getCenter().getY(), 0).add(i.getX(), i.getY(), i.getZ()).getBlock(); - b.setBlockData((BlockData) Objects.requireNonNull(entry.getValue()).nativeHandle(), false); + placeBlock(b, (BlockData) Objects.requireNonNull(entry.getValue()).nativeHandle()); if (states.containsKey(i)) { IrisLogging.info(Objects.requireNonNull(states.get(i)).toString()); @@ -381,10 +388,10 @@ public class IrisObject extends IrisRegistrant { public void placeCenterY(Location at) { readLock.lock(); try { - for (var entry : blocks) { - var i = entry.getKey(); + for (Map.Entry entry : blocks) { + IrisBlockVector i = entry.getKey(); Block b = at.clone().add(getCenter().getX(), getCenter().getY(), getCenter().getZ()).add(i.getX(), i.getY(), i.getZ()).getBlock(); - b.setBlockData((BlockData) Objects.requireNonNull(entry.getValue()).nativeHandle(), false); + placeBlock(b, (BlockData) Objects.requireNonNull(entry.getValue()).nativeHandle()); if (states.containsKey(i)) { Objects.requireNonNull(states.get(i)).toBukkitTry(b); @@ -406,6 +413,7 @@ public class IrisObject extends IrisRegistrant { } } + public int volume() { return blocks.size(); } @@ -429,4 +437,18 @@ public class IrisObject extends IrisRegistrant { public CompatStatus evaluateCompat(ContentGate gate) { return CompatStatus.OK; } + private static void placeBlock(Block block, BlockData data) { + if (data instanceof IrisCustomData custom) { + ExternalDataSVC external = IrisServices.getOrNull(ExternalDataSVC.class); + if (external != null && external.placeBlock(block, custom.getCustom())) { + return; + } + block.setBlockData(custom.getBase(), false); + IrisLogging.warnOnce("object-paste:external:" + custom.getCustom(), + "Direct placement of %s is unavailable; only its base block was placed.", custom.getCustom()); + return; + } + block.setBlockData(data, false); + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java index cd3b5b091..1eb46550d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java @@ -138,7 +138,7 @@ public final class IrisObjectIO { for (int i = 0; i < s; i++) { IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()); PlatformBlockState data = resolvePaletteState(self, din.readUTF()); - if (isStructureMarker(data)) { + if (isExcludedObjectBlock(data)) { continue; } self.blocks.put(pos, data); @@ -151,7 +151,7 @@ public final class IrisObjectIO { int size = din.readInt(); for (int i = 0; i < size; i++) { - self.states.put(new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()), TileData.read(din)); + readTile(self, din); } } catch (Throwable e) { IrisLogging.reportError(e); @@ -189,7 +189,7 @@ public final class IrisObjectIO { for (i = 0; i < s; i++) { IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()); PlatformBlockState data = resolved[din.readShort()]; - if (isStructureMarker(data)) { + if (isExcludedObjectBlock(data)) { continue; } self.blocks.put(pos, data); @@ -198,7 +198,15 @@ public final class IrisObjectIO { s = din.readInt(); for (i = 0; i < s; i++) { - self.states.put(new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()), TileData.read(din)); + readTile(self, din); + } + } + + private static void readTile(IrisObject self, DataInputStream input) throws IOException { + IrisBlockVector position = new IrisBlockVector(input.readShort(), input.readShort(), input.readShort()); + TileData tile = TileData.read(input); + if (self.blocks.get(position) != null) { + self.states.put(position, tile); } } @@ -251,12 +259,13 @@ public final class IrisObjectIO { return B.getState(key); } - private static boolean isStructureMarker(PlatformBlockState data) { + private static boolean isExcludedObjectBlock(PlatformBlockState data) { if (data == null) { return false; } String material = IrisObjectShaping.materialKey(data); - return material.equals("minecraft:jigsaw") || material.equals("minecraft:structure_block") || material.equals("minecraft:structure_void"); + return material.equals("minecraft:jigsaw") || material.equals("minecraft:structure_block") + || material.equals("minecraft:structure_void") || material.equals("minecraft:moving_piston"); } /** diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java index 764de193e..b3cba90f2 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java @@ -24,6 +24,7 @@ import art.arcane.iris.core.compat.MissingBlockState; import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.common.math.IrisBlockVector; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -72,6 +73,7 @@ public class IrisObjectRotation { private static final class Faces { private static final List WALL_FACES = List.of(BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST); + } @Desc("If this rotator is enabled or not") @@ -298,7 +300,16 @@ public class IrisObjectRotation { } BlockData original = (BlockData) state.nativeHandle(); - if (!canRotate() || !canRotateBlockData(original)) { + if (!canRotate()) { + return state; + } + if (original instanceof IrisCustomData custom) { + BlockData rotated = BukkitBlockState.rotateCustomData(this, custom, spinx, spiny, spinz); + if (rotated != null) { + return rotated == original ? state : BukkitBlockState.of(rotated); + } + } + if (!canRotateBlockData(original)) { return state; } @@ -317,6 +328,12 @@ public class IrisObjectRotation { } public BlockData rotate(BlockData dd, int spinxx, int spinyy, int spinzz) { + if (canRotate() && dd instanceof IrisCustomData custom) { + BlockData rotated = BukkitBlockState.rotateCustomData(this, custom, spinxx, spinyy, spinzz); + if (rotated != null) { + return rotated; + } + } BlockData d = dd; try { int spinx = (int) (90D * (Math.ceil(Math.abs((spinxx % 360D) / 90D)))); 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 1b687713f..dc00cf9dc 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 @@ -1122,13 +1122,23 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun Runnable activeOperation = Objects.requireNonNull(operation, "Exclusive control operation"); CompletableFuture outward = Objects.requireNonNull(future, "Exclusive control future"); TimeUnit activeUnit = Objects.requireNonNull(unit, "Exclusive control timeout unit"); + if (outward.isCancelled()) { + return; + } + ExclusiveControlAcquisition acquisition = new ExclusiveControlAcquisition(Thread.currentThread()); + outward.whenComplete((ignored, completionFailure) -> { + if (outward.isCancelled()) { + acquisition.cancel(); + } + }); boolean acquired = false; Throwable failure = null; try { - acquired = activeGate.tryAcquireExclusive( - acquisitionTimeout, - activeUnit, - outward::isCancelled); + try { + acquired = activeGate.tryAcquireExclusive(acquisitionTimeout, activeUnit); + } finally { + acquisition.stopWaiting(); + } if (!acquired) { if (!outward.isCancelled()) { failure = new TimeoutException("Timed out waiting for exclusive Iris generation control after " @@ -1138,7 +1148,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun activeOperation.run(); } } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + if (!acquisition.interruptedByCancellation()) { + Thread.currentThread().interrupt(); + } if (!outward.isCancelled()) { failure = e; } @@ -1447,30 +1459,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun permits.acquire(permitCount); } - boolean tryAcquireExclusive( - long timeout, - TimeUnit unit, - BooleanSupplier cancelled - ) throws InterruptedException { + boolean tryAcquireExclusive(long timeout, TimeUnit unit) throws InterruptedException { if (timeout <= 0L) { throw new IllegalArgumentException("Exclusive generation control timeout must be positive."); } TimeUnit activeUnit = Objects.requireNonNull(unit, "Exclusive generation control timeout unit"); - BooleanSupplier cancellation = Objects.requireNonNull(cancelled, "Exclusive generation control cancellation"); - long timeoutNanos = activeUnit.toNanos(timeout); - long started = System.nanoTime(); - long remainingNanos = timeoutNanos; - long cancellationPollNanos = TimeUnit.MILLISECONDS.toNanos(50L); - while (!cancellation.getAsBoolean() && remainingNanos > 0L) { - if (permits.tryAcquire( - permitCount, - Math.min(remainingNanos, cancellationPollNanos), - TimeUnit.NANOSECONDS)) { - return true; - } - remainingNanos = timeoutNanos - (System.nanoTime() - started); - } - return false; + return permits.tryAcquire(permitCount, timeout, activeUnit); } void releaseExclusive() { @@ -1491,6 +1485,34 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } + private static final class ExclusiveControlAcquisition { + private final Thread waiter; + private boolean waiting = true; + private boolean cancellationInterrupt; + + private ExclusiveControlAcquisition(Thread waiter) { + this.waiter = waiter; + } + + private synchronized void cancel() { + if (waiting && !waiter.isInterrupted()) { + cancellationInterrupt = true; + waiter.interrupt(); + } + } + + private synchronized void stopWaiting() { + waiting = false; + if (cancellationInterrupt) { + Thread.interrupted(); + } + } + + private synchronized boolean interruptedByCancellation() { + return cancellationInterrupt; + } + } + static final class GenerationStageExclusivePermit implements AutoCloseable { private final Semaphore permits; private final int permitCount; diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java index 381174d8a..76f291ddc 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java @@ -18,14 +18,24 @@ package art.arcane.iris.platform.bukkit; +import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.container.Pair; +import art.arcane.iris.core.service.ExternalDataSVC; +import art.arcane.iris.engine.object.IrisObjectRotation; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.volmlib.util.collection.KMap; +import org.bukkit.Axis; import org.bukkit.Bukkit; import org.bukkit.Tag; +import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -33,6 +43,15 @@ import java.util.concurrent.ConcurrentHashMap; * Interned Bukkit adapter for a neutral block state backed by BlockData. */ public final class BukkitBlockState implements PlatformBlockState { + private static final Map CUSTOM_NAMED_FACES = Map.of( + "north", BlockFace.NORTH, "south", BlockFace.SOUTH, + "east", BlockFace.EAST, "west", BlockFace.WEST, + "up", BlockFace.UP, "down", BlockFace.DOWN); + private static final List CUSTOM_ROTATION_FACES = List.of( + BlockFace.SOUTH, BlockFace.SOUTH_SOUTH_WEST, BlockFace.SOUTH_WEST, BlockFace.WEST_SOUTH_WEST, + BlockFace.WEST, BlockFace.WEST_NORTH_WEST, BlockFace.NORTH_WEST, BlockFace.NORTH_NORTH_WEST, + BlockFace.NORTH, BlockFace.NORTH_NORTH_EAST, BlockFace.NORTH_EAST, BlockFace.EAST_NORTH_EAST, + BlockFace.EAST, BlockFace.EAST_SOUTH_EAST, BlockFace.SOUTH_EAST, BlockFace.SOUTH_SOUTH_EAST); private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); // Front cache keyed on the BlockData itself (CraftBlockData equals/hashCode delegate to // the canonical NMS state): a hit skips getAsString(), which built the full property @@ -82,6 +101,64 @@ public final class BukkitBlockState implements PlatformBlockState { return state; } + public static BlockData rotateCustomData(IrisObjectRotation objectRotation, IrisCustomData custom, int spinxx, int spinyy, int spinzz) { + Pair> parsed = ExternalDataSVC.parseState(custom.getCustom()); + KMap original = parsed.getB(); + if (original.isEmpty()) { + return null; + } + KMap rotated = new KMap<>(original); + int spinx = (int) (90D * Math.ceil(Math.abs((spinxx % 360D) / 90D))); + int spiny = (int) (90D * Math.ceil(Math.abs((spinyy % 360D) / 90D))); + int spinz = (int) (90D * Math.ceil(Math.abs((spinzz % 360D) / 90D))); + boolean oriented = false; + String facing = original.get("facing"); + if (facing != null && CUSTOM_NAMED_FACES.containsKey(facing)) { + BlockFace face = objectRotation.getFace(rotateFace(objectRotation, CUSTOM_NAMED_FACES.get(facing), spinx, spiny, spinz)); + rotated.put("facing", face.name().toLowerCase(Locale.ROOT)); + oriented = true; + } + Axis axis = switch (original.getOrDefault("axis", "")) { + case "x" -> Axis.X; + case "y" -> Axis.Y; + case "z" -> Axis.Z; + default -> null; + }; + if (axis != null) { + Axis result = objectRotation.getAxis(rotateFace(objectRotation, objectRotation.faceForAxis(axis), spinx, spiny, spinz)); + rotated.put("axis", result.name().toLowerCase(Locale.ROOT)); + oriented = true; + } + int rotation = rotationIndex(original.get("rotation")); + if (rotation >= 0) { + BlockFace face = objectRotation.getHexFace(rotateFace(objectRotation, CUSTOM_ROTATION_FACES.get(rotation), spinx, spiny, spinz)); + int result = CUSTOM_ROTATION_FACES.indexOf(face); + if (result >= 0) { + rotated.put("rotation", Integer.toString(result)); + } + oriented = true; + } + for (Map.Entry entry : CUSTOM_NAMED_FACES.entrySet()) { + String value = original.get(entry.getKey()); + if (value == null) { + continue; + } + String destination = objectRotation.getFace(rotateFace(objectRotation, entry.getValue(), spinx, spiny, spinz)).name().toLowerCase(Locale.ROOT); + if (original.containsKey(destination)) { + rotated.put(destination, value); + } + oriented = true; + } + if (!oriented) { + return null; + } + if (rotated.equals(original)) { + return custom; + } + BlockData resolved = BukkitBlockResolution.resolveOrNull(ExternalDataSVC.buildState(parsed.getA(), rotated).toString()); + return resolved instanceof IrisCustomData ? resolved : custom; + } + @Override public boolean equals(Object other) { if (this == other) { @@ -369,9 +446,17 @@ public final class BukkitBlockState implements PlatformBlockState { @Override public PlatformBlockState withProperty(String name, String value) { - // Re-attach the custom identity (as the proxy's own merge/clone cases do) after - // editing the base block, so auto-waterlogging cannot turn custom blocks into vanilla. if (data instanceof IrisCustomData custom) { + if (ExternalDataSVC.parseState(custom.getCustom()).getB().containsKey(name)) { + String merged = mergeProperty(key, name, value); + BlockData resolved = BukkitBlockResolution.resolveOrNull(merged); + if (!(resolved instanceof IrisCustomData)) { + throw new IllegalArgumentException("Cannot resolve custom block state " + merged); + } + return of(resolved); + } + // Re-attach the custom identity (as the proxy's own merge/clone cases do) after + // editing the base block, so auto-waterlogging cannot turn custom blocks into vanilla. String merged = mergeProperty(custom.getBase().getAsString(), name, value); BlockData resolved = Bukkit.createBlockData(merged); return of(IrisCustomData.of(resolved, custom.getCustom())); @@ -383,4 +468,20 @@ public final class BukkitBlockState implements PlatformBlockState { public Object nativeHandle() { return data; } + + private static IrisBlockVector rotateFace(IrisObjectRotation objectRotation, BlockFace face, int spinx, int spiny, int spinz) { + return objectRotation.rotate(new IrisBlockVector(face.getModX(), face.getModY(), face.getModZ()), spinx, spiny, spinz); + } + + private static int rotationIndex(String value) { + if (value == null) { + return -1; + } + try { + int rotation = Integer.parseInt(value); + return rotation >= 0 && rotation < CUSTOM_ROTATION_FACES.size() ? rotation : -1; + } catch (NumberFormatException ignored) { + return -1; + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java b/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java index 536a24d88..1ad72557f 100644 --- a/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java @@ -7,6 +7,8 @@ import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.BiomeEnvironment; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.engine.framework.GenerationTransitionGate; import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.object.IrisBiome; @@ -35,7 +37,14 @@ import org.mockito.MockedStatic; import java.lang.reflect.Field; import java.util.ArrayDeque; +import java.util.List; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -195,6 +204,70 @@ public class BoardSVCSavedBiomeTest { } } + @Test + public void ordinaryRefreshLeavesOwnerThreadAvailableForStudioCheckpoint() throws Exception { + GenerationSessionManager sessions = new GenerationSessionManager(true); + AtomicInteger height = new AtomicInteger(70); + AtomicInteger checkpoints = new AtomicInteger(); + ExecutorService owner = Executors.newSingleThreadExecutor(); + ExecutorService reloader = Executors.newSingleThreadExecutor(); + Fixture fixture = null; + try { + fixture = owner.submit(() -> { + Fixture created = new Fixture(); + when(created.engine.getGenerationSessions()).thenReturn(sessions); + when(created.engine.getHeight(8, 8)).thenAnswer(invocation -> { + try (GenerationTransitionGate.Participation ignored = sessions.transitionGate().enter()) { + return height.get(); + } + }); + created.service.updatePlayer(created.player); + return created; + }).get(3L, TimeUnit.SECONDS); + Fixture board = fixture; + List previous = board.service.getLines(board.player); + assertTrue(board.lines().contains("70")); + + CompletableFuture cutover = CompletableFuture.runAsync(() -> { + try (GenerationTransitionGate.Transition ignored = sessions.transitionGate().beginTransition(2_000L)) { + sessions.sealAndAwait("Studio generation cutover", 2_000L); + Future refresh = owner.submit(board::tick); + owner.submit(checkpoints::incrementAndGet).get(2L, TimeUnit.SECONDS); + refresh.get(2L, TimeUnit.SECONDS); + assertEquals(previous, board.service.getLines(board.player)); + verify(board.engine, times(2)).getGeneratedPerSecond(); + verify(board.engine, times(1)).getBiomeOrMantleEnvironment(8, 160, 8); + verify(board.engine, times(1)).getHeight(8, 8); + height.set(85); + sessions.activateNextSession(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }, reloader); + cutover.get(3L, TimeUnit.SECONDS); + + assertEquals(1, checkpoints.get()); + owner.submit(board::tick).get(2L, TimeUnit.SECONDS); + assertTrue(board.lines().contains("85")); + assertEquals(1, board.ticks.size()); + assertEquals(0, sessions.activeLeases()); + verify(board.engine, times(2)).getBiomeOrMantleEnvironment(8, 160, 8); + verify(board.engine, times(2)).getHeight(8, 8); + } finally { + try { + if (fixture != null) { + Fixture board = fixture; + owner.submit(board::close).get(2L, TimeUnit.SECONDS); + } + } finally { + owner.shutdownNow(); + reloader.shutdownNow(); + assertTrue(owner.awaitTermination(2L, TimeUnit.SECONDS)); + assertTrue(reloader.awaitTermination(2L, TimeUnit.SECONDS)); + } + } + } + private static BiomeEnvironment environment(String regionName, String biomeName) { IrisRegion region = mock(IrisRegion.class); IrisBiome biome = mock(IrisBiome.class); diff --git a/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCBlockRoutingTest.java b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCBlockRoutingTest.java index edf027b46..1f886151e 100644 --- a/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCBlockRoutingTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCBlockRoutingTest.java @@ -15,6 +15,7 @@ import org.junit.Test; import java.lang.reflect.Field; import java.util.List; import java.util.MissingResourceException; +import java.util.Optional; import java.util.Set; import static org.junit.Assert.assertEquals; @@ -29,6 +30,43 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class ExternalDataSVCBlockRoutingTest { + @Test + public void directPlacementRetainsQualifiedProviderAndProperties() throws Exception { + Identifier nativeId = Identifier.fromString("default:palm_log"); + Identifier stateId = Identifier.fromString("default:palm_log[axis=x]"); + ExternalDataProvider provider = provider("CraftEngine", nativeId); + Block block = mock(Block.class); + when(provider.placeBlock(block, stateId)).thenReturn(true); + + assertEquals(true, service(provider).placeBlock(block, + Identifier.fromString("craftengine:default/palm_log[axis=x]"))); + verify(provider).placeBlock(block, stateId); + } + + @Test + public void capturedBlocksRetainSemanticPropertiesAndProviderIdentity() throws Exception { + Identifier id = Identifier.fromString("example:palm_log[axis=x]"); + ExternalDataProvider provider = provider("CraftEngine", id); + BlockData base = mock(BlockData.class); + when(base.getAsString()).thenReturn("craftengine:custom_18"); + when(provider.identifyBlock(base)).thenReturn(Optional.of(id)); + ExternalDataSVC service = service(provider); + + IrisCustomData captured = (IrisCustomData) service.captureBlockData(base); + + assertSame(base, captured.getBase()); + assertEquals("craftengine:example/palm_log[axis=x]", captured.getCustom().toString()); + assertSame(captured, service.captureBlockData(captured)); + } + + @Test + public void vanillaCaptureKeepsOriginalBlockData() throws Exception { + ExternalDataProvider provider = provider("CraftEngine", Identifier.fromString("example:wood")); + BlockData base = mock(BlockData.class); + + assertSame(base, service(provider).captureBlockData(base)); + } + @Test public void nativeLookupPinsProviderAndRetainsPropertiesUntilPlacement() throws Exception { Identifier nativeId = Identifier.fromString("example:wood"); diff --git a/core/src/test/java/art/arcane/iris/core/service/ObjectSVCUndoCustomBlockTest.java b/core/src/test/java/art/arcane/iris/core/service/ObjectSVCUndoCustomBlockTest.java new file mode 100644 index 000000000..7ff16f4c6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/ObjectSVCUndoCustomBlockTest.java @@ -0,0 +1,67 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class ObjectSVCUndoCustomBlockTest { + @Test + public void undoRestoresSemanticCustomStateOnItsOwningRegion() { + Block block = mock(Block.class); + Location location = new Location(mock(World.class), 7, 80, -9); + when(block.getLocation()).thenReturn(location); + Identifier id = new Identifier("craftengine", "test/lamp[lit=false]"); + BlockData custom = IrisCustomData.of(mock(BlockData.class), id); + ExternalDataSVC external = mock(ExternalDataSVC.class); + when(external.placeBlock(block, id)).thenReturn(true); + List ownerTasks = new ArrayList<>(); + try (MockedStatic services = mockStatic(IrisServices.class); + MockedStatic scheduling = mockStatic(J.class)) { + services.when(() -> IrisServices.getOrNull(ExternalDataSVC.class)).thenReturn(external); + scheduling.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return true; + }); + scheduling.when(() -> J.s(any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }); + scheduling.when(() -> J.runAt(any(Location.class), any(Runnable.class))).thenAnswer(invocation -> { + ownerTasks.add(invocation.getArgument(1, Runnable.class)); + return true; + }); + ObjectSVC undo = new ObjectSVC(); + undo.addChanges(new HashMap<>(Map.of(block, custom))); + + undo.revertChanges(1); + + assertEquals(1, ownerTasks.size()); + verifyNoInteractions(external); + ownerTasks.getFirst().run(); + verify(external).placeBlock(block, id); + verify(block, times(0)).setBlockData(any(BlockData.class), anyBoolean()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/EngineRuntimeBuilderStudioServiceTest.java b/core/src/test/java/art/arcane/iris/engine/EngineRuntimeBuilderStudioServiceTest.java new file mode 100644 index 000000000..a10d91e0e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/EngineRuntimeBuilderStudioServiceTest.java @@ -0,0 +1,287 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineAssignedWorldManager; +import art.arcane.iris.engine.framework.EngineEffects; +import art.arcane.iris.engine.framework.EngineEffectsProvider; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.framework.EngineWorldManagerProvider; +import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.engine.framework.GenerationTransitionGate; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.spi.IrisServices; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +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.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +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 EngineRuntimeBuilderStudioServiceTest { + @Test + public void replacementInterceptsCommandsWhileOldManagerRetires() throws Exception { + ExecutorService owner = Executors.newSingleThreadExecutor(); + try (Fixture fixture = new Fixture()) { + CompletableFuture teleport = new CompletableFuture<>(); + AtomicInteger requests = new AtomicInteger(); + fixture.previous.afterUnregister = () -> { + try { + owner.submit(() -> fixture.teleportDuringRetirement(teleport, requests)) + .get(2L, TimeUnit.SECONDS); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }; + + try (GenerationTransitionGate.Transition ignored = fixture.sessions.transitionGate().beginTransition(2_000L)) { + fixture.sessions.sealAndAwait("Studio generation cutover", 2_000L); + fixture.builder.refreshStudioRuntimeServices(); + } + + assertSame(fixture.replacement, fixture.engine.runtime.worldManager()); + assertEquals(List.of(fixture.replacement), fixture.registered); + assertEquals(1, requests.get()); + assertFalse(teleport.isDone()); + assertTrue(teleport.complete(true)); + fixture.previous.tick.run(); + fixture.replacement.tick.run(); + assertEquals(0, fixture.previous.updates); + assertEquals(1, fixture.replacement.updates); + verify(fixture.previousEffects).close(); + verify(fixture.nextEffects, never()).close(); + } finally { + owner.shutdownNow(); + assertTrue(owner.awaitTermination(2L, TimeUnit.SECONDS)); + } + } + + @Test + public void failedReplacementStartKeepsThePreviousListenerRegistered() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.replacement.failRegistration = true; + EngineRuntime previous = fixture.engine.runtime; + try (GenerationTransitionGate.Transition ignored = fixture.sessions.transitionGate().beginTransition(2_000L)) { + fixture.sessions.sealAndAwait("Studio generation cutover", 2_000L); + + assertThrows(IllegalStateException.class, fixture.builder::refreshStudioRuntimeServices); + } + + assertSame(previous, fixture.engine.runtime); + assertEquals(List.of(fixture.previous), fixture.registered); + assertEquals(0, fixture.previous.closes); + assertEquals(1, fixture.replacement.closes); + assertEquals(1L, fixture.sessions.currentSessionId()); + verify(fixture.previousEffects, never()).close(); + verify(fixture.nextEffects).close(); + } + } + + private static final class Fixture implements AutoCloseable { + private final IrisEngine engine = mock(IrisEngine.class); + private final GenerationSessionManager sessions = new GenerationSessionManager(true); + private final AtomicBoolean closing = new AtomicBoolean(true); + private final List registered = new CopyOnWriteArrayList<>(); + private final EngineEffects previousEffects = mock(EngineEffects.class); + private final EngineEffects nextEffects = mock(EngineEffects.class); + private final IrisWorld irisWorld = mock(IrisWorld.class); + private final World world = mock(World.class); + private final TestWorldManager previous; + private final TestWorldManager replacement; + private final EngineRuntimeBuilder builder; + private final MockedStatic services; + + private Fixture() throws Exception { + EngineTarget target = mock(EngineTarget.class); + when(engine.getTarget()).thenReturn(target); + when(target.getWorld()).thenReturn(irisWorld); + when(engine.isStudio()).thenReturn(true); + when(engine.isClosing()).thenAnswer(invocation -> closing.get()); + when(engine.isShuttingDown()).thenCallRealMethod(); + when(engine.getClosing()).thenReturn(closing); + when(engine.getGenerationSessions()).thenReturn(sessions); + engine.lifecycleState = IrisEngine.LifecycleState.HOTLOADING; + EngineBackgroundTasks background = mock(EngineBackgroundTasks.class); + when(background.scheduleTrackedTask(any(Runnable.class))).thenReturn(true); + Field backgroundField = IrisEngine.class.getDeclaredField("backgroundTasks"); + backgroundField.setAccessible(true); + backgroundField.set(engine, background); + previous = new TestWorldManager(engine, registered); + replacement = new TestWorldManager(engine, registered); + engine.runtime = new EngineRuntime(mock(GenerationRuntime.class), previousEffects, previous); + previous.start(); + builder = new EngineRuntimeBuilder(engine); + EngineEffectsProvider effectsProvider = mock(EngineEffectsProvider.class); + EngineWorldManagerProvider managerProvider = mock(EngineWorldManagerProvider.class); + when(effectsProvider.create(engine)).thenReturn(nextEffects); + when(managerProvider.create(engine)).thenReturn(replacement); + services = mockStatic(IrisServices.class); + services.when(() -> IrisServices.get(EngineEffectsProvider.class)).thenReturn(effectsProvider); + services.when(() -> IrisServices.get(EngineWorldManagerProvider.class)).thenReturn(managerProvider); + } + + private void teleportDuringRetirement(CompletableFuture teleport, AtomicInteger requests) { + Player player = mock(Player.class); + Location destination = new Location(world, 1024.5D, 309D, 0.5D, 45F, 10F); + PlayerTeleportEvent event = new PlayerTeleportEvent(player, + new Location(world, 0.5D, 100D, 0.5D), destination, + PlayerTeleportEvent.TeleportCause.COMMAND); + try (MockedStatic platform = mockStatic(BukkitPlatform.class); + MockedStatic binding = mockStatic(BukkitWorldBinding.class)) { + platform.when(BukkitPlatform::isPaperServer).thenReturn(true); + binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world); + platform.when(() -> BukkitPlatform.teleportAsync(same(player), any(Location.class), + eq(PlayerTeleportEvent.TeleportCause.COMMAND))) + .thenAnswer(invocation -> { + assertTrue(event.isCancelled()); + Location requested = invocation.getArgument(1, Location.class); + assertEquals(destination, requested); + assertNotSame(destination, requested); + requests.incrementAndGet(); + return teleport; + }); + assertEquals(List.of(replacement), registered); + for (TestWorldManager manager : registered) { + if (!event.isCancelled()) { + manager.on(event); + } + } + assertTrue(event.isCancelled()); + previous.tick.run(); + replacement.tick.run(); + assertEquals(0, previous.updates); + assertEquals(0, replacement.updates); + assertEquals(0, sessions.activeLeases()); + } + } + + @Override + public void close() { + previous.afterUnregister = null; + previous.close(); + replacement.close(); + services.close(); + } + } + + private static final class TestWorldManager extends EngineAssignedWorldManager { + private final List registered; + private final WorldTeleportWarmup warmup = new WorldTeleportWarmup(); + private Runnable tick; + private Runnable afterUnregister; + private boolean failRegistration; + private int updates; + private int closes; + + private TestWorldManager(Engine engine, List registered) { + super(engine); + this.registered = registered; + } + + @Override + public void close() { + closes++; + super.close(); + } + + @Override + protected void registerManagerListener() { + registered.add(this); + if (failRegistration) { + throw new IllegalStateException("listener registration failed"); + } + } + + @Override + protected void unregisterManagerListener() { + registered.remove(this); + if (afterUnregister != null) { + afterUnregister.run(); + } + } + + @Override + protected int scheduleManagerTick(Runnable tick) { + this.tick = tick; + return 42; + } + + @Override + protected void cancelManagerTick(int scheduledTaskId) { + } + + @Override + public int getEntityCount() { + return 0; + } + + @Override + public int getChunkCount() { + return 0; + } + + @Override + public double getEntitySaturation() { + return 0D; + } + + @Override + public void onTick() { + updates++; + } + + @Override + public void onSave() { + } + + @Override + public void onBlockBreak(BlockBreakEvent event) { + } + + @Override + public void onBlockPlace(BlockPlaceEvent event) { + } + + @Override + public void onChunkLoad(Chunk chunk, boolean generated) { + } + + @Override + public void onChunkUnload(Chunk chunk) { + } + + @Override + public void teleportAsync(PlayerTeleportEvent event) { + warmup.teleportAsync(event); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineLifecycleGateTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineLifecycleGateTest.java index 7001d412a..ce7a6199d 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisEngineLifecycleGateTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineLifecycleGateTest.java @@ -6,8 +6,38 @@ import java.util.concurrent.TimeoutException; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class IrisEngineLifecycleGateTest { + @Test + public void studioSessionSealDoesNotMeanWorldShutdown() { + IrisEngine engine = mock(IrisEngine.class); + when(engine.isShuttingDown()).thenCallRealMethod(); + when(engine.isClosing()).thenReturn(true); + engine.lifecycleState = IrisEngine.LifecycleState.HOTLOADING; + + assertTrue(engine.isClosing()); + assertFalse(engine.isShuttingDown()); + + engine.lifecycleState = IrisEngine.LifecycleState.CLOSING; + assertTrue(engine.isShuttingDown()); + engine.lifecycleState = IrisEngine.LifecycleState.CLOSED; + assertTrue(engine.isShuttingDown()); + engine.lifecycleState = IrisEngine.LifecycleState.FAILED; + assertTrue(engine.isShuttingDown()); + } + + @Test + public void closedWorldRejectsNavigationRegardlessOfLifecyclePublication() { + IrisEngine engine = mock(IrisEngine.class); + when(engine.isShuttingDown()).thenCallRealMethod(); + engine.lifecycleState = IrisEngine.LifecycleState.RUNNING; + engine.closed = true; + + assertTrue(engine.isShuttingDown()); + } + @Test public void incompleteBackgroundDrainBlocksResourceRelease() { EngineBackgroundTasks.BackgroundTaskDrain drain = new EngineBackgroundTasks.BackgroundTaskDrain( diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerTeleportTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerTeleportTest.java index e9e593ee9..29b0be808 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerTeleportTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/EngineAssignedWorldManagerTeleportTest.java @@ -12,10 +12,20 @@ import org.bukkit.event.player.PlayerTeleportEvent; import org.junit.Test; import org.mockito.MockedStatic; +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 static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; 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 EngineAssignedWorldManagerTeleportTest { @@ -30,6 +40,68 @@ public class EngineAssignedWorldManagerTeleportTest { } } + @Test + public void commandTeleportStillConvertsDuringSealedStudioCutover() throws Exception { + ExecutorService reloader = Executors.newSingleThreadExecutor(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (TeleportHarness harness = new TeleportHarness(false, true)) { + when(harness.engine.isClosing()).thenReturn(true); + when(harness.engine.isShuttingDown()).thenReturn(false); + Location destination = harness.event.getTo(); + Future cutover = reloader.submit(() -> { + try (GenerationTransitionGate.Transition ignored = harness.sessions.transitionGate().beginTransition(2_000L)) { + harness.sessions.sealAndAwait("Studio generation cutover", 2_000L); + entered.countDown(); + assertTrue(release.await(2L, TimeUnit.SECONDS)); + harness.sessions.activateNextSession(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }); + assertTrue(entered.await(2L, TimeUnit.SECONDS)); + + harness.manager.on(harness.event); + + assertSame(harness.event, harness.manager.teleportEvent); + assertSame(destination, harness.manager.teleportEvent.getTo()); + assertEquals(PlayerTeleportEvent.TeleportCause.COMMAND, harness.manager.teleportEvent.getCause()); + assertEquals(0, harness.sessions.activeLeases()); + release.countDown(); + cutover.get(2L, TimeUnit.SECONDS); + } finally { + release.countDown(); + reloader.shutdownNow(); + assertTrue(reloader.awaitTermination(2L, TimeUnit.SECONDS)); + } + } + + @Test + public void closingStudioCancelsCommandInsteadOfFallingThroughToVanilla() { + try (TeleportHarness harness = new TeleportHarness(false, true)) { + when(harness.engine.isClosing()).thenReturn(true); + when(harness.engine.isShuttingDown()).thenReturn(true); + + harness.manager.on(harness.event); + + verify(harness.event).setCancelled(true); + assertNull(harness.manager.teleportEvent); + } + } + + @Test + public void closedStudioCancelsEvenAnAlreadyLoadedDestination() { + try (TeleportHarness harness = new TeleportHarness(true, true)) { + when(harness.engine.isClosed()).thenReturn(true); + when(harness.engine.isShuttingDown()).thenReturn(true); + + harness.manager.on(harness.event); + + verify(harness.event).setCancelled(true); + assertNull(harness.manager.teleportEvent); + } + } + @Test public void loadedIrisDestinationContinuesWithoutInterception() { TeleportHarness harness = new TeleportHarness(true, true); @@ -80,16 +152,20 @@ public class EngineAssignedWorldManagerTeleportTest { public void otherWorldDestinationContinuesWithoutInterception() { TeleportHarness harness = new TeleportHarness(false, true); World otherWorld = mock(World.class); + when(harness.engine.isClosing()).thenReturn(true); + when(harness.engine.isShuttingDown()).thenReturn(true); when(harness.event.getTo()).thenReturn(new Location(otherWorld, 400.5D, 96D, 2.5D)); try (harness) { harness.manager.on(harness.event); assertNull(harness.manager.teleportEvent); + verify(harness.event, never()).setCancelled(true); } } private static final class TeleportHarness implements AutoCloseable { + private final GenerationSessionManager sessions = new GenerationSessionManager(true); private final Engine engine; private final TestWorldManager manager; private final PlayerTeleportEvent event; @@ -106,6 +182,7 @@ public class EngineAssignedWorldManagerTeleportTest { when(engine.getTarget()).thenReturn(target); when(engine.isStudio()).thenReturn(true); + when(engine.getGenerationSessions()).thenReturn(sessions); when(target.getWorld()).thenReturn(irisWorld); when(event.getTo()).thenReturn(destination); when(event.getCause()).thenReturn(PlayerTeleportEvent.TeleportCause.COMMAND); @@ -116,10 +193,12 @@ public class EngineAssignedWorldManagerTeleportTest { binding = mockStatic(BukkitWorldBinding.class); binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world); manager = new TestWorldManager(engine); + manager.start(); } @Override public void close() { + manager.close(); binding.close(); platform.close(); } @@ -133,9 +212,20 @@ public class EngineAssignedWorldManagerTeleportTest { } @Override - protected boolean runManagerTask(String operation, Runnable task) { - task.run(); - return true; + protected void registerManagerListener() { + } + + @Override + protected int scheduleManagerTick(Runnable tick) { + return 0; + } + + @Override + protected void unregisterManagerListener() { + } + + @Override + protected void cancelManagerTick(int scheduledTaskId) { } @Override diff --git a/core/src/test/java/art/arcane/iris/engine/history/BoundarySamplingConcurrencyTest.java b/core/src/test/java/art/arcane/iris/engine/history/BoundarySamplingConcurrencyTest.java new file mode 100644 index 000000000..25a062b1a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/BoundarySamplingConcurrencyTest.java @@ -0,0 +1,224 @@ +package art.arcane.iris.engine.history; + +import art.arcane.iris.testsupport.DurabilityMode; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.lang.reflect.Field; +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.OptionalInt; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +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.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BoundarySamplingConcurrencyTest { + private static final BoundaryColumnGeometry GEOMETRY = new BoundaryColumnGeometry(0, + List.of(new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, + "", false)), new int[]{65}, new short[]{0}); + + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void simultaneousQueriesBuildOneCandidateIndex() throws Exception { + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + CountDownLatch scanning = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(snapshot.nearestCandidatesForChunk(0, 0, 32)).thenAnswer(invocation -> { + scanning.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return List.of(signature(0, 0)); + }); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(32, snapshot); + ExecutorService workers = Executors.newFixedThreadPool(8); + CyclicBarrier start = new CyclicBarrier(8); + List> queries = new ArrayList<>(); + try { + for (int index = 0; index < 8; index++) { + queries.add(workers.submit(() -> { + start.await(5, TimeUnit.SECONDS); + return sampler.geometryAt(1, 1); + })); + } + assertTrue(scanning.await(5, TimeUnit.SECONDS)); + release.countDown(); + BoundaryGeometryInfluence expected = queries.getFirst().get(5, TimeUnit.SECONDS); + for (Future query : queries) { + assertEquals(expected, query.get(5, TimeUnit.SECONDS)); + } + verify(snapshot, times(1)).nearestCandidatesForChunk(0, 0, 32); + assertEquals(1L, sampler.candidateBuildCount()); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void failedCandidateBuildCanBeRetried() { + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + when(snapshot.nearestCandidatesForChunk(0, 0, 32)) + .thenThrow(new IllegalStateException("Unavailable boundary shard")) + .thenReturn(List.of(signature(0, 0))); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(32, snapshot); + + assertThrows(IllegalStateException.class, () -> sampler.sample(1, 1)); + assertEquals(64D, sampler.sample(1, 1).historicalSurfaceHeight(), 0D); + verify(snapshot, times(2)).nearestCandidatesForChunk(0, 0, 32); + } + + @Test + public void generationWindowReusesCandidatesAndEvictsPastItsChunkBound() { + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + when(snapshot.nearestCandidatesForChunk(anyInt(), anyInt(), anyInt())).thenReturn(List.of()); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(32, snapshot); + for (int chunk = 0; chunk < 512; chunk++) { + sampler.geometryAt(chunk * 16, 0); + } + sampler.geometryAt(0, 0); + assertEquals(512L, sampler.candidateBuildCount()); + sampler.geometryAt(512 * 16, 0); + sampler.geometryAt(16, 0); + assertEquals(514L, sampler.candidateBuildCount()); + } + + @Test + public void denseCandidateIndexesRespectTheReferenceBudget() { + List dense = new ArrayList<>(); + for (int x = 0; x < 32; x++) { + for (int z = 0; z < 32; z++) { + dense.add(signature(x, z)); + } + } + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + when(snapshot.nearestCandidatesForChunk(anyInt(), anyInt(), anyInt())).thenReturn(dense); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(32, snapshot); + for (int chunk = 0; chunk < 33; chunk++) { + sampler.geometryAt(chunk * 16, 0); + } + sampler.geometryAt(0, 0); + assertEquals(34L, sampler.candidateBuildCount()); + } + + @Test + public void activeGeometryColumnKeepsItsCandidatesAfterSharedCacheEviction() throws Exception { + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + when(snapshot.nearestCandidatesForChunk(anyInt(), anyInt(), anyInt())).thenReturn(List.of(signature(0, 0))); + TransitionBoundarySampler sampler = new TransitionBoundarySampler(32, snapshot); + BoundaryGeometryInfluence first = sampler.geometryAt(1, 1); + ExecutorService other = Executors.newSingleThreadExecutor(); + try { + other.submit(() -> { + for (int chunk = 1; chunk <= 512; chunk++) { + sampler.geometryAt(chunk * 16, 0); + } + }).get(5, TimeUnit.SECONDS); + assertEquals(first, sampler.geometryAt(1, 1)); + verify(snapshot, times(1)).nearestCandidatesForChunk(0, 0, 32); + } finally { + other.shutdownNow(); + assertTrue(other.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void distinctBoundaryScansDoNotHoldTheShardCacheMonitor() throws Exception { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("parallel-scans").toPath()); + TerrainBoundarySignatureStore.Snapshot snapshot = store.publish(1L, + List.of(signature(0, 0), signature(512, 512))); + snapshot.signatureAt(0, 0).orElseThrow(); + snapshot.signatureAt(512, 512).orElseThrow(); + Map> cache = shardCache(snapshot); + BlockingSignatures blocked = new BlockingSignatures(cache.get(0L)); + cache.put(0L, blocked); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future> first = workers.submit( + () -> snapshot.nearestCandidatesForChunk(0, 0, 16)); + assertTrue(blocked.entered.await(5, TimeUnit.SECONDS)); + Future> distant = workers.submit( + () -> snapshot.nearestCandidatesForChunk(32, 32, 16)); + assertEquals(512, distant.get(3, TimeUnit.SECONDS).getFirst().blockX()); + assertFalse(first.isDone()); + assertTrue(workers.submit(() -> snapshot.intersectsTerrainBand( + new TerrainBoundarySignatureStore.BlockBounds(512, 512, 527, 527), 16)) + .get(3, TimeUnit.SECONDS)); + blocked.release.countDown(); + assertEquals(0, first.get(3, TimeUnit.SECONDS).getFirst().blockX()); + assertEquals(2L, snapshot.shardLoadCount()); + } finally { + blocked.release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @SuppressWarnings("unchecked") + private static Map> shardCache( + TerrainBoundarySignatureStore.Snapshot snapshot) throws Exception { + Field sourceField = TerrainBoundarySignatureStore.Snapshot.class.getDeclaredField("source"); + sourceField.setAccessible(true); + Object source = sourceField.get(snapshot); + Field cacheField = source.getClass().getDeclaredField("cache"); + cacheField.setAccessible(true); + return (Map>) cacheField.get(source); + } + + private static TerrainBoundarySignature signature(int x, int z) { + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(x, z, 64, 64, OptionalInt.empty(), OptionalInt.empty()), + new TerrainBoundarySignature.Samples(new TerrainBoundarySignature.VerticalLayout(0, 4, 0), + new TerrainBoundarySignature.BiomeEncoding(List.of(), new short[0])), + GEOMETRY); + } + + private static final class BlockingSignatures extends AbstractList { + private final List values; + private final CountDownLatch entered = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + + private BlockingSignatures(List values) { + this.values = values; + } + + @Override + public TerrainBoundarySignature get(int index) { + entered.countDown(); + try { + assertTrue(release.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new AssertionError(interruption); + } + return values.get(index); + } + + @Override + public int size() { + return values.size(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryStoreTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryStoreTest.java index 3e782eca2..a1c821a4f 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryStoreTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationBoundaryStoreTest.java @@ -262,6 +262,76 @@ public class GenerationBoundaryStoreTest { } } + @Test + public void repeatedRegionQueriesDoNotWaitForBoundaryIteration() throws Exception { + GenerationBoundaryStore store = new GenerationBoundaryStore( + temporaryFolder.newFolder("boundary-warm-region").toPath()); + GenerationBoundary boundary = store.publishPacked(1L, + new long[]{GenerationBoundary.packChunk(-1, -1)}); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ExecutorService queries = Executors.newSingleThreadExecutor(); + ExecutorService iterator = Executors.newSingleThreadExecutor(); + try { + assertTrue(queries.submit(() -> boundary.isHistoricalChunk(-1, -1)).get(5, TimeUnit.SECONDS)); + Future iteration = iterator.submit(() -> { + boundary.forEachHistoricalChunk((x, z) -> { + entered.countDown(); + try { + assertTrue(release.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted boundary iteration", interruption); + } + }); + return null; + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Future warm = queries.submit(() -> { + for (int x = -32; x < -1; x++) { + assertFalse(boundary.isHistoricalChunk(x, -1)); + } + return boundary.isHistoricalChunk(-1, -1); + }); + assertTrue(warm.get(3, TimeUnit.SECONDS)); + assertFalse(iteration.isDone()); + release.countDown(); + iteration.get(5, TimeUnit.SECONDS); + assertEquals(1, boundary.cachedRegionCount()); + } finally { + release.countDown(); + queries.shutdownNow(); + iterator.shutdownNow(); + assertTrue(queries.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(iterator.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void lastRegionChangesPreserveMissingBitsAndCoordinateExtremes() throws Exception { + GenerationBoundaryStore store = new GenerationBoundaryStore( + temporaryFolder.newFolder("boundary-region-transitions").toPath()); + List chunks = List.of( + new GenerationBoundary.ChunkCoordinate(-33, -1), + new GenerationBoundary.ChunkCoordinate(-32, -32), + new GenerationBoundary.ChunkCoordinate(0, 0), + new GenerationBoundary.ChunkCoordinate(31, -32), + new GenerationBoundary.ChunkCoordinate(32, 31), + new GenerationBoundary.ChunkCoordinate(Integer.MIN_VALUE, Integer.MAX_VALUE), + new GenerationBoundary.ChunkCoordinate(Integer.MAX_VALUE, Integer.MIN_VALUE)); + GenerationBoundary boundary = store.publish(1L, chunks); + for (int pass = 0; pass < 3; pass++) { + for (GenerationBoundary.ChunkCoordinate chunk : chunks) { + assertTrue(boundary.isHistoricalChunk(chunk.chunkX(), chunk.chunkZ())); + int neighbor = chunk.chunkX() == Integer.MAX_VALUE ? chunk.chunkX() - 1 : chunk.chunkX() + 1; + assertFalse(boundary.isHistoricalChunk(neighbor, chunk.chunkZ())); + assertFalse(boundary.isHistoricalChunk(1024, -5555)); + assertTrue(boundary.isHistoricalChunk(chunk.chunkX(), chunk.chunkZ())); + } + } + assertTrue(boundary.cachedRegionCount() <= 64); + } + @Test public void knownRegionHitsKeepAccessOrderAndEvictedMasksAreRevalidated() throws Exception { GenerationBoundaryStore store = new GenerationBoundaryStore(temporaryFolder.newFolder("boundary-cache-revalidation").toPath()); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectDirectPlacementTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectDirectPlacementTest.java new file mode 100644 index 000000000..775a53018 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectDirectPlacementTest.java @@ -0,0 +1,72 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.service.ExternalDataSVC; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.IrisCustomData; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisObjectDirectPlacementTest { + @Test + public void bothDirectPlacementMethodsUseProviderWithoutPassingProxyToBukkit() { + BlockData base = mock(BlockData.class); + Identifier id = new Identifier("craftengine", "test/lamp[lit=true]"); + IrisCustomData custom = IrisCustomData.of(base, id); + IrisObject object = object(custom); + World world = mock(World.class); + Block block = mock(Block.class); + when(world.getBlockAt(any(Location.class))).thenReturn(block); + ExternalDataSVC external = mock(ExternalDataSVC.class); + when(external.placeBlock(block, id)).thenReturn(true); + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.getOrNull(ExternalDataSVC.class)).thenReturn(external); + + object.place(new Location(world, 7, 80, -9)); + object.placeCenterY(new Location(world, 7, 80, -9)); + + verify(external, times(2)).placeBlock(block, id); + verify(block, times(0)).setBlockData(any(BlockData.class), anyBoolean()); + } + } + + @Test + public void unsupportedProviderPlacesOnlyBaseDataAndVanillaStillPlacesNormally() { + BlockData base = mock(BlockData.class); + Identifier id = new Identifier("craftengine", "test/chair[variant=alpha]"); + World world = mock(World.class); + Block block = mock(Block.class); + when(world.getBlockAt(any(Location.class))).thenReturn(block); + ExternalDataSVC external = mock(ExternalDataSVC.class); + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.getOrNull(ExternalDataSVC.class)).thenReturn(external); + + object(IrisCustomData.of(base, id)).place(new Location(world, 7, 80, -9)); + object(base).placeCenterY(new Location(world, 7, 80, -9)); + + verify(external).placeBlock(block, id); + verify(block, times(2)).setBlockData(base, false); + } + } + + private static IrisObject object(BlockData data) { + IrisObject object = new IrisObject(1, 1, 1); + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.nativeHandle()).thenReturn(data); + object.setUnsigned(0, 0, 0, state); + return object; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransientBlockTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransientBlockTest.java new file mode 100644 index 000000000..52addcc06 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectTransientBlockTest.java @@ -0,0 +1,158 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.testsupport.PlatformBinding; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.math.RNG; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisObjectTransientBlockTest { + private static final List PALETTE = List.of( + "minecraft:moving_piston[facing=down,type=normal]", "minecraft:oak_log[axis=y]", + "minecraft:oak_leaves[persistent=true]", "minecraft:chest[facing=north,type=single,waterlogged=false]", + "minecraft:piston[facing=north,extended=false]", "minecraft:sticky_piston[facing=north,extended=false]", + "minecraft:jigsaw[orientation=north_up]"); + private static final TypeToken> TILE_PROPERTIES = new TypeToken<>() { }; + + @Rule + public final PlatformBinding platform = PlatformBinding.mockPlatform(); + + private TileData.TileReader previousReader; + + @Before + public void bindStatesAndTiles() { + when(platform.registries().blockOrNull(anyString(), eq(false))) + .thenAnswer(invocation -> state(invocation.getArgument(0))); + Gson gson = new Gson(); + previousReader = TileData.bindPlatformReader(input -> new TileData(input.readUTF(), + gson.fromJson(input.readUTF(), TILE_PROPERTIES))); + } + + @After + public void restoreTiles() { + TileData.restorePlatformReader(previousReader); + IrisObjectScale.invalidate(null); + } + + @Test + public void bothObjectFormatsOmitTransientCellsAndTheirTiles() throws Throwable { + for (boolean legacy : new boolean[]{false, true}) { + IrisObject object = loadFixture(legacy); + + assertEquals(5, object.getBlocks().size()); + assertNull(object.getBlocks().get(new IrisBlockVector(0, 0, 0))); + assertNull(object.getBlocks().get(new IrisBlockVector(6, 0, 0))); + assertEquals("minecraft:oak_log", object.getBlocks().get(new IrisBlockVector(1, 0, 0)).materialKey()); + assertEquals("minecraft:oak_leaves", object.getBlocks().get(new IrisBlockVector(2, 0, 0)).materialKey()); + assertEquals("minecraft:piston", object.getBlocks().get(new IrisBlockVector(4, 0, 0)).materialKey()); + assertEquals("minecraft:sticky_piston", object.getBlocks().get(new IrisBlockVector(5, 0, 0)).materialKey()); + assertEquals(1, object.getStates().size()); + assertEquals("chest contents", object.getStates().get(new IrisBlockVector(3, 0, 0)) + .getProperties().get("payload")); + } + } + + @Test + public void scalingLoadedObjectsKeepsRealBlockEntitiesWithoutRevivingTransientCells() throws Throwable { + IrisObject source = loadFixture(false); + IrisObjectPlacement placement = new IrisObjectPlacement(); + for (double factor : new double[]{0.5D, 1D, 2D}) { + IrisObject object = placement.scaleObject(new RNG(1337), source, + new IrisDimension().setAllObjectScaleFactor(factor)); + assertEquals(factor == 2D ? 8 : 1, object.getStates().size()); + for (Map.Entry entry : object.getBlocks()) { + assertFalse(entry.getValue().materialKey().equals("minecraft:moving_piston")); + if (entry.getValue().materialKey().equals("minecraft:chest")) { + assertNotNull(object.getStates().get(entry.getKey())); + assertEquals("chest contents", object.getStates().get(entry.getKey()).getProperties().get("payload")); + } + } + } + assertEquals(5, source.getBlocks().size()); + assertEquals(1, source.getStates().size()); + } + + private static IrisObject loadFixture(boolean legacy) throws Throwable { + IrisObject object = new IrisObject(); + ByteArrayInputStream input = new ByteArrayInputStream(fixture(legacy)); + if (legacy) { + IrisObjectIO.readLegacy(object, input); + } else { + IrisObjectIO.read(object, input); + } + return object; + } + + private static byte[] fixture(boolean legacy) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(16); + output.writeInt(4); + output.writeInt(4); + if (!legacy) { + output.writeUTF("Iris V2 IOB;"); + output.writeShort(PALETTE.size()); + for (String key : PALETTE) { + output.writeUTF(key); + } + } + output.writeInt(PALETTE.size()); + for (int index = 0; index < PALETTE.size(); index++) { + writePosition(output, index); + if (legacy) { + output.writeUTF(PALETTE.get(index)); + } else { + output.writeShort(index); + } + } + output.writeInt(4); + writeTile(output, 0, "minecraft:moving_piston", "transient"); + writeTile(output, 3, "minecraft:chest", "chest contents"); + writeTile(output, 6, "minecraft:jigsaw", "marker"); + writeTile(output, 7, "minecraft:chest", "orphan"); + } + return bytes.toByteArray(); + } + + private static void writeTile(DataOutputStream output, int x, String material, String payload) throws IOException { + writePosition(output, x); + output.writeUTF(material); + output.writeUTF("{\"payload\":\"" + payload + "\"}"); + } + + private static void writePosition(DataOutputStream output, int x) throws IOException { + output.writeShort(x); + output.writeShort(0); + output.writeShort(0); + } + + private static PlatformBlockState state(String key) { + String material = key.substring(0, key.indexOf('[')); + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(key); + when(state.materialKey()).thenReturn(material); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java index f16eb2c77..719f4303c 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java @@ -313,6 +313,68 @@ public class BukkitChunkGeneratorGenerationStageGateTest { } } + @Test + public void timedExclusiveRetainsPriorityDuringStaggeredGenerationDrain() throws Exception { + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(3, () -> false); + BukkitChunkGenerator.GenerationStagePermit first = gate.acquireStage("first-active"); + BukkitChunkGenerator.GenerationStagePermit last = gate.acquireStage("last-active"); + CompletableFuture outward = new CompletableFuture<>(); + CountDownLatch exclusiveEntered = new CountDownLatch(1); + CountDownLatch releaseExclusive = new CountDownLatch(1); + CountDownLatch readerEntered = new CountDownLatch(1); + List order = new CopyOnWriteArrayList<>(); + ExecutorService executor = Executors.newFixedThreadPool(3); + + try { + Future exclusive = executor.submit(() -> BukkitChunkGenerator.completeExclusiveControlFuture( + gate, + () -> { + order.add("exclusive"); + exclusiveEntered.countDown(); + try { + assertTrue(releaseExclusive.await(2L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + }, + outward, + 2L, + TimeUnit.SECONDS)); + awaitQueueLength(gate, 1); + Runnable reader = () -> { + try (BukkitChunkGenerator.GenerationStagePermit ignored = gate.acquireStage("later-reader")) { + order.add("reader"); + readerEntered.countDown(); + } + }; + Future firstReader = executor.submit(reader); + Future secondReader = executor.submit(reader); + awaitQueueLength(gate, 3); + + assertFalse(readerEntered.await(150L, TimeUnit.MILLISECONDS)); + first.close(); + assertFalse(readerEntered.await(150L, TimeUnit.MILLISECONDS)); + last.close(); + assertTrue(exclusiveEntered.await(2L, TimeUnit.SECONDS)); + assertEquals(1L, readerEntered.getCount()); + releaseExclusive.countDown(); + exclusive.get(2L, TimeUnit.SECONDS); + outward.get(2L, TimeUnit.SECONDS); + firstReader.get(2L, TimeUnit.SECONDS); + secondReader.get(2L, TimeUnit.SECONDS); + assertEquals(List.of("exclusive", "reader", "reader"), order); + assertEquals(3, gate.availablePermits()); + } finally { + first.close(); + last.close(); + releaseExclusive.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(2L, TimeUnit.SECONDS)); + } + } + @Test public void timedExclusiveControlDoesNotReleaseAnUnacquiredPermit() throws Exception { AtomicBoolean closing = new AtomicBoolean(false); @@ -355,16 +417,19 @@ public class BukkitChunkGeneratorGenerationStageGateTest { BukkitChunkGenerator.GenerationStagePermit stage = gate.acquireStage("cancellation-holder"); CompletableFuture outward = new CompletableFuture<>(); AtomicBoolean operationRan = new AtomicBoolean(false); + AtomicBoolean interruptedAfterCancellation = new AtomicBoolean(true); ExecutorService executor = Executors.newSingleThreadExecutor(); try { - Future operation = executor.submit(() -> - BukkitChunkGenerator.completeExclusiveControlFuture( - gate, - () -> operationRan.set(true), - outward, - 30L, - TimeUnit.SECONDS)); + Future operation = executor.submit(() -> { + BukkitChunkGenerator.completeExclusiveControlFuture( + gate, + () -> operationRan.set(true), + outward, + 30L, + TimeUnit.SECONDS); + interruptedAfterCancellation.set(Thread.currentThread().isInterrupted()); + }); awaitQueueLength(gate, 1); assertTrue(outward.cancel(true)); @@ -372,6 +437,7 @@ public class BukkitChunkGeneratorGenerationStageGateTest { assertTrue(outward.isCancelled()); assertFalse(operationRan.get()); + assertFalse(interruptedAfterCancellation.get()); assertEquals(0, gate.availablePermits()); } finally { stage.close(); @@ -380,6 +446,80 @@ public class BukkitChunkGeneratorGenerationStageGateTest { } } + @Test + public void externalInterruptStillRestoresTheAcquisitionWorkerFlag() throws Exception { + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, () -> false); + BukkitChunkGenerator.GenerationStagePermit stage = gate.acquireStage("interrupt-holder"); + CompletableFuture outward = new CompletableFuture<>(); + AtomicReference worker = new AtomicReference<>(); + AtomicBoolean interruptedAfterAcquisition = new AtomicBoolean(false); + AtomicBoolean operationRan = new AtomicBoolean(false); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + Future operation = executor.submit(() -> { + worker.set(Thread.currentThread()); + BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> operationRan.set(true), + outward, 30L, TimeUnit.SECONDS); + interruptedAfterAcquisition.set(Thread.currentThread().isInterrupted()); + }); + awaitQueueLength(gate, 1); + worker.get().interrupt(); + operation.get(2L, TimeUnit.SECONDS); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> outward.get(2L, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof InterruptedException); + assertTrue(interruptedAfterAcquisition.get()); + assertFalse(operationRan.get()); + assertEquals(0, gate.availablePermits()); + } finally { + stage.close(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(2L, TimeUnit.SECONDS)); + } + } + + @Test + public void cancellationAfterAcquisitionDoesNotInterruptTheRunningOperation() throws Exception { + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, () -> false); + CompletableFuture outward = new CompletableFuture<>(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean interrupted = new AtomicBoolean(false); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + Future operation = executor.submit(() -> { + BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> { + entered.countDown(); + try { + assertTrue(release.await(2L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + interrupted.set(true); + Thread.currentThread().interrupt(); + } + }, outward, 30L, TimeUnit.SECONDS); + interrupted.compareAndSet(false, Thread.currentThread().isInterrupted()); + }); + assertTrue(entered.await(2L, TimeUnit.SECONDS)); + assertTrue(outward.cancel(true)); + assertEquals(0, gate.availablePermits()); + release.countDown(); + operation.get(2L, TimeUnit.SECONDS); + + assertFalse(interrupted.get()); + assertTrue(outward.isCancelled()); + assertEquals(1, gate.availablePermits()); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(2L, TimeUnit.SECONDS)); + } + } + @Test public void queuedStageRemainsAdmittedWhileShutdownIsOnlyQuiesced() throws Exception { AtomicBoolean closing = new AtomicBoolean(false); diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitCustomBlockTransformTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitCustomBlockTransformTest.java new file mode 100644 index 000000000..bba41279a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitCustomBlockTransformTest.java @@ -0,0 +1,159 @@ +package art.arcane.iris.platform.bukkit; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.service.ExternalDataSVC; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.testsupport.BukkitTestServer; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.iris.util.common.data.registry.RegistryUtil; +import org.bukkit.Material; +import org.bukkit.block.data.BlockData; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mockStatic; + +public class BukkitCustomBlockTransformTest { + @BeforeClass + public static void installServer() { + BukkitTestServer.install(); + try (MockedStatic registry = mockStatic(RegistryUtil.class)) { + registry.when(() -> RegistryUtil.find(Material.class, "grass", "short_grass")).thenReturn(Material.SHORT_GRASS); + BukkitBlockResolution.getAir(); + } + } + + @Test + public void waterloggingUpdatesSemanticIdentityAndResolvedCarrier() { + IrisCustomData original = custom("craftengine:forest/amber_slab[axis=x,waterlogged=false]", 1); + IrisCustomData expected = custom("craftengine:forest/amber_slab[axis=x,waterlogged=true]", 2); + PlatformBlockState state = BukkitBlockState.of(original); + + try (MockedStatic resolution = mockStatic(BukkitBlockResolution.class)) { + resolveTo(resolution, expected); + PlatformBlockState result = state.withProperty("waterlogged", "true"); + + assertSame(expected, result.nativeHandle()); + assertEquals(expected.getCustom().toString(), result.deferredPlacementKey()); + assertEquals(expected.getBase().getAsString(), result.placementBaseState().key()); + assertEquals("craftengine:forest/amber_slab[axis=x,waterlogged=false]", state.key()); + resolution.verify(() -> BukkitBlockResolution.resolveOrNull(anyString())); + } + } + + @Test + public void customPropertyRejectsAnUnresolvableValue() { + PlatformBlockState state = BukkitBlockState.of(custom("craftengine:forest/amber_log[axis=x]", 1)); + + try (MockedStatic resolution = mockStatic(BukkitBlockResolution.class)) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> state.withProperty("axis", "invalid")); + + assertTrue(error.getMessage().contains("axis=invalid")); + assertEquals("craftengine:forest/amber_log[axis=x]", state.key()); + resolution.verify(() -> BukkitBlockResolution.resolveOrNull("craftengine:forest/amber_log[axis=invalid]")); + } + } + + @Test + public void semanticFacingRotatesWithoutDirectionalCarrier() { + IrisCustomData original = custom("craftengine:forest/amber_lamp[facing=north,waterlogged=true]", 1); + IrisCustomData expected = custom("craftengine:forest/amber_lamp[facing=west,waterlogged=true]", 2); + PlatformBlockState state = BukkitBlockState.of(original); + + try (MockedStatic resolution = mockStatic(BukkitBlockResolution.class)) { + resolveTo(resolution, expected); + PlatformBlockState result = IrisObjectRotation.of(0, 90, 0).rotate(state, 0, 0, 0); + + assertSame(expected, result.nativeHandle()); + assertEquals(expected.getCustom().toString(), result.key()); + assertEquals("craftengine:forest/amber_lamp[facing=north,waterlogged=true]", state.key()); + resolution.verify(() -> BukkitBlockResolution.resolveOrNull(anyString())); + } + } + + @Test + public void semanticAxisRotatesThroughRawBlockDataEntryPoint() { + IrisCustomData original = custom("craftengine:forest/amber_log[axis=x,waterlogged=false]", 1); + IrisCustomData expected = custom("craftengine:forest/amber_log[axis=z,waterlogged=false]", 2); + + try (MockedStatic resolution = mockStatic(BukkitBlockResolution.class)) { + resolveTo(resolution, expected); + BlockData result = IrisObjectRotation.of(0, 90, 0).rotate(original, 0, 0, 0); + + assertSame(expected, result); + assertEquals("craftengine:forest/amber_log[axis=x,waterlogged=false]", original.getCustom().toString()); + resolution.verify(() -> BukkitBlockResolution.resolveOrNull(anyString())); + } + } + + @Test + public void semanticRotationPreservesSixteenWayOrientation() { + PlatformBlockState state = BukkitBlockState.of(custom("craftengine:forest/amber_sign[rotation=0]", 1)); + IrisCustomData expected = custom("craftengine:forest/amber_sign[rotation=12]", 2); + + try (MockedStatic resolution = mockStatic(BukkitBlockResolution.class)) { + resolveTo(resolution, expected); + PlatformBlockState result = IrisObjectRotation.of(0, 90, 0).rotate(state, 0, 0, 0); + + assertSame(expected, result.nativeHandle()); + resolution.verify(() -> BukkitBlockResolution.resolveOrNull(anyString())); + } + } + + @Test + public void semanticConnectionsRotateTogether() { + PlatformBlockState state = BukkitBlockState.of(custom( + "craftengine:forest/amber_fence[north=true,east=false,south=false,west=false]", 1)); + IrisCustomData expected = custom( + "craftengine:forest/amber_fence[north=false,east=false,south=false,west=true]", 2); + + try (MockedStatic resolution = mockStatic(BukkitBlockResolution.class)) { + resolveTo(resolution, expected); + PlatformBlockState result = IrisObjectRotation.of(0, 90, 0).rotate(state, 0, 0, 0); + + assertSame(expected, result.nativeHandle()); + resolution.verify(() -> BukkitBlockResolution.resolveOrNull(anyString())); + } + } + + @Test + public void unsupportedRotationRetainsTheOriginalCustomBlock() { + PlatformBlockState state = BukkitBlockState.of(custom("craftengine:forest/amber_lamp[facing=north]", 1)); + + try (MockedStatic resolution = mockStatic(BukkitBlockResolution.class)) { + assertSame(state, IrisObjectRotation.of(90, 0, 0).rotate(state, 0, 0, 0)); + resolution.verify(() -> BukkitBlockResolution.resolveOrNull(anyString())); + } + } + + @Test + public void unchangedSemanticAxisDoesNotResolveOrMutateCarrier() { + PlatformBlockState state = BukkitBlockState.of(custom("craftengine:forest/amber_log[axis=y]", 1)); + + try (MockedStatic resolution = mockStatic(BukkitBlockResolution.class)) { + assertSame(state, IrisObjectRotation.of(0, 90, 0).rotate(state, 0, 0, 0)); + resolution.verifyNoInteractions(); + } + } + + private static IrisCustomData custom(String key, int note) { + BlockData carrier = BukkitTestServer.blockData("minecraft:note_block[instrument=harp,note=" + note + ",powered=false]"); + return IrisCustomData.of(carrier, Identifier.fromString(key)); + } + + private static void resolveTo(MockedStatic resolution, IrisCustomData expected) { + resolution.when(() -> BukkitBlockResolution.resolveOrNull(anyString())).thenAnswer(invocation -> { + Identifier actual = Identifier.fromString(invocation.getArgument(0, String.class)); + assertEquals(ExternalDataSVC.parseState(expected.getCustom()), ExternalDataSVC.parseState(actual)); + return expected; + }); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 137ed519e..fe1a63e05 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ mythic = "5.12.1" mythic-crucible = "2.2.0" kgenerators = "7.3" # https://repo.codemc.io/repository/maven-public/me/kryniowesegryderiusz/kgenerators-core/maven-metadata.xml multiverseCore = "5.8.0" -craftengine = "26.7.3" # https://github.com/Xiao-MoMi/craft-engine/releases +craftengine = "26.8.2" # https://github.com/Xiao-MoMi/craft-engine/releases # Fabric API (net.fabricmc.fabric-api) - each module is independently versioned fabricApi-base = "2.0.4+ece063239e"