CrantEngine Tweak and a bit more.

- Fixed CE support
- Added some checks for things like paint and so on in the 3d Generator.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-08 19:22:55 -04:00
parent 0782bbf0b4
commit 4de1aea8b9
44 changed files with 3161 additions and 244 deletions
@@ -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<MobSpawnSettings.SpawnerData> getMobsAt(Holder<Biome> 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<MobSpawnSettings.SpawnerData> getAdmittedMobsAt(
Holder<Biome> 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);
}
}
@@ -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<Checkpoint> checkpoints = new ArrayList<>(saves.size());
for (CompletableFuture<Checkpoint> 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<Checkpoint> 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) {
}
}
@@ -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<Void> 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> biome = Holder.direct(mock(Biome.class));
private final StructureManager structures = mock(StructureManager.class);
private final WeightedList<MobSpawnSettings.SpawnerData> 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<MobSpawnSettings.SpawnerData> 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());
}
}
}
@@ -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<ChunkPos> completed = new HashSet<>();
AtomicBoolean flushed = new AtomicBoolean();
try (MockedStatic<MoonriseRegionFileIO> io = mockStatic(MoonriseRegionFileIO.class);
MockedStatic<SavedTerrainChunk> 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<MoonriseRegionFileIO> io = mockStatic(MoonriseRegionFileIO.class);
MockedStatic<SavedTerrainChunk> 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<MoonriseRegionFileIO> io = mockStatic(MoonriseRegionFileIO.class);
MockedStatic<SavedTerrainChunk> 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));
}
}
}
@@ -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<Thread> saveThread = new AtomicReference<>();
AtomicReference<Thread> compressionThread = new AtomicReference<>();
try (MockedStatic<MoonriseRegionFileIO> 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;
}
}
@@ -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<Void> 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<Thread> firstWaiter = new AtomicReference<>();
AtomicReference<Thread> secondWaiter = new AtomicReference<>();
try {
Future<KList<NativeStructureVolume>> original = owner.submit(
() -> index.resolve(null, 0, 0, 15, 15));
assertTrue(ownerEntered.await(5, TimeUnit.SECONDS));
Future<KList<NativeStructureVolume>> adjacent = workers.submit(() -> {
firstWaiter.set(Thread.currentThread());
KList<NativeStructureVolume> result = index.resolve(null, 16, 0, 31, 15);
interruptedAfterQuery.set(Thread.currentThread().isInterrupted());
return result;
});
Future<KList<NativeStructureVolume>> 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<NativeStructureVolume> 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<Thread> 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();
@@ -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);
}
@@ -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:
@@ -11,12 +11,8 @@ softdepend:
- ItemsAdder
- SCore
- ExecutableItems
- MythicLib
- MMOItems
- eco
- EcoItems
- MythicMobs
- MythicCrucible
- KGenerators
- WorldEdit
loadbefore: [ Multiverse-Core ]
@@ -41,8 +41,11 @@ public class PaperPluginMetadataTest {
private static final List<String> JOINED_PLUGIN_IDS = OPTIONAL_PLUGIN_IDS.stream()
.filter(pluginId -> !"ExecutableItems".equals(pluginId) && !"Multiverse-Core".equals(pluginId))
.toList();
private static final List<String> UNORDERED_PLUGIN_IDS = List.of(
"MythicLib", "MMOItems", "MythicMobs", "MythicCrucible");
private static final List<String> 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)));
@@ -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<Block, BlockData> changes = new HashMap<>();
try (MockedStatic<IrisServices> 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<IrisServices> 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);
}
}
}
@@ -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,
+12
View File
@@ -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
}
@@ -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<BukkitCraftEngine> engine = mockStatic(BukkitCraftEngine.class);
MockedStatic<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> furniture = mockStatic(CraftEngineFurniture.class);
MockedStatic<CraftEngineItems> 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<BukkitCraftEngine> engine = mockStatic(BukkitCraftEngine.class);
MockedStatic<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> furniture = mockStatic(CraftEngineFurniture.class);
MockedStatic<CraftEngineItems> items = mockStatic(CraftEngineItems.class);
MockedStatic<IrisServices> 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<String, String> properties = new KMap<>();
properties.put("lit", "true");
try (MockedStatic<CraftEngineBlocks> 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<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class)) {
blocks.when(() -> CraftEngineBlocks.byId(Key.of("test:lamp"))).thenReturn(definition);
for (Map.Entry<String, String> entry : List.of(Map.entry("missing", "true"), Map.entry("lit", "yes"))) {
KMap<String, String> 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<CraftEngineBlocks> 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> bukkit = mockStatic(Bukkit.class);
MockedStatic<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> furniture = mockStatic(CraftEngineFurniture.class);
MockedStatic<CraftEngineItems> 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<CraftEngineBlocks> 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<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> 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<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> furniture = mockStatic(CraftEngineFurniture.class);
MockedStatic<RegistryUtil> registry = materialRegistry();
MockedStatic<BukkitBlockResolution> 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<String, String> 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<String, String> 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<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> 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<CraftEngineItems> 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<CraftEngineBlocks> 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<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> 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<RegistryUtil> materialRegistry() {
MockedStatic<RegistryUtil> 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;
}
}
@@ -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<Identifier> identifyBlock(@NotNull BlockData blockData) {
return Optional.empty();
}
public boolean placeBlock(@NotNull Block block, @NotNull Identifier blockId) {
return false;
}
/**
* @see ExternalDataProvider#getBlockData(Identifier, KMap)
*/
@@ -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<BlockProperty> 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<BlockProperty> 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<BlockProperty> 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<String, Object> 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<String, String> 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<String, String> 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<Identifier> 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<Identifier, KMap<String, String>> 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<Identifier, KMap<String, String>> statePair = ExternalDataSVC.parseState(blockId);
Pair<Identifier, KMap<String, String>> statePair = ExternalDataSVC.parseState(blockId);
Identifier baseBlockId = statePair.getA();
KMap<String, String> 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<String, String> 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<String, String> entry : state.entrySet()) {
Property<?> property = customBlock.getProperty(entry.getKey());
@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;
};
}
private boolean updateContent() {
try {
Set<Identifier> blocks = new LinkedHashSet<>();
for (Key key : CraftEngineBlocks.loadedBlocks().keySet()) {
blocks.add(new Identifier(key.namespace(), key.value()));
}
for (Key key : CraftEngineFurniture.loadedFurniture().keySet()) {
blocks.add(new Identifier(key.namespace(), key.value()));
}
Set<Identifier> 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;
}
}
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<String, String> properties) {
BlockDefinition block = state.owner().value();
for (Map.Entry<String, String> entry : properties.entrySet()) {
Property<?> property = block.getProperty(entry.getKey());
if (property == null) {
continue;
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;
}
Comparable<?> tag = property.optional(entry.getValue()).orElse(null);
if (tag == null) {
continue;
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.");
}
}
blockState = ImmutableBlockState.with(blockState, property, tag);
}
CraftEngineBlocks.place(block.getLocation(), blockState, false);
return;
}
FurnitureDefinition furniture = CraftEngineFurniture.byId(key);
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<String, String> validateFurnitureProperties(Identifier id, FurnitureDefinition furniture, Map<String, String> state) {
KMap<String, String> properties = new KMap<>();
for (Map.Entry<String, String> 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<String, String> 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<Key> keys = switch (dataType) {
case ENTITY -> Stream.<Key>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 <T extends Comparable<T>> BlockProperty convert(Property<T> 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<Identifier> items, Set<Identifier> blocks, boolean ready) {
}
}
@@ -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<String> lines = new ArrayList<>(this.lines.size());
@@ -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> identifier = provider.identifyBlock(blockData);
if (identifier.isPresent()) {
return IrisCustomData.of(blockData, qualifyBlockId(provider, identifier.get()));
}
}
return blockData;
}
public Optional<BlockData> getBlockData(final Identifier key) {
Pair<Identifier, KMap<String, String>> 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<Identifier, KMap<String, String>> 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<ExternalDataProvider> provider = activeProviders.stream().filter(p -> p.isValidProvider(mobId, DataType.ENTITY)).findFirst();
if (provider.isEmpty()) {
@@ -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<Block, BlockData> 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);
}
}
@@ -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);
@@ -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) {
@@ -195,6 +195,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
return isClosed();
}
default boolean isShuttingDown() {
return isClosed() || isClosing();
}
double getMaxBiomeObjectDensity();
double getMaxBiomeDecoratorDensity();
@@ -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
@@ -619,6 +619,7 @@ public final class GenerationBoundaryStore {
private final long[] regionKeys;
private final Long2ObjectOpenHashMap<String> shardHashes;
private final LinkedHashMap<Long, byte[]> masks;
private final ThreadLocal<RegionMask> 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);
RegionMask region = lastRegion.get();
if (region == null || region.key() != regionKey) {
String hash = shardHashes.get(regionKey);
if (hash == null) {
return false;
}
byte[] mask;
byte[] loaded = null;
if (hash != null) {
synchronized (this) {
mask = load(regionKey, hash);
loaded = load(regionKey, hash);
}
}
region = new RegionMask(regionKey, loaded);
lastRegion.set(region);
}
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 {
@@ -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<CellReference> cells;
private final Long2ObjectOpenHashMap<long[]> cellsBySuperCell;
private final LinkedHashMap<Long, List<TerrainBoundarySignature>> cache;
private long catalogProbes;
private long shardLoads;
private final Map<Long, CompletableFuture<List<TerrainBoundarySignature>>> 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<TerrainBoundarySignature> all() throws IOException {
private List<TerrainBoundarySignature> all() throws IOException {
ArrayList<TerrainBoundarySignature> 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<TerrainBoundarySignature> nearestCandidatesForChunk(
private List<TerrainBoundarySignature> 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<TerrainBoundarySignature> load(long cellKey, CellReference reference) throws IOException {
CompletableFuture<List<TerrainBoundarySignature>> pending;
boolean loader;
synchronized (this) {
List<TerrainBoundarySignature> 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);
}
}
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<TerrainBoundarySignature> loaded = CellShard.read(
shard,
reference.hash(),
cellX(cellKey),
cellZ(cellKey)
);
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");
}
shardLoads++;
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);
}
}
}
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() {
@@ -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<Long, CandidateIndex> chunkCandidates;
private final Map<Long, CompletableFuture<CandidateIndex>> pendingCandidates = new HashMap<>();
private final ThreadLocal<ChunkCache> 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,31 +130,57 @@ final class TransitionBoundarySampler {
private CandidateIndex candidatesForChunk(int chunkX, int chunkZ) {
long chunkKey = pack(chunkX, chunkZ);
CompletableFuture<CandidateIndex> 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);
}
}
if (!builder) {
return pending.join();
}
try {
CandidateIndex built = CandidateIndex.build(signatures.nearestCandidatesForChunk(
chunkX,
chunkZ,
searchWidth
));
chunkX, chunkZ, searchWidth));
synchronized (this) {
CandidateIndex raced = chunkCandidates.get(chunkKey);
if (raced != null) {
return raced;
}
chunkCandidates.put(chunkKey, built);
cachedCandidates += built.size;
candidateBuildCount++;
while (chunkCandidates.size() > MAXIMUM_CACHED_CHUNKS) {
while (chunkCandidates.size() > MAXIMUM_CACHED_CHUNKS
|| cachedCandidates > MAXIMUM_CACHED_CANDIDATES) {
Iterator<Map.Entry<Long, CandidateIndex>> entries = chunkCandidates.entrySet().iterator();
entries.next();
cachedCandidates -= entries.next().getValue().size;
entries.remove();
}
return built;
}
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) {
@@ -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<TerrainBoundarySignature> 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) {
@@ -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<IrisBlockVector, PlatformBlockState> 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<IrisBlockVector, PlatformBlockState> 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);
}
}
@@ -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");
}
/**
@@ -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<BlockFace> 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))));
@@ -1122,13 +1122,23 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
Runnable activeOperation = Objects.requireNonNull(operation, "Exclusive control operation");
CompletableFuture<Void> 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) {
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;
@@ -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<String, BlockFace> 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<BlockFace> 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<String, BukkitBlockState> 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<Identifier, KMap<String, String>> parsed = ExternalDataSVC.parseState(custom.getCustom());
KMap<String, String> original = parsed.getB();
if (original.isEmpty()) {
return null;
}
KMap<String, String> 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<String, BlockFace> 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) {
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.
if (data instanceof IrisCustomData custom) {
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;
}
}
}
@@ -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<String> previous = board.service.getLines(board.player);
assertTrue(board.lines().contains("70"));
CompletableFuture<Void> 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);
@@ -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");
@@ -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<Runnable> ownerTasks = new ArrayList<>();
try (MockedStatic<IrisServices> services = mockStatic(IrisServices.class);
MockedStatic<J> 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());
}
}
}
@@ -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<Boolean> 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<TestWorldManager> 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<IrisServices> 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<Boolean> 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<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
MockedStatic<BukkitWorldBinding> 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<TestWorldManager> 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<TestWorldManager> 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);
}
}
}
@@ -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(
@@ -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
@@ -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<Future<BoundaryGeometryInfluence>> 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<BoundaryGeometryInfluence> 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<TerrainBoundarySignature> 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<Long, List<TerrainBoundarySignature>> cache = shardCache(snapshot);
BlockingSignatures blocked = new BlockingSignatures(cache.get(0L));
cache.put(0L, blocked);
ExecutorService workers = Executors.newFixedThreadPool(2);
try {
Future<List<TerrainBoundarySignature>> first = workers.submit(
() -> snapshot.nearestCandidatesForChunk(0, 0, 16));
assertTrue(blocked.entered.await(5, TimeUnit.SECONDS));
Future<List<TerrainBoundarySignature>> 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<Long, List<TerrainBoundarySignature>> 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<Long, List<TerrainBoundarySignature>>) 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<TerrainBoundarySignature> {
private final List<TerrainBoundarySignature> values;
private final CountDownLatch entered = new CountDownLatch(1);
private final CountDownLatch release = new CountDownLatch(1);
private BlockingSignatures(List<TerrainBoundarySignature> 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();
}
}
}
@@ -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<Boolean> 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<GenerationBoundary.ChunkCoordinate> 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());
@@ -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<IrisServices> 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<IrisServices> 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;
}
}
@@ -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<String> 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<KMap<String, Object>> 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<IrisBlockVector, PlatformBlockState> 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;
}
}
@@ -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<Void> outward = new CompletableFuture<>();
CountDownLatch exclusiveEntered = new CountDownLatch(1);
CountDownLatch releaseExclusive = new CountDownLatch(1);
CountDownLatch readerEntered = new CountDownLatch(1);
List<String> 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<Void> outward = new CompletableFuture<>();
AtomicBoolean operationRan = new AtomicBoolean(false);
AtomicBoolean interruptedAfterCancellation = new AtomicBoolean(true);
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<?> operation = executor.submit(() ->
Future<?> operation = executor.submit(() -> {
BukkitChunkGenerator.completeExclusiveControlFuture(
gate,
() -> operationRan.set(true),
outward,
30L,
TimeUnit.SECONDS));
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<Void> outward = new CompletableFuture<>();
AtomicReference<Thread> 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<Void> 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);
@@ -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<RegistryUtil> 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<BukkitBlockResolution> 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<BukkitBlockResolution> 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<BukkitBlockResolution> 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<BukkitBlockResolution> 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<BukkitBlockResolution> 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<BukkitBlockResolution> 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<BukkitBlockResolution> 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<BukkitBlockResolution> 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<BukkitBlockResolution> 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;
});
}
}
+1 -1
View File
@@ -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"