From dbc9d953729282e8722b1baebf2b8862c3b22159 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 16 Oct 2026 19:32:01 -0400 Subject: [PATCH] Skip stale deferred placements - Skip deferred custom block records whose live block no longer matches the provider carrier type - Leave air-carrier furniture and unresolvable carriers to the provider unchanged - Resolve each carrier once per chunk pass and log skipped records once per chunk at debug level - Skip tile records quietly when the live block no longer matches the tile material - Apply the same carrier check to the modded custom block pass - Add Bukkit, CraftEngine and modded regression tests for flattened, replaced, intact and furniture records --- .../service/ModdedChunkUpdateService.java | 52 +++- .../ModdedChunkUpdateServiceCarrierTest.java | 205 ++++++++++++++ .../1.21.1/test/source-replacements.json | 6 + .../CraftEngineStaleRecordTest.java | 179 ++++++++++++ .../platform/generation/EngineBukkitOps.java | 65 ++++- .../EngineBukkitOpsStaleRecordTest.java | 265 ++++++++++++++++++ 6 files changed, 765 insertions(+), 7 deletions(-) create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceCarrierTest.java create mode 100644 core/src/externalProviderTest/java/art/arcane/iris/platform/generation/CraftEngineStaleRecordTest.java create mode 100644 core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsStaleRecordTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java index 13356e977..0d8e8190d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java @@ -31,10 +31,12 @@ import art.arcane.iris.integration.Identifier; import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.IrisProceduralBlocks; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedBlockResolution; import art.arcane.iris.modded.ModdedLootApplier; import art.arcane.iris.modded.ModdedTileData; +import art.arcane.iris.modded.api.ModdedBlockData; import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement; import art.arcane.iris.spi.IrisLogging; @@ -50,6 +52,8 @@ import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import it.unimi.dsi.fastutil.ints.IntArrayList; +import java.util.HashMap; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -239,16 +243,24 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { int baseX = chunkX << 4; int baseZ = chunkZ << 4; int minHeight = engine.getWorld().minHeight(); + StaleCarriers stale = new StaleCarriers(); materializeDeferredSlice(chunk, Identifier.class, () -> chunk.iterate(Identifier.class, (Integer x, Integer yf, Integer z, Identifier identifier) -> { int y = yf + minHeight; if (y < level.minHeight() || y >= level.maxHeight() - 1) { return; } - NativeBlockPoint position = new NativeBlockPoint(baseX + (x & 15), y, baseZ + (z & 15)); - ModdedCustomContentRegistry.processBlockPlacement(engine, identifier.toString(), + int blockX = baseX + (x & 15); + int blockZ = baseZ + (z & 15); + String key = identifier.toString(); + if (stale.carrierReplaced(key, level.getBlock(blockX, y, blockZ))) { + return; + } + NativeBlockPoint position = new NativeBlockPoint(blockX, y, blockZ); + ModdedCustomContentRegistry.processBlockPlacement(engine, key, () -> NativeWorldMaintenance.placement(level, position)); })); + stale.report(level.name(), chunkX, chunkZ); } static void materializeDeferredSlice(MantleChunk chunk, Class sliceType, Runnable materializer) { @@ -386,4 +398,40 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { NativeWorldMaintenance.refresh(level, pos); } } + + private static final class StaleCarriers { + private static final String UNCHECKED = ""; + + private final Map carriers = new HashMap<>(); + private int skipped; + + private boolean carrierReplaced(String key, NativeBlockState live) { + String carrier = carriers.get(key); + if (carrier == null) { + carrier = carrierKey(key); + carriers.put(key, carrier); + } + if (carrier.equals(UNCHECKED) || carrier.equals(IrisProceduralBlocks.materialKey(live))) { + return false; + } + skipped++; + return true; + } + + private void report(String levelName, int chunkX, int chunkZ) { + if (skipped == 0) { + return; + } + IrisLogging.debug("Skipped %d stale custom block records at chunk %d,%d in %s", + skipped, chunkX, chunkZ, levelName); + } + + private static String carrierKey(String key) { + ModdedBlockData resolved = ModdedCustomContentRegistry.resolveBlock(key); + if (resolved == null || resolved.state().isAir()) { + return UNCHECKED; + } + return IrisProceduralBlocks.materialKey(resolved.state()); + } + } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceCarrierTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceCarrierTest.java new file mode 100644 index 000000000..b3b45d01e --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceCarrierTest.java @@ -0,0 +1,205 @@ +package art.arcane.iris.modded.service; + +import art.arcane.iris.generation.mantle.EngineMantle; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EngineMetrics; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.modded.api.ModdedBlockData; +import art.arcane.iris.modded.api.ModdedBlockPlacementContext; +import art.arcane.iris.modded.api.ModdedCustomContentRegistry; +import art.arcane.iris.modded.api.ModdedDataProvider; +import art.arcane.iris.modded.api.ModdedDataType; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.world.IrisWorld; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.function.Consumer4; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; +import art.arcane.volmlib.util.mantle.runtime.MantleHooks; +import art.arcane.volmlib.util.matter.Matter; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.NoteBlock; +import net.minecraft.world.level.block.state.BlockState; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class ModdedChunkUpdateServiceCarrierTest { + private static final String MOD_ID = "iris_carrier_guard_test"; + private static final String LAMP = MOD_ID + ":lamp"; + private static final String CHAIR = MOD_ID + ":chair"; + private static final List PLACED = new CopyOnWriteArrayList<>(); + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + ModdedCustomContentRegistry.register(new CarrierProvider()); + } + + @Before + public void clearPlacements() { + PLACED.clear(); + } + + @Test + public void staleRecordsAreSkippedWhileIntactAndAirCarrierRecordsArePlaced() { + RecordMantleChunk chunk = new RecordMantleChunk(List.of( + new Placed(1, LAMP), + new Placed(2, LAMP), + new Placed(3, LAMP), + new Placed(4, CHAIR))); + NativeWorld level = level(Map.of( + 1, Blocks.NOTE_BLOCK.defaultBlockState().setValue(NoteBlock.NOTE, 7), + 2, Blocks.AIR.defaultBlockState(), + 3, Blocks.COBBLESTONE.defaultBlockState(), + 4, Blocks.COBBLESTONE.defaultBlockState())); + List debug = new ArrayList<>(); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + logging.when(() -> IrisLogging.debug(anyString(), any(Object[].class))).thenAnswer((InvocationOnMock invocation) -> { + Object[] arguments = invocation.getArguments(); + debug.add(String.format((String) arguments[0], Arrays.copyOfRange(arguments, 1, arguments.length))); + return null; + }); + + new ModdedChunkUpdateService().updateRegeneratedChunk(engine(chunk), level, 0, 0); + } + + assertEquals(List.of(new BlockPos(1, 6, 1), new BlockPos(4, 6, 4)), PLACED); + assertTrue(chunk.isFlagged(MantleFlag.ETCHED)); + assertTrue(chunk.customDeleted()); + assertEquals(1, debug.size()); + assertTrue(debug.getFirst(), debug.getFirst().contains("2 stale")); + } + + private static NativeWorld level(Map live) { + ServerLevel serverLevel = mock(ServerLevel.class); + when(serverLevel.getBlockState(any(BlockPos.class))).thenReturn(Blocks.STONE.defaultBlockState()); + NativeWorld level = mock(NativeWorld.class); + when(level.name()).thenReturn("example:carriers"); + when(level.minHeight()).thenReturn(-64); + when(level.maxHeight()).thenReturn(320); + when(level.nativeHandle()).thenReturn(serverLevel); + for (Map.Entry entry : live.entrySet()) { + int offset = entry.getKey(); + when(level.getBlock(offset, 6, offset)).thenReturn(ModdedBlockState.of(entry.getValue(), null)); + } + return level; + } + + private static Engine engine(RecordMantleChunk chunk) { + @SuppressWarnings("unchecked") + Mantle mantle = mock(Mantle.class); + when(mantle.isChunkLoaded(0, 0)).thenReturn(true); + when(mantle.hasFlag(0, 0, MantleFlag.ETCHED)).thenAnswer((InvocationOnMock invocation) -> chunk.isFlagged(MantleFlag.ETCHED)); + when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> chunk.use()); + IrisWorld world = mock(IrisWorld.class); + when(world.minHeight()).thenReturn(-64); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getMantle()).thenReturn(mantle); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(world); + when(engine.getMetrics()).thenReturn(new EngineMetrics(8)); + when(engine.getMantle()).thenReturn(engineMantle); + return engine; + } + + @SuppressWarnings("unchecked") + private static MantleDataAdapter emptyAdapter() { + return (MantleDataAdapter) Proxy.newProxyInstance( + MantleDataAdapter.class.getClassLoader(), + new Class[]{MantleDataAdapter.class}, + (proxy, method, arguments) -> { + throw new UnsupportedOperationException(method.getName()); + } + ); + } + + private record Placed(int offset, String key) { + } + + private static final class RecordMantleChunk extends MantleChunk { + private final List records; + private boolean customDeleted; + + private RecordMantleChunk(List records) { + super(1, 0, 0, emptyAdapter(), MantleHooks.NONE); + this.records = records; + } + + @Override + public void iterate(Class type, Consumer4 iterator) { + if (type != Identifier.class) { + return; + } + for (Placed record : records) { + iterator.accept(record.offset(), 70, record.offset(), type.cast(Identifier.fromString(record.key()))); + } + } + + @Override + public void deleteSlices(Class type) { + if (type == Identifier.class) { + customDeleted = true; + } + } + + private boolean customDeleted() { + return customDeleted; + } + } + + private static final class CarrierProvider implements ModdedDataProvider { + @Override + public String modId() { + return MOD_ID; + } + + @Override + public Collection getTypes(ModdedDataType type) { + return type == ModdedDataType.BLOCK ? List.of(LAMP, CHAIR) : List.of(); + } + + @Override + public boolean isValidProvider(String id, ModdedDataType type) { + return type == ModdedDataType.BLOCK && (LAMP.equals(id) || CHAIR.equals(id)); + } + + @Override + public ModdedBlockData getBlockData(String blockId, Map state) { + BlockState carrier = CHAIR.equals(blockId) ? Blocks.AIR.defaultBlockState() : Blocks.NOTE_BLOCK.defaultBlockState(); + return ModdedBlockData.deferred(ModdedBlockState.of(carrier, null)); + } + + @Override + public void processBlockPlacement(ModdedBlockPlacementContext context) { + PLACED.add(context.placement().position()); + } + } +} diff --git a/adapters/modded-common/src/versions/1.21.1/test/source-replacements.json b/adapters/modded-common/src/versions/1.21.1/test/source-replacements.json index b5287de53..ebbc12b13 100644 --- a/adapters/modded-common/src/versions/1.21.1/test/source-replacements.json +++ b/adapters/modded-common/src/versions/1.21.1/test/source-replacements.json @@ -136,6 +136,12 @@ "replacement": "when(level.getMaxBuildHeight()).thenReturn(320);" } ], + "art/arcane/iris/modded/service/ModdedChunkUpdateServiceCarrierTest.java": [ + { + "pattern": "\\bResourceLocation\\b", + "replacement": "Identifier" + } + ], "art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnPopulationTest.java": [ { "pattern": "when\\(level\\.getMaxY\\(\\)\\)\\.thenReturn\\(320\\);", diff --git a/core/src/externalProviderTest/java/art/arcane/iris/platform/generation/CraftEngineStaleRecordTest.java b/core/src/externalProviderTest/java/art/arcane/iris/platform/generation/CraftEngineStaleRecordTest.java new file mode 100644 index 000000000..38a2b8d58 --- /dev/null +++ b/core/src/externalProviderTest/java/art/arcane/iris/platform/generation/CraftEngineStaleRecordTest.java @@ -0,0 +1,179 @@ +package art.arcane.iris.platform.generation; + +import art.arcane.iris.generation.block.IrisCustomData; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.iris.integration.ExternalDataProvider; +import art.arcane.iris.integration.ExternalDataSVC; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.integration.data.CraftEngineDataProvider; +import art.arcane.iris.platform.bukkit.BukkitBlockResolution; +import art.arcane.iris.platform.registry.RegistryUtil; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.testsupport.BukkitTestServer; +import art.arcane.iris.world.IrisWorld; +import art.arcane.volmlib.util.function.Consumer4; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +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.entity.furniture.BukkitFurniture; +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.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.Chunk; +import org.bukkit.Location; +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 org.mockito.invocation.InvocationOnMock; + +import java.lang.reflect.Field; +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.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CraftEngineStaleRecordTest { + @Test + public void stalePlacementRecordsAreSkippedAndIntactOnesPlaced() throws Exception { + BukkitTestServer.install(); + BlockDefinition lampDefinition = mock(BlockDefinition.class); + ImmutableBlockState lamp = mock(ImmutableBlockState.class); + when(lamp.owner()).thenReturn(Holder.direct(lampDefinition)); + when(lamp.toString()).thenReturn("test:lamp"); + when(lampDefinition.defaultState()).thenReturn(lamp); + FurnitureDefinition chairDefinition = mock(FurnitureDefinition.class); + when(chairDefinition.id()).thenReturn(Key.of("test:chair")); + when(chairDefinition.variants()).thenReturn(Map.of("alpha", mock(FurnitureVariant.class))); + BlockData noteBlock = blockData(Material.NOTE_BLOCK); + BlockData air = blockData(Material.AIR); + CraftEngineDataProvider provider = new CraftEngineDataProvider(); + ExternalDataSVC service = new ExternalDataSVC(); + activate(service, provider); + + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(mock(IrisWorld.class)); + SeedManager seeds = mock(SeedManager.class); + when(engine.getSeedManager()).thenReturn(seeds); + World world = mock(World.class); + when(world.getMinHeight()).thenReturn(0); + when(world.getMaxHeight()).thenReturn(256); + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + block(chunk, 1, Material.NOTE_BLOCK); + block(chunk, 2, Material.AIR); + block(chunk, 3, Material.COBBLESTONE); + block(chunk, 4, Material.COBBLESTONE); + + try (MockedStatic craftEngine = mockStatic(BukkitCraftEngine.class); + MockedStatic blocks = mockStatic(CraftEngineBlocks.class); + MockedStatic furniture = mockStatic(CraftEngineFurniture.class); + MockedStatic items = mockStatic(CraftEngineItems.class); + MockedStatic registry = materialRegistry(); + MockedStatic resolution = mockStatic(BukkitBlockResolution.class); + MockedStatic services = mockStatic(IrisServices.class)) { + resolution.when(BukkitBlockResolution::getAir).thenReturn(air); + services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(service); + blocks.when(CraftEngineBlocks::loadedBlocks).thenReturn(Map.of(Key.of("test:lamp"), lampDefinition)); + furniture.when(CraftEngineFurniture::loadedFurniture).thenReturn(Map.of(Key.of("test:chair"), chairDefinition)); + items.when(CraftEngineItems::loadedItems).thenReturn(Map.of()); + blocks.when(() -> CraftEngineBlocks.byId(Key.of("test:lamp"))).thenReturn(lampDefinition); + blocks.when(() -> CraftEngineBlocks.getBukkitBlockData(lamp)).thenReturn(noteBlock); + blocks.when(() -> CraftEngineBlocks.place(any(Location.class), eq(lamp), eq(false))).thenReturn(true); + furniture.when(() -> CraftEngineFurniture.byId(Key.of("test:chair"))).thenReturn(chairDefinition); + furniture.when(() -> CraftEngineFurniture.place(any(Location.class), eq(chairDefinition), eq("alpha"), eq(false))) + .thenReturn(mock(BukkitFurniture.class)); + provider.onReload(mock(CraftEngineReloadEvent.class)); + Identifier lampRecord = generatedRecord(service, "test:lamp"); + Identifier chairRecord = generatedRecord(service, "test:chair"); + assertEquals("craftengine:test/lamp", lampRecord.toString()); + assertEquals("craftengine:test/chair[variant=alpha]", chairRecord.toString()); + + EngineBukkitOps.materializeCustomBlocks(engine, chunk, records( + new Placed(1, lampRecord), + new Placed(2, lampRecord), + new Placed(3, lampRecord), + new Placed(4, chairRecord))); + + blocks.verify(() -> CraftEngineBlocks.place(any(Location.class), eq(lamp), eq(false)), times(1)); + blocks.verify(() -> CraftEngineBlocks.place(location(1), lamp, false)); + blocks.verify(() -> CraftEngineBlocks.place(location(2), lamp, false), never()); + blocks.verify(() -> CraftEngineBlocks.place(location(3), lamp, false), never()); + furniture.verify(() -> CraftEngineFurniture.place(location(4), chairDefinition, "alpha", false)); + } + } + + private static MockedStatic materialRegistry() { + MockedStatic registry = mockStatic(RegistryUtil.class); + registry.when(() -> RegistryUtil.find(Material.class, "grass", "short_grass")).thenReturn(Material.SHORT_GRASS); + return registry; + } + + private static Identifier generatedRecord(ExternalDataSVC service, String key) { + BlockData resolved = service.getBlockData(Identifier.fromString(key)).orElseThrow(); + return ((IrisCustomData) resolved).getCustom(); + } + + private static BlockData blockData(Material material) { + BlockData data = mock(BlockData.class); + when(data.getMaterial()).thenReturn(material); + return data; + } + + private static Block block(Chunk chunk, int offset, Material type) { + Block block = mock(Block.class); + when(block.getType()).thenReturn(type); + when(block.getX()).thenReturn(offset); + when(block.getY()).thenReturn(10); + when(block.getZ()).thenReturn(offset); + when(block.getLocation()).thenAnswer((InvocationOnMock invocation) -> location(offset)); + when(chunk.getBlock(offset, 10, offset)).thenReturn(block); + return block; + } + + private static Location location(int offset) { + return new Location(null, offset, 10, offset); + } + + @SuppressWarnings("unchecked") + private static MantleChunk records(Placed... records) { + MantleChunk chunk = mock(MantleChunk.class); + doAnswer((InvocationOnMock invocation) -> { + Consumer4 iterator = invocation.getArgument(1); + for (Placed record : records) { + iterator.accept(record.offset(), 10, record.offset(), record.identifier()); + } + return null; + }).when(chunk).iterate(eq(Identifier.class), any()); + return chunk; + } + + @SuppressWarnings("unchecked") + private static void activate(ExternalDataSVC service, ExternalDataProvider provider) throws Exception { + Field field = ExternalDataSVC.class.getDeclaredField("activeProviders"); + field.setAccessible(true); + ((List) field.get(service)).add(provider); + } + + private record Placed(int offset, Identifier identifier) { + } +} diff --git a/core/src/main/java/art/arcane/iris/platform/generation/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/platform/generation/EngineBukkitOps.java index 6a1bb99ce..59e49b07f 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/EngineBukkitOps.java @@ -36,6 +36,7 @@ import art.arcane.iris.world.loot.InventorySlotType; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.world.loot.IrisLootTable; import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.generation.block.IrisCustomData; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.platform.bukkit.BukkitBlockResolution; import art.arcane.iris.platform.bukkit.BukkitBlockState; @@ -83,7 +84,10 @@ import org.bukkit.loot.Lootable; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; @@ -146,12 +150,16 @@ public final class EngineBukkitOps { } static void materializeTiles(Engine engine, Chunk chunk, MantleChunk mantleChunk) { + StaleRecords stale = new StaleRecords(); mantleChunk.iterate(TileWrapper.class, (x, y, z, value) -> { int worldY = y + engine.getWorld().minHeight(); if (worldY < chunk.getWorld().getMinHeight() || worldY >= chunk.getWorld().getMaxHeight()) { return; } Block block = chunk.getBlock(x & 15, worldY, z & 15); + if (stale.tileReplaced(block, value.getData())) { + return; + } if (!TileData.setTileState(block, value.getData())) { NamespacedKey blockTypeKey = KeyedType.getKey(block.getType()); String blockType = blockTypeKey == null ? block.getType().name() : blockTypeKey.toString(); @@ -160,21 +168,25 @@ public final class EngineBukkitOps { } }); mantleChunk.deleteSlices(TileWrapper.class); + stale.report("tile", chunk); } static void materializeCustomBlocks(Engine engine, Chunk chunk, MantleChunk mantleChunk) { + StaleRecords stale = new StaleRecords(); mantleChunk.iterate(Identifier.class, (x, y, z, value) -> { int worldY = y + engine.getWorld().minHeight(); if (worldY < chunk.getWorld().getMinHeight() || worldY >= chunk.getWorld().getMaxHeight()) { return; } - IrisServices.get(ExternalDataSVC.class).processUpdate( - engine, - chunk.getBlock(x & 15, worldY, z & 15), - value - ); + ExternalDataSVC service = IrisServices.get(ExternalDataSVC.class); + Block block = chunk.getBlock(x & 15, worldY, z & 15); + if (stale.carrierReplaced(service, block, value)) { + return; + } + service.processUpdate(engine, block, value); }); mantleChunk.deleteSlices(Identifier.class); + stale.report("custom block", chunk); } static void materializeUpdates(Engine engine, Chunk chunk, MantleChunk mantleChunk) { @@ -855,4 +867,47 @@ public final class EngineBukkitOps { } } + private static final class StaleRecords { + private final Map carriers = new HashMap<>(); + private int skipped; + + private boolean carrierReplaced(ExternalDataSVC service, Block block, Identifier record) { + Material carrier = carriers.get(record); + if (carrier == null) { + carrier = carrierType(service, record); + carriers.put(record, carrier); + } + if (carrier == Material.AIR || block.getType() == carrier) { + return false; + } + skipped++; + return true; + } + + private boolean tileReplaced(Block block, TileData tile) { + if (tile.resolveMaterial() == null || tile.isApplicable(block.getBlockData())) { + return false; + } + skipped++; + return true; + } + + private void report(String records, Chunk chunk) { + if (skipped == 0) { + return; + } + IrisLogging.debug("Skipped %d stale %s records at chunk %d,%d in %s", + skipped, records, chunk.getX(), chunk.getZ(), chunk.getWorld().getName()); + } + + private static Material carrierType(ExternalDataSVC service, Identifier record) { + Optional resolved = service.getBlockData(record); + if (resolved.isEmpty()) { + return Material.AIR; + } + BlockData data = resolved.get(); + Material type = (data instanceof IrisCustomData custom ? custom.getBase() : data).getMaterial(); + return type == Material.CAVE_AIR || type == Material.VOID_AIR ? Material.AIR : type; + } + } } diff --git a/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsStaleRecordTest.java b/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsStaleRecordTest.java new file mode 100644 index 000000000..6636655ad --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsStaleRecordTest.java @@ -0,0 +1,265 @@ +package art.arcane.iris.platform.generation; + +import art.arcane.iris.generation.block.IrisCustomData; +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.integration.ExternalDataSVC; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.storage.matter.TileWrapper; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.function.Consumer4; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.TileState; +import org.bukkit.block.data.BlockData; +import org.junit.ClassRule; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +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 EngineBukkitOpsStaleRecordTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private static final Identifier ORE = Identifier.fromString("itemsadder:forest/amber_ore"); + private static final Identifier CHAIR = Identifier.fromString("craftengine:test/chair[variant=alpha]"); + + @Test + public void customRecordOverFlattenedAirIsNotPlaced() { + Fixture fixture = new Fixture(); + fixture.carrier(ORE, Material.NOTE_BLOCK); + Block flattened = fixture.block(1, 10, 1, Material.AIR); + MantleChunk records = customRecords(new Placed<>(1, 10, 1, ORE)); + + fixture.materializeCustomBlocks(records); + + verify(fixture.service, never()).processUpdate(any(), eq(flattened), any()); + verify(records).deleteSlices(Identifier.class); + } + + @Test + public void customRecordOverReplacedBlockIsNotPlaced() { + Fixture fixture = new Fixture(); + fixture.carrier(ORE, Material.NOTE_BLOCK); + Block wall = fixture.block(2, 10, 2, Material.COBBLESTONE); + MantleChunk records = customRecords(new Placed<>(2, 10, 2, ORE)); + + fixture.materializeCustomBlocks(records); + + verify(fixture.service, never()).processUpdate(any(), eq(wall), any()); + verify(records).deleteSlices(Identifier.class); + } + + @Test + public void customRecordOverIntactCarrierIsPlaced() { + Fixture fixture = new Fixture(); + fixture.carrier(ORE, Material.NOTE_BLOCK); + Block intact = fixture.block(3, 10, 3, Material.NOTE_BLOCK); + + fixture.materializeCustomBlocks(customRecords(new Placed<>(3, 10, 3, ORE))); + + verify(fixture.service).processUpdate(fixture.engine, intact, ORE); + } + + @Test + public void airCarrierFurnitureRecordIsPlacedWhateverTheLiveBlock() { + Fixture fixture = new Fixture(); + fixture.carrier(CHAIR, Material.AIR); + Block open = fixture.block(4, 10, 4, Material.AIR); + Block covered = fixture.block(5, 10, 5, Material.COBBLESTONE); + + fixture.materializeCustomBlocks(customRecords( + new Placed<>(4, 10, 4, CHAIR), + new Placed<>(5, 10, 5, CHAIR))); + + verify(fixture.service).processUpdate(fixture.engine, open, CHAIR); + verify(fixture.service).processUpdate(fixture.engine, covered, CHAIR); + } + + @Test + public void unresolvableCarrierLeavesPlacementToTheProvider() { + Fixture fixture = new Fixture(); + when(fixture.service.getBlockData(ORE)).thenReturn(Optional.empty()); + Block block = fixture.block(6, 10, 6, Material.STONE); + + fixture.materializeCustomBlocks(customRecords(new Placed<>(6, 10, 6, ORE))); + + verify(fixture.service).processUpdate(fixture.engine, block, ORE); + } + + @Test + public void carrierResolvesOncePerIdentifierAndStaleRecordsLogOncePerChunk() { + Fixture fixture = new Fixture(); + fixture.carrier(ORE, Material.NOTE_BLOCK); + fixture.block(1, 10, 1, Material.AIR); + fixture.block(2, 10, 2, Material.COBBLESTONE); + Block intact = fixture.block(3, 10, 3, Material.NOTE_BLOCK); + MantleChunk records = customRecords( + new Placed<>(1, 10, 1, ORE), + new Placed<>(2, 10, 2, ORE), + new Placed<>(3, 10, 3, ORE)); + + List debug = fixture.materializeCustomBlocksCapturingDebug(records); + + verify(fixture.service, times(1)).getBlockData(ORE); + verify(fixture.service, times(1)).processUpdate(any(), any(), any()); + verify(fixture.service).processUpdate(fixture.engine, intact, ORE); + assertEquals(1, debug.size()); + assertTrue(debug.getFirst(), debug.getFirst().contains("2 stale")); + } + + @Test + public void staleTileRecordIsSkippedWithoutWarning() { + Fixture fixture = new Fixture(); + TileData chest = chestTile(); + Block replaced = fixture.block(7, 10, 7, Material.STONE); + BlockData stone = blockData(Material.STONE); + when(replaced.getBlockData()).thenReturn(stone); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + EngineBukkitOps.materializeTiles(fixture.engine, fixture.chunk, tileRecords(new Placed<>(7, 10, 7, chest))); + + logging.verify(() -> IrisLogging.warn(anyString(), any(Object[].class)), never()); + logging.verify(() -> IrisLogging.debug(anyString(), any(Object[].class)), times(1)); + } + verify(chest, never()).toBukkitTry(any()); + } + + @Test + public void intactTileRecordIsApplied() { + Fixture fixture = new Fixture(); + TileData chest = chestTile(); + Block intact = fixture.block(8, 10, 8, Material.CHEST); + BlockData chestData = blockData(Material.CHEST); + when(intact.getBlockData()).thenReturn(chestData); + when(intact.getState()).thenReturn(mock(TileState.class)); + doReturn(true).when(chest).toBukkitTry(intact); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + EngineBukkitOps.materializeTiles(fixture.engine, fixture.chunk, tileRecords(new Placed<>(8, 10, 8, chest))); + + logging.verify(() -> IrisLogging.warn(anyString(), any(Object[].class)), never()); + } + verify(chest).toBukkitTry(intact); + } + + private static TileData chestTile() { + TileData tile = spy(new TileData("minecraft:chest", new KMap<>())); + doReturn(Material.CHEST).when(tile).resolveMaterial(); + return tile; + } + + private static BlockData blockData(Material material) { + BlockData data = mock(BlockData.class); + when(data.getMaterial()).thenReturn(material); + return data; + } + + @SafeVarargs + private static MantleChunk customRecords(Placed... records) { + return records(Identifier.class, records); + } + + @SafeVarargs + private static MantleChunk tileRecords(Placed... records) { + List> wrapped = new ArrayList<>(records.length); + for (Placed record : records) { + wrapped.add(new Placed<>(record.x(), record.y(), record.z(), new TileWrapper(record.value()))); + } + return records(TileWrapper.class, wrapped.toArray(Placed[]::new)); + } + + @SuppressWarnings("unchecked") + private static MantleChunk records(Class type, Placed[] records) { + MantleChunk chunk = mock(MantleChunk.class); + doAnswer((InvocationOnMock invocation) -> { + Consumer4 iterator = invocation.getArgument(1); + for (Placed record : records) { + iterator.accept(record.x(), record.y(), record.z(), record.value()); + } + return null; + }).when(chunk).iterate(eq(type), any()); + return chunk; + } + + private record Placed(int x, int y, int z, T value) { + } + + private static final class Fixture { + private final Engine engine = mock(Engine.class); + private final Chunk chunk = mock(Chunk.class); + private final ExternalDataSVC service = mock(ExternalDataSVC.class); + + private Fixture() { + IrisWorld irisWorld = mock(IrisWorld.class); + when(engine.getWorld()).thenReturn(irisWorld); + World world = mock(World.class); + when(world.getMinHeight()).thenReturn(0); + when(world.getMaxHeight()).thenReturn(256); + when(world.getName()).thenReturn("example"); + when(chunk.getWorld()).thenReturn(world); + } + + private void carrier(Identifier identifier, Material material) { + BlockData carrier = IrisCustomData.of(blockData(material), identifier); + when(service.getBlockData(identifier)).thenReturn(Optional.of(carrier)); + } + + private Block block(int x, int y, int z, Material type) { + Block block = mock(Block.class); + when(block.getType()).thenReturn(type); + when(chunk.getBlock(x, y, z)).thenReturn(block); + return block; + } + + private void materializeCustomBlocks(MantleChunk records) { + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(service); + EngineBukkitOps.materializeCustomBlocks(engine, chunk, records); + } + } + + private List materializeCustomBlocksCapturingDebug(MantleChunk records) { + List messages = new ArrayList<>(); + try (MockedStatic services = mockStatic(IrisServices.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(service); + logging.when(() -> IrisLogging.debug(anyString(), any(Object[].class))).thenAnswer((InvocationOnMock invocation) -> { + Object[] arguments = invocation.getArguments(); + messages.add(String.format((String) arguments[0], Arrays.copyOfRange(arguments, 1, arguments.length))); + return null; + }); + EngineBukkitOps.materializeCustomBlocks(engine, chunk, records); + } + return messages; + } + } +}