mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
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:
+9
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+73
-20
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+144
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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;
|
||||
}
|
||||
}
|
||||
+85
@@ -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();
|
||||
|
||||
+13
-2
@@ -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)));
|
||||
|
||||
+80
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+371
@@ -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 {
|
||||
|
||||
+50
-18
@@ -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(
|
||||
|
||||
+93
-3
@@ -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
|
||||
|
||||
+224
@@ -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;
|
||||
}
|
||||
}
|
||||
+142
-2
@@ -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);
|
||||
|
||||
+159
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user