diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index 3467c73c2..58d017a62 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -1150,6 +1150,11 @@ public class NMSBinding implements INMSBinding { } } + @Override + public void reconcileNativeStructurePois(Chunk chunk) { + NativeStructurePoiUpdates.reconcile((LevelChunk) ((CraftChunk) chunk).getHandle(ChunkStatus.FULL)); + } + @Override public boolean clearChunkBlocks(Chunk bukkitChunk) { try { diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructurePoiUpdates.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructurePoiUpdates.java new file mode 100644 index 000000000..9b4c907b9 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructurePoiUpdates.java @@ -0,0 +1,142 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import ca.spottedleaf.moonrise.common.util.TickThread; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.village.poi.PoiManager; +import net.minecraft.world.entity.ai.village.poi.PoiRecord; +import net.minecraft.world.entity.ai.village.poi.PoiSection; +import net.minecraft.world.entity.ai.village.poi.PoiType; +import net.minecraft.world.entity.ai.village.poi.PoiTypes; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ImposterProtoChunk; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.ProtoChunk; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataType; + +import java.util.Arrays; +import java.util.Optional; + +final class NativeStructurePoiUpdates { + private static final NamespacedKey DIRTY_SECTIONS = new NamespacedKey("iris", "native_poi_sections"); + + private final int skipPoiFlag; + + NativeStructurePoiUpdates(int skipPoiFlag) { + this.skipPoiFlag = skipPoiFlag; + } + + static NativeStructurePoiUpdates create() { + try { + return new NativeStructurePoiUpdates(Block.class.getField("UPDATE_SKIP_POI").getInt(null)); + } catch (NoSuchFieldException e) { + return null; + } catch (IllegalAccessException e) { + throw new ExceptionInInitializerError(e); + } + } + + boolean setBlock(WorldGenLevel world, BlockPos position, BlockState state, int flags, int limit) { + if ((flags & skipPoiFlag) != 0) { + return world.setBlock(position, state, flags, limit); + } + ChunkAccess chunk = world.getChunk(position); + if (!(chunk instanceof ProtoChunk)) { + return world.setBlock(position, state, flags, limit); + } + if (!(chunk instanceof ImposterProtoChunk) + && !PoiTypes.forState(chunk.getBlockState(position)).equals(PoiTypes.forState(state))) { + markSection(chunk, chunk.getSectionIndex(position.getY())); + } + return world.setBlock(position, state, flags | skipPoiFlag, limit); + } + + static void reconcile(LevelChunk chunk) { + long[] sections = chunk.persistentDataContainer.get(DIRTY_SECTIONS, PersistentDataType.LONG_ARRAY); + if (sections == null) { + return; + } + validateSections(chunk, sections); + ServerLevel world = (ServerLevel) chunk.getLevel(); + TickThread.ensureTickThread(world, chunk.getPos(), "Native structure POIs require the chunk owning thread"); + PoiManager manager = world.getPoiManager(); + for (int word = 0; word < sections.length; word++) { + long remaining = sections[word]; + while (remaining != 0) { + int index = (word << 6) + Long.numberOfTrailingZeros(remaining); + reconcileSection(chunk, manager, index); + remaining &= remaining - 1; + } + } + chunk.persistentDataContainer.remove(DIRTY_SECTIONS); + chunk.markUnsaved(); + } + + private static void markSection(ChunkAccess chunk, int index) { + long[] sections = chunk.persistentDataContainer.get(DIRTY_SECTIONS, PersistentDataType.LONG_ARRAY); + int word = index >>> 6; + long bit = 1L << (index & 63); + if (sections == null) { + sections = new long[word + 1]; + } else { + validateSections(chunk, sections); + if (word < sections.length && (sections[word] & bit) != 0) { + return; + } + if (word >= sections.length) { + sections = Arrays.copyOf(sections, word + 1); + } + } + sections[word] |= bit; + chunk.persistentDataContainer.set(DIRTY_SECTIONS, PersistentDataType.LONG_ARRAY, sections); + } + + private static void validateSections(ChunkAccess chunk, long[] sections) { + int count = chunk.getSectionsCount(); + int words = (count + 63) >>> 6; + if (sections.length > words + || sections.length == words && (count & 63) != 0 + && (sections[words - 1] >>> (count & 63)) != 0) { + throw new IllegalArgumentException("Native POI repair sections exceed chunk height at " + chunk.getPos()); + } + } + + private static void reconcileSection(LevelChunk chunk, PoiManager manager, int index) { + SectionPos sectionPosition = SectionPos.of(chunk.getPos(), chunk.getSectionYFromSectionIndex(index)); + Optional existing = manager.getOrLoad(sectionPosition.asLong()); + if (existing.isPresent()) { + for (PoiRecord record : existing.get().getRecords(type -> true, PoiManager.Occupancy.ANY).toList()) { + if (!PoiTypes.forState(chunk.getBlockState(record.getPos())).equals(Optional.of(record.getPoiType()))) { + manager.remove(record.getPos()); + } + } + } + LevelChunkSection section = chunk.getSection(index); + if (!section.maybeHas(PoiTypes::hasPoi)) { + return; + } + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + Optional> type = PoiTypes.forState(section.getBlockState(x, y, z)); + if (type.isEmpty()) { + continue; + } + position.set(sectionPosition.minBlockX() + x, sectionPosition.minBlockY() + y, + sectionPosition.minBlockZ() + z); + if (!manager.getType(position).equals(type)) { + manager.add(position.immutable(), type.get()); + } + } + } + } + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java index 8b7ff3624..6c25b02b7 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java @@ -11,6 +11,7 @@ import net.minecraft.core.RegistryAccess; import net.minecraft.core.particles.ParticleOptions; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.WorldGenRegion; import net.minecraft.sounds.SoundEvent; import net.minecraft.sounds.SoundSource; import net.minecraft.util.Mth; @@ -59,6 +60,7 @@ import java.util.function.Supplier; final class NativeStructureWorldgenAccess implements WorldGenLevel { private static final int WRITE_RADIUS = 1; + private static final NativeStructurePoiUpdates POI_UPDATES = NativeStructurePoiUpdates.create(); private final WorldGenLevel delegate; private final ChunkPos generationCenter; @@ -431,17 +433,31 @@ final class NativeStructureWorldgenAccess implements WorldGenLevel { @Override public boolean setBlock(BlockPos position, BlockState state, int updateFlags, int updateLimit) { - return isWritable(position) && delegate.setBlock(position, state, updateFlags, updateLimit); + return isWritable(position) + && (POI_UPDATES != null + ? POI_UPDATES.setBlock(delegate, position, state, updateFlags, updateLimit) + : delegate.setBlock(position, state, updateFlags, updateLimit)); } @Override public boolean removeBlock(BlockPos position, boolean move) { - return isWritable(position) && delegate.removeBlock(position, move); + if (!isWritable(position)) { + return false; + } + return POI_UPDATES != null && delegate instanceof WorldGenRegion + ? setBlock(position, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL, Block.UPDATE_LIMIT) + : delegate.removeBlock(position, move); } @Override public boolean destroyBlock(BlockPos position, boolean drop, Entity source, int updateLimit) { - return isWritable(position) && delegate.destroyBlock(position, drop, source, updateLimit); + if (!isWritable(position)) { + return false; + } + return POI_UPDATES != null && delegate instanceof WorldGenRegion + ? !delegate.getBlockState(position).isAir() + && setBlock(position, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL, updateLimit) + : delegate.destroyBlock(position, drop, source, updateLimit); } @Override diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructurePoiUpdatesTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructurePoiUpdatesTest.java new file mode 100644 index 000000000..5ab44d916 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructurePoiUpdatesTest.java @@ -0,0 +1,250 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import ca.spottedleaf.moonrise.common.util.TickThread; +import com.mojang.serialization.Codec; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.IdMapper; +import net.minecraft.core.SectionPos; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.ai.village.poi.PoiManager; +import net.minecraft.world.entity.ai.village.poi.PoiRecord; +import net.minecraft.world.entity.ai.village.poi.PoiSection; +import net.minecraft.world.entity.ai.village.poi.PoiType; +import net.minecraft.world.entity.ai.village.poi.PoiTypes; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ImposterProtoChunk; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.Strategy; +import net.minecraft.world.level.chunk.UpgradeData; +import org.bukkit.NamespacedKey; +import org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry; +import org.bukkit.craftbukkit.persistence.DirtyCraftPersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NativeStructurePoiUpdatesTest { + private static final NamespacedKey DIRTY_SECTIONS = new NamespacedKey("iris", "native_poi_sections"); + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void generationRecordsIntentBeforeSuppressingNativePoiCallbacks() { + Fixture fixture = new Fixture(); + BlockPos position = new BlockPos(-31, 5, 49); + doAnswer(call -> { + assertNotNull(fixture.proto.persistentDataContainer.get(DIRTY_SECTIONS, PersistentDataType.LONG_ARRAY)); + assertEquals(Block.UPDATE_CLIENTS | 4096, (int) call.getArgument(2)); + fixture.proto.setBlockState(position, call.getArgument(1), 0); + return true; + }).when(fixture.world).setBlock(any(), any(), anyInt(), anyInt()); + + assertTrue(fixture.updates.setBlock(fixture.world, position, + Blocks.COMPOSTER.defaultBlockState(), Block.UPDATE_CLIENTS, 17)); + assertArrayEquals(new long[]{16L}, fixture.marker()); + } + + @Test + public void denseWritesStoreOneSectionBitAndSurvivePersistentDataRoundTrip() throws Exception { + Fixture fixture = new Fixture(); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + fixture.write(new BlockPos(-32 + x, 5, 48 + z), Blocks.COMPOSTER.defaultBlockState()); + } + } + assertArrayEquals(new long[]{16L}, fixture.marker()); + DirtyCraftPersistentDataContainer restored = new DirtyCraftPersistentDataContainer(new CraftPersistentDataTypeRegistry()); + restored.putAll(fixture.proto.persistentDataContainer.toTagCompound()); + fixture.loaded.persistentDataContainer = restored; + + onOwner(() -> NativeStructurePoiUpdates.reconcile(fixture.loaded)); + + assertEquals(256L, fixture.records().getRecords(type -> true, PoiManager.Occupancy.ANY).count()); + assertFalse(restored.has(DIRTY_SECTIONS)); + } + + @Test + public void validSectionsReconcileFinalStatesAndKeepMatchingClaimedTickets() throws Exception { + Fixture fixture = new Fixture(); + BlockPos retained = new BlockPos(-31, 5, 49); + BlockPos replaced = new BlockPos(-30, 5, 49); + BlockPos removed = new BlockPos(-29, 5, 49); + Holder farmer = PoiTypes.forState(Blocks.COMPOSTER.defaultBlockState()).orElseThrow(); + PoiSection existing = new PoiSection.Packed(true, List.of( + new PoiRecord.Packed(retained, farmer, 0), + new PoiRecord.Packed(replaced, farmer, 1), + new PoiRecord.Packed(removed, farmer, 1))).unpack(() -> {}); + fixture.pois.put(SectionPos.of(retained).asLong(), existing); + PoiRecord retainedRecord = existing.getRecords(type -> true, PoiManager.Occupancy.ANY) + .filter(record -> record.getPos().equals(retained)).findFirst().orElseThrow(); + for (BlockPos position : List.of(retained, replaced, removed)) { + fixture.proto.setBlockState(position, Blocks.COMPOSTER.defaultBlockState(), 0); + } + fixture.write(replaced, Blocks.BARREL.defaultBlockState()); + fixture.write(removed, Blocks.AIR.defaultBlockState()); + + onOwner(() -> NativeStructurePoiUpdates.reconcile(fixture.loaded)); + + assertEquals(PoiTypes.forState(Blocks.BARREL.defaultBlockState()), existing.getType(replaced)); + assertTrue(existing.getType(removed).isEmpty()); + assertSame(retainedRecord, existing.getRecords(type -> true, PoiManager.Occupancy.ANY) + .filter(record -> record.getPos().equals(retained)).findFirst().orElseThrow()); + assertEquals(0, existing.getFreeTickets(retained)); + onOwner(() -> NativeStructurePoiUpdates.reconcile(fixture.loaded)); + assertEquals(0, existing.getFreeTickets(retained)); + } + + @Test + public void failedReconciliationRetainsIntentForRetry() throws Exception { + Fixture fixture = new Fixture(); + fixture.write(new BlockPos(-31, 5, 49), Blocks.COMPOSTER.defaultBlockState()); + IllegalStateException failure = new IllegalStateException("POI storage failure"); + doThrow(failure).when(fixture.manager).add(any(), any()); + + onOwner(() -> assertSame(failure, assertThrows(IllegalStateException.class, + () -> NativeStructurePoiUpdates.reconcile(fixture.loaded)))); + + assertArrayEquals(new long[]{16L}, fixture.marker()); + fixture.allowPoiAdds(); + onOwner(() -> NativeStructurePoiUpdates.reconcile(fixture.loaded)); + assertFalse(fixture.proto.persistentDataContainer.has(DIRTY_SECTIONS)); + } + + @Test + public void explicitPoiSuppressionAndReadOnlyFullChunksDoNotCreateIntent() { + Fixture fixture = new Fixture(); + BlockPos position = new BlockPos(-31, 5, 49); + fixture.updates.setBlock(fixture.world, position, Blocks.COMPOSTER.defaultBlockState(), + 4096, 17); + assertFalse(fixture.proto.persistentDataContainer.has(DIRTY_SECTIONS)); + ImposterProtoChunk full = mock(ImposterProtoChunk.class); + when(fixture.world.getChunk(any(BlockPos.class))).thenReturn(full); + fixture.updates.setBlock(fixture.world, position, Blocks.BARREL.defaultBlockState(), 2, 17); + assertFalse(fixture.proto.persistentDataContainer.has(DIRTY_SECTIONS)); + } + + @Test + public void invalidSectionBitsRetainTheStoredRecord() throws Exception { + Fixture fixture = new Fixture(); + fixture.proto.persistentDataContainer.set(DIRTY_SECTIONS, PersistentDataType.LONG_ARRAY, new long[]{1L << 24}); + onOwner(() -> assertThrows(IllegalArgumentException.class, + () -> NativeStructurePoiUpdates.reconcile(fixture.loaded))); + assertArrayEquals(new long[]{1L << 24}, fixture.marker()); + } + + private static void onOwner(Runnable action) throws Exception { + FutureTask task = new FutureTask<>(action, null); + new TickThread(task, "Native POI test").start(); + task.get(5L, TimeUnit.SECONDS); + } + + private static PalettedContainerFactory containerFactory() { + Strategy blocks = Strategy.createForBlockStates(Block.BLOCK_STATE_REGISTRY); + BlockState air = Blocks.AIR.defaultBlockState(); + IdMapper> biomeIds = new IdMapper<>(); + Holder biome = Holder.direct((Biome) null); + biomeIds.add(biome); + Strategy> biomes = Strategy.createForBiomes(biomeIds); + Codec> biomeCodec = Codec.STRING.xmap(name -> biome, holder -> "iris-test-biome"); + return new PalettedContainerFactory(blocks, air, PalettedContainer.codecRW(BlockState.CODEC, blocks, air), + biomes, biome, PalettedContainer.codecRO(biomeCodec, biomes, biome), + PalettedContainer.codecRW(biomeCodec, biomes, biome)); + } + + private static final class Fixture { + private final NativeStructurePoiUpdates updates = new NativeStructurePoiUpdates(4096); + private final ProtoChunk proto = new ProtoChunk(new ChunkPos(-2, 3), UpgradeData.EMPTY, + LevelHeightAccessor.create(-64, 384), containerFactory(), null); + private final LevelChunk loaded = mock(LevelChunk.class); + private final WorldGenLevel world = mock(WorldGenLevel.class); + private final PoiManager manager = mock(PoiManager.class); + private final Map pois = new HashMap<>(); + + private Fixture() { + ServerLevel level = mock(ServerLevel.class); + when(level.getPoiManager()).thenReturn(manager); + loaded.persistentDataContainer = proto.persistentDataContainer; + when(loaded.getLevel()).thenReturn(level); + when(loaded.getPos()).thenReturn(proto.getPos()); + when(loaded.getSectionsCount()).thenReturn(proto.getSectionsCount()); + when(loaded.getSectionYFromSectionIndex(anyInt())).thenAnswer(call -> proto.getSectionYFromSectionIndex(call.getArgument(0))); + when(loaded.getSection(anyInt())).thenAnswer(call -> proto.getSection(call.getArgument(0))); + when(loaded.getBlockState(any())).thenAnswer(call -> proto.getBlockState(call.getArgument(0))); + when(world.getChunk(any(BlockPos.class))).thenReturn(proto); + when(world.setBlock(any(), any(), anyInt(), anyInt())).thenAnswer(call -> { + proto.setBlockState(call.getArgument(0), call.getArgument(1), call.getArgument(2)); + return true; + }); + when(manager.getOrLoad(anyLong())).thenAnswer(call -> Optional.ofNullable(pois.get((long) call.getArgument(0)))); + when(manager.getType(any())).thenAnswer(call -> { + BlockPos position = call.getArgument(0); + PoiSection section = pois.get(SectionPos.of(position).asLong()); + return section == null ? Optional.empty() : section.getType(position); + }); + doAnswer(call -> { + BlockPos position = call.getArgument(0); + pois.get(SectionPos.of(position).asLong()).remove(position); + return null; + }).when(manager).remove(any()); + allowPoiAdds(); + } + + private void allowPoiAdds() { + doAnswer(call -> { + BlockPos position = call.getArgument(0); + return pois.computeIfAbsent(SectionPos.of(position).asLong(), ignored -> new PoiSection(() -> {})) + .add(position, call.getArgument(1)); + }).when(manager).add(any(), any()); + } + + private void write(BlockPos position, BlockState state) { + updates.setBlock(world, position, state, Block.UPDATE_CLIENTS, Block.UPDATE_LIMIT); + } + + private long[] marker() { + return proto.persistentDataContainer.get(DIRTY_SECTIONS, PersistentDataType.LONG_ARRAY); + } + + private PoiSection records() { + return pois.get(SectionPos.of(new BlockPos(-31, 5, 49)).asLong()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index 12e88f147..440f61ba5 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -245,6 +245,9 @@ public interface INMSBinding { default void flushChunkIO(World world) { } + default void reconcileNativeStructurePois(Chunk chunk) { + } + void injectBiomesFromMantle(Chunk e, Mantle mantle); ItemStack applyCustomNbt(ItemStack itemStack, KMap customNbt) throws IllegalArgumentException; diff --git a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java index a10cbec55..9deffdc1c 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.nms.INMS; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedWorldManager; @@ -212,6 +213,8 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return; } + INMS.get().reconcileNativeStructurePois(e); + if (cleanupServiceStopped || cleanupService == null || cleanupService.isShutdown()) { return; } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerNativePoiTest.java b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerNativePoiTest.java new file mode 100644 index 000000000..5f8d3e4be --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerNativePoiTest.java @@ -0,0 +1,52 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.INMSBinding; +import art.arcane.iris.engine.framework.Engine; +import org.bukkit.Chunk; +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisWorldManagerNativePoiTest { + @Test + public void chunkLoadReconcilesPoisWithoutAnOptionalCleanupExecutor() { + IrisWorldManager manager = mock(IrisWorldManager.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + Chunk chunk = mock(Chunk.class); + INMSBinding binding = mock(INMSBinding.class); + when(manager.getEngine()).thenReturn(engine); + + try (MockedStatic nms = mockStatic(INMS.class)) { + nms.when(INMS::get).thenReturn(binding); + manager.onChunkLoad(chunk, false); + } + + verify(binding).reconcileNativeStructurePois(chunk); + } + + @Test + public void chunkLoadPropagatesPoiFailuresToTheRuntimeEventReporter() { + IrisWorldManager manager = mock(IrisWorldManager.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + Chunk chunk = mock(Chunk.class); + INMSBinding binding = mock(INMSBinding.class); + IllegalStateException failure = new IllegalStateException("Native POI storage failure"); + when(manager.getEngine()).thenReturn(engine); + doThrow(failure).when(binding).reconcileNativeStructurePois(chunk); + + try (MockedStatic nms = mockStatic(INMS.class)) { + nms.when(INMS::get).thenReturn(binding); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> manager.onChunkLoad(chunk, true))); + } + } +}