diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/platform/bukkit/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/platform/bukkit/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java index 19960216f..0c2964d07 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/platform/bukkit/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/platform/bukkit/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java @@ -231,7 +231,7 @@ public class NativeStructureWorldgenAccessTest { recording.world(), generationCenter, (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - true, + true, true, position -> false, NativeBlockChangeSet.disabled()); int x = generationCenter.getMiddleBlockX(); @@ -250,7 +250,7 @@ public class NativeStructureWorldgenAccessTest { recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - false, + false, true, position::equals, NativeBlockChangeSet.disabled()); @@ -322,7 +322,7 @@ public class NativeStructureWorldgenAccessTest { recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - false, + false, true, position -> false, NativeBlockChangeSet.disabled()); } diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/platform/bukkit/nms/v26_3_R1/NativeStructureWorldgenAccessTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/platform/bukkit/nms/v26_3_R1/NativeStructureWorldgenAccessTest.java index a40a5ee71..602738f90 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/platform/bukkit/nms/v26_3_R1/NativeStructureWorldgenAccessTest.java +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/platform/bukkit/nms/v26_3_R1/NativeStructureWorldgenAccessTest.java @@ -231,7 +231,7 @@ public class NativeStructureWorldgenAccessTest { recording.world(), generationCenter, (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - true, + true, true, position -> false, NativeBlockChangeSet.disabled()); int x = generationCenter.getMiddleBlockX(); @@ -250,7 +250,7 @@ public class NativeStructureWorldgenAccessTest { recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - false, + false, true, position::equals, NativeBlockChangeSet.disabled()); @@ -322,7 +322,7 @@ public class NativeStructureWorldgenAccessTest { recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - false, + false, true, position -> false, NativeBlockChangeSet.disabled()); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java index 8933cdc93..d7dc059ec 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java @@ -19,6 +19,10 @@ package art.arcane.iris.world.runtime; import art.arcane.iris.api.world.IrisWorldPhase; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.iris.integration.ExternalDataSVC; +import art.arcane.iris.spi.IrisServices; +import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint; import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.world.event.IrisEngineHotloadEvent; @@ -51,6 +55,13 @@ import org.bukkit.World; import org.bukkit.WorldBorder; public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { + @Override + public boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) { + ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class); + return service != null && service.removeGenerated( + BukkitWorldBinding.world(engine.getWorld()).getBlockAt(position.x(), position.y(), position.z()), entry); + } + @Override public CompletableFuture flushSavedTerrainCapture(Engine engine) { return INMS.get().flushSavedTerrainCapture(BukkitWorldBinding.world(engine.getWorld())); diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/biomes/flat.json b/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/biomes/flat.json new file mode 100644 index 000000000..e6160bdc6 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/biomes/flat.json @@ -0,0 +1,46 @@ +{ + "name": "Flat", + "derivative": "minecraft:snowy_plains", + "vanillaDerivative": "minecraft:snowy_plains", + "layers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [ + { + "block": "minecraft:lime_wool" + } + ] + }, + { + "minHeight": 384, + "maxHeight": 384, + "palette": [ + { + "block": "minecraft:green_concrete" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 96, + "max": 96 + } + ], + "decorators": [ + { + "palette": [ + { + "block": "craftengine:default/flower_basket", + "data": { + "variant": "ground" + } + } + ], + "chance": 1, + "forcePlace": true + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/dimensions/ce-native-feature-fixture.json b/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/dimensions/ce-native-feature-fixture.json new file mode 100644 index 000000000..4932589b5 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/dimensions/ce-native-feature-fixture.json @@ -0,0 +1,39 @@ +{ + "name": "CraftEngine Imported Feature Fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "flat" + ], + "focusRegion": "flat", + "focus": "flat", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { + "min": -64, + "max": 320 + }, + "logicalHeight": 384, + "fluidHeight": 0, + "bedrock": false, + "rockPalette": { + "palette": [ + { + "block": "minecraft:red_concrete" + } + ] + }, + "importedStructures": { + "disabled": [ + "minecraft:" + ] + }, + "importedFeatures": { + "enabled": true, + "steps": [ + "TOP_LAYER_MODIFICATION" + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/generators/flat.json b/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/generators/flat.json new file mode 100644 index 000000000..7cd98b811 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/generators/flat.json @@ -0,0 +1,15 @@ +{ + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "seed": 310, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/regions/flat.json b/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/regions/flat.json new file mode 100644 index 000000000..d6fff925f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-feature-pack/regions/flat.json @@ -0,0 +1,12 @@ +{ + "name": "Flat", + "landBiomes": [ + "flat" + ], + "seaBiomes": [ + "flat" + ], + "shoreBiomes": [ + "flat" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/build.gradle b/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/build.gradle new file mode 100644 index 000000000..07d261314 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/build.gradle @@ -0,0 +1,35 @@ +plugins { + id 'java' +} + +version = '1.0.0' + +repositories { + mavenCentral() + maven { url = 'https://repo.papermc.io/repository/maven-public/' } +} + +dependencies { + compileOnly files(providers.gradleProperty('irisJar').get()) + compileOnly files(providers.gradleProperty('craftEngineJar').get()) + compileOnly 'io.papermc.paper:paper-api:26.1.2.build.74-stable' +} + +java { + toolchain.languageVersion = JavaLanguageVersion.of(25) +} + +sourceSets { + main { + java.srcDirs = ['src'] + resources.srcDirs = ['resources'] + } +} + +tasks.withType(JavaCompile).configureEach { + options.compilerArgs += '-parameters' +} + +tasks.named('jar') { + archiveBaseName = 'IrisCraftEngineNativeFixture' +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/resources/plugin.yml b/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/resources/plugin.yml new file mode 100644 index 000000000..6e167924e --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/resources/plugin.yml @@ -0,0 +1,12 @@ +name: IrisCraftEngineNativeFixture +version: 1.0.0 +main: art.arcane.iris.fixture.CraftEngineNativeFixture +api-version: '26.1.2' +folia-supported: true +depend: [Iris, CraftEngine] +commands: + iriscenative: + permission: iriscenative.run +permissions: + iriscenative.run: + default: op diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/settings.gradle b/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/settings.gradle new file mode 100644 index 000000000..8a322c58c --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'iris-craftengine-native-fixture' diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/src/art/arcane/iris/fixture/CraftEngineNativeFixture.java b/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/src/art/arcane/iris/fixture/CraftEngineNativeFixture.java new file mode 100644 index 000000000..2ed375e75 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-fixture/src/art/arcane/iris/fixture/CraftEngineNativeFixture.java @@ -0,0 +1,385 @@ +package art.arcane.iris.fixture; + +import art.arcane.iris.generation.block.IrisCustomData; +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.mantle.DeferredPlacementRecords; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.integration.ExternalDataSVC; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.platform.generation.EngineBukkitOps; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.world.IrisCreator; +import art.arcane.iris.world.IrisToolbelt; +import art.arcane.iris.world.storage.matter.TileWrapper; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +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.core.util.Key; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; + +public final class CraftEngineNativeFixture extends JavaPlugin { + private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "ce_native_probe"); + private static final NamespacedKey FEATURE_WORLD_KEY = new NamespacedKey("iris", "ce_native_feature_probe"); + private static final int TEST_Y = 180; + private static final int TEST_Z = 8; + private static final String BLOCK_ID = "craftengine:default/topaz_ore"; + private static final String FURNITURE_ID = "craftengine:default/flower_basket[variant=ground]"; + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { + if (!(sender instanceof Player player) || arguments.length != 1) { + sender.sendMessage("CE_FAILED Use /iriscenative prepare|pending|neighbor|loaded|features|restart from a player"); + return true; + } + if (arguments[0].equals("prepare")) { + player.setGameMode(GameMode.CREATIVE); + prepare(player); + return true; + } + if (arguments[0].equals("features")) { + importedFeatures(player); + return true; + } + World world = Bukkit.getWorld(WORLD_KEY); + if (world == null) { + fail(player, new IllegalStateException("Fixture world has not been created")); + return true; + } + if (arguments[0].equals("restart")) { + loadChunks().thenCompose(ignored -> J.runRegionFuture(world, 0, 0, + () -> later(player, world, () -> restarted(player, world)))) + .whenComplete((ignored, failure) -> { + if (failure != null) { + fail(player, failure); + } + }); + return true; + } + J.runRegionFuture(world, 0, 0, () -> { + switch (arguments[0]) { + case "pending" -> pending(player, world); + case "neighbor" -> neighbor(player, world); + case "loaded" -> invalidateLoaded(player, world, IrisToolbelt.access(world).getEngine()); + default -> throw new IllegalArgumentException("Unknown fixture operation"); + } + }).whenComplete((ignored, failure) -> { + if (failure != null) { + fail(player, failure); + } + }); + return true; + } + + private void prepare(Player player) { + CompletableFuture.runAsync(() -> { + if (Bukkit.getWorld(WORLD_KEY) == null) { + try { + new IrisCreator().name(WORLD_KEY.toString()).dimension("ce-native-fixture") + .seed(78264193L).create(); + } catch (Exception failure) { + throw new IllegalStateException("Could not create CraftEngine fixture world", failure); + } + } + }).thenCompose(ignored -> loadChunks()).thenCompose(ignored -> { + World world = Bukkit.getWorld(WORLD_KEY); + return J.runRegionFuture(world, 0, 0, () -> preparePlatform(world)); + }).thenAccept(ignored -> { + World world = Bukkit.getWorld(WORLD_KEY); + if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, TEST_Y + 3, 8.5)) + .whenComplete((teleported, failure) -> { + if (failure != null || !Boolean.TRUE.equals(teleported)) { + fail(player, failure == null ? new IllegalStateException("Fixture teleport failed") : failure); + return; + } + player.sendMessage("CE_READY world=" + WORLD_KEY + " folia=" + J.isFolia()); + }))) { + throw new IllegalStateException("Could not schedule fixture teleport"); + } + }).exceptionally(failure -> { + fail(player, failure); + return null; + }); + } + + private void importedFeatures(Player player) { + CompletableFuture.runAsync(() -> { + if (Bukkit.getWorld(FEATURE_WORLD_KEY) == null) { + try { + new IrisCreator().name(FEATURE_WORLD_KEY.toString()).dimension("ce-native-feature-fixture") + .seed(78264193L).create(); + } catch (Exception failure) { + throw new IllegalStateException("Could not create imported feature fixture world", failure); + } + } + }).thenCompose(ignored -> { + World world = Bukkit.getWorld(FEATURE_WORLD_KEY); + return world.getChunkAtAsync(0, 0, true); + }).thenCompose(chunk -> J.runRegionFuture(chunk.getWorld(), 0, 0, () -> { + World world = chunk.getWorld(); + Engine engine = IrisToolbelt.access(world).getEngine(); + int snow = 0; + int pending = 0; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + if (world.getBlockAt(x, 97, z).getType() == Material.SNOW) { + snow++; + } + if (engine.getMantle().getMantle().get(x, 97 - engine.getMinHeight(), z, Identifier.class) != null) { + pending++; + } + } + } + require(snow == 256, "Imported snow feature did not cover all 256 test columns: " + snow); + require(pending == 0, "Imported feature left pending furniture records: " + pending); + for (Entity entity : chunk.getEntities()) { + require(!CraftEngineFurniture.isFurniture(entity), "Imported snow feature left generated furniture"); + } + if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, 101, 8.5)) + .whenComplete((teleported, failure) -> { + if (failure != null || !Boolean.TRUE.equals(teleported)) { + fail(player, failure == null ? new IllegalStateException("Feature world teleport failed") : failure); + return; + } + player.sendMessage("CE_FEATURES snow=256 pending=0 furniture=0"); + }))) { + throw new IllegalStateException("Could not schedule feature world teleport"); + } + })).exceptionally(failure -> { + fail(player, failure); + return null; + }); + } + + private CompletableFuture loadChunks() { + World world = Bukkit.getWorld(WORLD_KEY); + require(world != null && IrisToolbelt.isIrisWorld(world), "Fixture must use a real Iris world"); + List> chunks = new ArrayList<>(15); + for (int x = -1; x <= 3; x++) { + for (int z = -1; z <= 1; z++) { + chunks.add(world.getChunkAtAsync(x, z, true).thenCompose(chunk -> + J.runRegionFuture(world, chunk.getX(), chunk.getZ(), () -> chunk.addPluginChunkTicket(this)))); + } + } + return CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new)); + } + + private void preparePlatform(World world) { + require(CraftEngineBlocks.byId(Key.of("default:topaz_ore")) != null, "CraftEngine default block is missing"); + require(CraftEngineFurniture.byId(Key.of("default:flower_basket")) != null, "CraftEngine furniture is missing"); + for (int chunkX = 0; chunkX <= 2; chunkX++) { + for (Entity entity : world.getChunkAt(chunkX, 0).getEntities()) { + if (CraftEngineFurniture.isFurniture(entity)) { + CraftEngineFurniture.remove(entity, false, false); + } + } + } + for (int x = 0; x <= 47; x++) { + for (int z = 2; z <= 14; z++) { + world.getBlockAt(x, TEST_Y - 1, z).setType(Material.STONE, false); + for (int y = TEST_Y; y <= TEST_Y + 5; y++) { + world.getBlockAt(x, y, z).setType(Material.AIR, false); + } + } + } + } + + private void pending(Player player, World world) { + Engine engine = IrisToolbelt.access(world).getEngine(); + Mantle mantle = engine.getMantle().getMantle(); + queue(engine, world, 2, BLOCK_ID); + queue(engine, world, 4, BLOCK_ID); + queue(engine, world, 6, BLOCK_ID); + queue(engine, world, 8, BLOCK_ID); + queue(engine, world, 10, FURNITURE_ID); + queue(engine, world, 12, FURNITURE_ID); + Block chest = world.getBlockAt(14, TEST_Y, TEST_Z); + chest.setType(Material.CHEST, false); + mantle.set(14, TEST_Y - engine.getMinHeight(), TEST_Z, new TileWrapper(TileData.getTileState(chest, false))); + world.getBlockAt(4, TEST_Y, TEST_Z).setType(Material.AIR, false); + world.getBlockAt(6, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false); + + NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world)); + changes.add(8, TEST_Y, TEST_Z); + changes.add(12, TEST_Y - 1, TEST_Z); + changes.add(12, TEST_Y, TEST_Z); + changes.add(14, TEST_Y, TEST_Z); + world.getBlockAt(8, TEST_Y, TEST_Z).setType(Material.AIR, false); + world.getBlockAt(12, TEST_Y - 1, TEST_Z).setType(Material.AIR, false); + chest.setType(Material.COBBLESTONE, false); + DeferredPlacementRecords.invalidate(engine, changes); + require(record(engine, 8, Identifier.class) == null, "Native cut retained a pending custom record"); + require(record(engine, 12, Identifier.class) == null, "Flatten column retained pending furniture"); + require(record(engine, 14, TileWrapper.class) == null, "Native wall retained pending chest data"); + require(record(engine, 2, Identifier.class) != null, "Native writes invalidated an untouched custom record"); + replay(engine, world, 0); + CustomBlockOwnership ownership = record(engine, 10, CustomBlockOwnership.class); + require(record(engine, 2, Identifier.class) == null, "Custom materialization did not consume pending records"); + require(CraftEngineBlocks.isCustomBlock(world.getBlockAt(2, TEST_Y, TEST_Z)), "Intact custom block was not placed"); + require(world.getBlockAt(4, TEST_Y, TEST_Z).getType().isAir(), "Cut custom block was resurrected"); + require(world.getBlockAt(6, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE, "Custom block replaced native wall"); + require(world.getBlockAt(8, TEST_Y, TEST_Z).getType().isAir(), "Invalidated custom block was resurrected"); + require(chest.getType() == Material.COBBLESTONE, "Chest data replaced native wall"); + later(player, world, () -> { + int intactFurniture = furnitureAt(world, 10); + require(intactFurniture == 1, "Intact furniture count is " + intactFurniture + + "; pending=" + record(engine, 10, Identifier.class) + + "; ownership=" + ownership + + "; chunkEntities=" + world.getChunkAt(0, 0).getEntities().length); + require(furnitureAt(world, 12) == 0, "Flattened furniture was resurrected"); + player.sendMessage("CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1"); + }); + } + + private void neighbor(Player player, World world) { + Engine engine = IrisToolbelt.access(world).getEngine(); + queue(engine, world, 18, FURNITURE_ID); + queue(engine, world, 20, FURNITURE_ID); + queue(engine, world, 22, FURNITURE_ID); + queue(engine, world, 42, FURNITURE_ID); + replay(engine, world, 1); + replay(engine, world, 2); + world.getBlockAt(40, TEST_Y, TEST_Z).setType(Material.DIAMOND_BLOCK, false); + later(player, world, () -> { + require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1 + && furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize"); + require(furnitureAt(world, 42) == 1 && record(engine, 42, CustomBlockOwnership.class) != null, + "Regeneration target furniture did not materialize with saved ownership"); + if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 40.5, TEST_Y + 3, TEST_Z + 0.5)) + .whenComplete((teleported, failure) -> { + if (failure != null || !Boolean.TRUE.equals(teleported)) { + fail(player, failure == null ? new IllegalStateException("Regeneration target teleport failed") : failure); + return; + } + player.sendMessage("CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1"); + }))) { + throw new IllegalStateException("Could not schedule regeneration target teleport"); + } + }); + } + + private void invalidateLoaded(Player player, World world, Engine engine) { + require(world.getBlockAt(40, TEST_Y, TEST_Z).getType().isAir(), "Adjacent target chunk did not regenerate"); + require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1 + && furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize"); + NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world)); + changes.add(18, TEST_Y - 1, TEST_Z); + changes.add(18, TEST_Y, TEST_Z); + changes.add(22, TEST_Y, TEST_Z); + world.getBlockAt(18, TEST_Y - 1, TEST_Z).setType(Material.AIR, false); + world.getBlockAt(18, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false); + DeferredPlacementRecords.invalidate(engine, changes); + require(CraftEngineFurniture.place(new Location(world, 22.5, TEST_Y, TEST_Z + 0.5), + Key.of("default:flower_basket"), "ground", false) != null, + "Could not place replacement furniture before scheduled cleanup"); + later(player, world, () -> { + require(furnitureAt(world, 42) == 0, "Regeneration retained target chunk furniture"); + require(furnitureAt(world, 18) == 0, "Native write left materialized neighbor furniture"); + require(furnitureAt(world, 20) == 1, "Native write removed untouched neighbor furniture"); + require(furnitureAt(world, 22) == 1, "Native cleanup removed replacement furniture or retained the old one"); + require(world.getBlockAt(18, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE, + "Furniture cleanup replaced the native wall"); + player.sendMessage("CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1"); + }); + } + + private void restarted(Player player, World world) { + require(furnitureAt(world, 20) == 1, "Generated neighbor furniture did not survive restart"); + require(furnitureAt(world, 10) == 1, "Untouched furniture did not survive restart"); + Engine engine = IrisToolbelt.access(world).getEngine(); + NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world)); + changes.add(20, TEST_Y, TEST_Z); + DeferredPlacementRecords.invalidate(engine, changes); + later(player, world, () -> { + require(furnitureAt(world, 20) == 0, "Persisted furniture ownership was not invalidated after restart"); + require(furnitureAt(world, 10) == 1, "Restart cleanup removed untouched furniture"); + player.sendMessage("CE_RESTART removed=1 untouched=1"); + }); + } + + private void queue(Engine engine, World world, int x, String identifier) { + ExternalDataSVC service = IrisServices.get(ExternalDataSVC.class); + BlockData data = service.getBlockData(Identifier.fromString(identifier)).orElseThrow(); + require(data instanceof IrisCustomData, "Iris did not resolve real CraftEngine content: " + identifier); + IrisCustomData custom = (IrisCustomData) data; + world.getBlockAt(x, TEST_Y, TEST_Z).setBlockData(custom.getBase(), false); + engine.getMantle().getMantle().set(x, TEST_Y - engine.getMinHeight(), TEST_Z, custom.getCustom()); + } + + private T record(Engine engine, int x, Class type) { + return engine.getMantle().getMantle().get(x, TEST_Y - engine.getMinHeight(), TEST_Z, type); + } + + private void replay(Engine engine, World world, int chunkX) { + Mantle mantle = engine.getMantle().getMantle(); + for (int x = chunkX - 1; x <= chunkX + 1; x++) { + for (int z = -1; z <= 1; z++) { + require(world.isChunkLoaded(x, z), "Replay neighbor is not loaded: " + x + "," + z); + } + } + require(mantle.isLoaded(world.getChunkAt(chunkX, 0)), "Replay mantle chunk is not loaded: " + chunkX); + mantle.flag(chunkX, 0, MantleFlag.ETCHED, false); + mantle.flag(chunkX, 0, MantleFlag.TILE, false); + mantle.flag(chunkX, 0, MantleFlag.CUSTOM, false); + EngineBukkitOps.updateChunk(engine, world.getChunkAt(chunkX, 0), mantle); + } + + private int furnitureAt(World world, int x) { + int count = 0; + for (Entity entity : world.getChunkAt(x >> 4, TEST_Z >> 4).getEntities()) { + Location location = entity.getLocation(); + if (location.getBlockX() == x && location.getBlockY() == TEST_Y && location.getBlockZ() == TEST_Z + && CraftEngineFurniture.getLoadedFurnitureByMetaEntity(entity) != null) { + count++; + } + } + return count; + } + + private void later(Player player, World world, Runnable assertion) { + Bukkit.getRegionScheduler().runDelayed(this, new Location(world, 8, TEST_Y, TEST_Z), task -> { + try { + assertion.run(); + } catch (Throwable failure) { + fail(player, failure); + } + }, 20); + } + + private NativeBlockChangeSet.Bounds bounds(World world) { + return new NativeBlockChangeSet.Bounds(-1, -1, 3, 1, world.getMinHeight(), world.getMaxHeight() - world.getMinHeight()); + } + + private void require(boolean condition, String message) { + if (!condition) { + throw new IllegalStateException(message); + } + } + + private void fail(CommandSender sender, Throwable failure) { + getLogger().log(Level.SEVERE, "CraftEngine native placement assertion failed", failure); + sender.sendMessage("CE_FAILED " + failure.getMessage()); + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/biomes/flat.json b/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/biomes/flat.json new file mode 100644 index 000000000..ba493aa72 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/biomes/flat.json @@ -0,0 +1,32 @@ +{ + "name": "Flat", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [ + { + "block": "minecraft:lime_wool" + } + ] + }, + { + "minHeight": 384, + "maxHeight": 384, + "palette": [ + { + "block": "minecraft:green_concrete" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 96, + "max": 96 + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/dimensions/ce-native-fixture.json b/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/dimensions/ce-native-fixture.json new file mode 100644 index 000000000..00a03f160 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/dimensions/ce-native-fixture.json @@ -0,0 +1,33 @@ +{ + "name": "CraftEngine Native Fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "flat" + ], + "focusRegion": "flat", + "focus": "flat", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { + "min": -64, + "max": 320 + }, + "logicalHeight": 384, + "fluidHeight": 0, + "bedrock": false, + "rockPalette": { + "palette": [ + { + "block": "minecraft:red_concrete" + } + ] + }, + "importedStructures": { + "disabled": [ + "minecraft:" + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/generators/flat.json b/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/generators/flat.json new file mode 100644 index 000000000..7cd98b811 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/generators/flat.json @@ -0,0 +1,15 @@ +{ + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "seed": 310, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/regions/flat.json b/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/regions/flat.json new file mode 100644 index 000000000..d6fff925f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-pack/regions/flat.json @@ -0,0 +1,12 @@ +{ + "name": "Flat", + "landBiomes": [ + "flat" + ], + "seaBiomes": [ + "flat" + ], + "shoreBiomes": [ + "flat" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native-restart.mjs b/adapters/bukkit/plugin/src/test/gameplay/ce-native-restart.mjs new file mode 100644 index 000000000..a0962fef0 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native-restart.mjs @@ -0,0 +1,15 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +export default { + name: 'iris-craftengine-native-restart', + description: 'Verify persisted generated furniture ownership after a full server restart.', + async run(context) { + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance') + await context.step('native writes remove persisted ownership after restart', async () => { + const response = await context.command('/iriscenative restart', /CE_RESTART|CE_FAILED/, 60000) + context.expect(response.includes('CE_RESTART removed=1 untouched=1'), response) + }) + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/ce-native.mjs b/adapters/bukkit/plugin/src/test/gameplay/ce-native.mjs new file mode 100644 index 000000000..33e2bd6fd --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/ce-native.mjs @@ -0,0 +1,49 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +export default { + name: 'iris-craftengine-native', + description: 'Verify deferred native invalidation and loaded neighbor cleanup with real Iris and CraftEngine.', + async run(context) { + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance') + await context.step('create and enter a real Iris world with CraftEngine content', async () => { + const response = await context.command('/iriscenative prepare', /CE_READY|CE_FAILED/, 180000) + context.expect(response.includes('CE_READY'), response) + }) + await context.step('native writes remove pending block, chest, and flatten furniture records', async () => { + const response = await context.command('/iriscenative pending', /CE_PENDING|CE_FAILED/, 60000) + context.expect(response.includes('CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1'), response) + await context.waitUntil(() => { + const origin = context.bot.entity.position.floored() + const cut = context.bot.blockAt(origin.set(4, 180, 8)) + const wall = context.bot.blockAt(origin.set(6, 180, 8)) + const invalidated = context.bot.blockAt(origin.set(8, 180, 8)) + const chest = context.bot.blockAt(origin.set(14, 180, 8)) + return cut?.name === 'air' && wall?.name === 'cobblestone' + && invalidated?.name === 'air' && chest?.name === 'cobblestone' + }, { label: 'Client observes retained native air and walls', timeoutMs: 30000, intervalMs: 100 }) + }) + await context.step('regeneration removes target furniture and preserves neighbor ownership', async () => { + const ready = await context.command('/iriscenative neighbor', /CE_NEIGHBOR_READY|CE_FAILED/, 30000) + context.expect(ready.includes('CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1'), ready) + const regenerated = await context.command('/iris developer regen radius=0', /Regen complete:|Regen failed:/, 90000) + context.expect(regenerated.includes('Regen complete:'), regenerated) + const response = await context.command('/iriscenative loaded', /CE_LOADED|CE_FAILED/, 30000) + context.expect(response.includes('CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1'), response) + }) + await context.step('imported snow features replace generated furniture records', async () => { + const response = await context.command('/iriscenative features', /CE_FEATURES|CE_FAILED/, 180000) + context.expect(response.includes('CE_FEATURES snow=256 pending=0 furniture=0'), response) + await context.waitUntil(() => { + const position = context.bot.entity.position.floored() + for (let x = 0; x < 16; x++) { + for (let z = 0; z < 16; z++) { + if (context.bot.blockAt(position.set(x, 97, z))?.name !== 'snow') return false + } + } + return true + }, { label: 'Client observes 256 imported snow layers', timeoutMs: 30000, intervalMs: 100 }) + }) + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index 23fdd8bc4..c91340570 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -57,6 +57,8 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenera import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext; import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage; import art.arcane.iris.world.safeguard.GenerationRefusalNotice; @@ -829,12 +831,12 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat importedFeatures.invalidate(); } - public void updateRegeneratedChunk(int chunkX, int chunkZ) { + public void updateRegeneratedChunk(Mantle mantle, int chunkX, int chunkZ) { ModdedChunkUpdateService service = ModdedEngineBootstrap.services().service(ModdedChunkUpdateService.class); if (service == null) { throw new IllegalStateException("Iris chunk update service is unavailable during regeneration"); } - service.updateRegeneratedChunk(engine(), boundLevel(), chunkX, chunkZ); + service.updateRegeneratedChunk(engine(), boundLevel(), mantle, chunkX, chunkZ); } public NativeBiomeResolver regenBiomeResolver() { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java index bc36a04d8..1bd14e1fe 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java @@ -19,6 +19,8 @@ package art.arcane.iris.modded.api; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.iris.integration.Identifier; import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement; @@ -297,11 +299,11 @@ public final class ModdedCustomContentRegistry { * consulted for that position. An unparseable key or no matching provider is logged and skipped. Called on the * server thread with the chunk loaded. */ - public static void processBlockPlacement(Engine engine, String key, Supplier placement) { + public static CustomBlockOwnership.Entry processBlockPlacement(Engine engine, String key, Supplier placement) { String base = parseIdentifier(key); if (base == null) { ModdedIrisLog.warn("Iris deferred custom block placement rejected invalid id {}", key); - return; + return null; } Map state = parseState(key); for (ModdedDataProvider provider : PROVIDERS) { @@ -309,14 +311,33 @@ public final class ModdedCustomContentRegistry { continue; } try { - provider.processBlockPlacement(new ModdedBlockPlacementContext( + String token = provider.processBlockPlacement(new ModdedBlockPlacementContext( engine, placement.get(), base, state)); + int properties = key.indexOf('['); + String qualifiedKey = base.replace(':', '/') + (properties < 0 ? "" : key.substring(properties)); + return token == null ? null : new CustomBlockOwnership.Entry( + new Identifier(provider.modId(), qualifiedKey), token, false); } catch (Throwable error) { ModdedIrisLog.error("Iris custom content provider '{}' failed post-placement for {}", provider.modId(), key, error); } - return; + return null; } ModdedIrisLog.warn("Iris deferred custom block placement has no provider for {}", key); + return null; + } + + public static boolean removeGenerated(Engine engine, CustomBlockOwnership.Entry entry, + Supplier placement) { + for (ModdedDataProvider provider : PROVIDERS) { + if (provider.modId().equals(entry.identifier().namespace()) && provider.isReady()) { + String storedKey = entry.identifier().key(); + int separator = storedKey.indexOf('/'); + String key = storedKey.substring(0, separator) + ":" + storedKey.substring(separator + 1); + return provider.removeGenerated(new ModdedBlockPlacementContext( + engine, placement.get(), parseIdentifier(key), parseState(key)), entry.token()); + } + } + return false; } /** diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java index 38b996c77..9455eab1a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java @@ -95,7 +95,12 @@ public interface ModdedDataProvider { * Called on the server thread, once per deferred position, by the first provider that claims the identifier - * later providers are not consulted for that position. Only fires for states returned as deferred. */ - default void processBlockPlacement(ModdedBlockPlacementContext context) { + default String processBlockPlacement(ModdedBlockPlacementContext context) { + return null; + } + + default boolean removeGenerated(ModdedBlockPlacementContext context, String token) { + return false; } /** diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java index b28a0b777..022d2d884 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java @@ -22,6 +22,7 @@ import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.world.WorldMaintenance; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.mantle.EngineMantle; +import art.arcane.iris.generation.mantle.DeferredPlacementRecords; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedBlockBuffer; import art.arcane.iris.spi.IrisPlatforms; @@ -36,6 +37,9 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeChunkRegeneration; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -110,10 +114,12 @@ public final class ModdedRegen { private void resetMantleMargin() { EngineMantle engineMantle = engine.getMantle(); + Mantle mantle = engineMantle.getMantle(); int margin = radius + Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1; for (int dx = -margin; dx <= margin; dx++) { for (int dz = -margin; dz <= margin; dz++) { - engineMantle.getMantle().deleteChunk(centerX + dx, centerZ + dz); + DeferredPlacementRecords.resetMantleChunk(mantle, centerX + dx, centerZ + dz, + Math.abs(dx) <= radius && Math.abs(dz) <= radius); } } } @@ -199,10 +205,21 @@ public final class ModdedRegen { } private void apply(int chunkX, int chunkZ, ModdedBlockBuffer blocks, Hunk biomes) { + Mantle mantle = engine.getMantle().getMantle(); regeneration.apply(new NativeChunkRegeneration.Replacement(chunkX, chunkZ, engine.getMinHeight(), engine.getMaxHeight() - engine.getMinHeight(), blocks::rawOrNull, generator.regenBiomeResolver()), - () -> generator.updateRegeneratedChunk(chunkX, chunkZ)); + () -> completeReplacement(mantle, chunkX, chunkZ)); + } + + private void completeReplacement(Mantle mantle, int chunkX, int chunkZ) { + MantleChunk records = mantle.useChunk(chunkX, chunkZ); + try { + DeferredPlacementRecords.invalidateCustomBlockOwnership(records); + } finally { + records.release(); + } + generator.updateRegeneratedChunk(mantle, chunkX, chunkZ); } private void ok(String message) { 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 0d8e8190d..e4058079f 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 @@ -28,6 +28,8 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockProperties; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.studio.view.PregeneratorJob; import art.arcane.iris.integration.Identifier; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.iris.generation.mantle.DeferredPlacementRecords; import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.block.TileData; @@ -166,8 +168,8 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { updatePassChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32))); } - public void updateRegeneratedChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) { - updateChunk(engine, level, chunkX, chunkZ, false); + public void updateRegeneratedChunk(Engine engine, NativeWorld level, Mantle mantle, int chunkX, int chunkZ) { + updateChunk(engine, level, mantle, chunkX, chunkZ, false); } /** @@ -176,7 +178,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { */ private void updatePassChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) { try { - updateChunk(engine, level, chunkX, chunkZ, true); + updateChunk(engine, level, engine.getMantle().getMantle(), chunkX, chunkZ, true); } catch (Throwable failure) { if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) { throw failure; @@ -188,7 +190,8 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } } - private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ, boolean requireNeighbors) { + private void updateChunk(Engine engine, NativeWorld level, Mantle mantle, + int chunkX, int chunkZ, boolean requireNeighbors) { if (requireNeighbors) { for (int x = -1; x <= 1; x++) { for (int z = -1; z <= 1; z++) { @@ -199,11 +202,11 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } } - Mantle mantle = engine.getMantle().getMantle(); if (!mantle.isChunkLoaded(chunkX, chunkZ)) { warmupMantleChunk(mantle, chunkX, chunkZ); return; } + DeferredPlacementRecords.removeInvalidated(engine, mantle, chunkX, chunkZ); if (mantle.hasFlag(chunkX, chunkZ, MantleFlag.ETCHED)) { return; } @@ -229,14 +232,19 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { int baseX = chunkX << 4; int baseZ = chunkZ << 4; int minHeight = engine.getWorld().minHeight(); - materializeDeferredSlice(chunk, TileWrapper.class, () -> - chunk.iterate(TileWrapper.class, (Integer x, Integer yf, Integer z, TileWrapper v) -> { - int y = yf + minHeight; - if (y < level.minHeight() || y >= level.maxHeight() - 1) { - return; - } - applyTile(level, baseX + (x & 15), y, baseZ + (z & 15), v.getData()); - })); + materializeDeferredSlice(chunk, TileWrapper.class, () -> { + for (DeferredPlacementRecords.Pending record : DeferredPlacementRecords.snapshot(chunk, TileWrapper.class)) { + if (!DeferredPlacementRecords.isPending(chunk, TileWrapper.class, record)) { + continue; + } + NativeBlockPoint position = record.position(); + int y = position.y() + minHeight; + if (y < level.minHeight() || y >= level.maxHeight() - 1) { + continue; + } + applyTile(level, baseX + (position.x() & 15), y, baseZ + (position.z() & 15), record.value().getData()); + } + }); } private void runCustomPass(Engine engine, NativeWorld level, int chunkX, int chunkZ, MantleChunk chunk) { @@ -244,22 +252,30 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { 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; - } - 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)); - })); + materializeDeferredSlice(chunk, Identifier.class, () -> { + for (DeferredPlacementRecords.Pending record : DeferredPlacementRecords.snapshot(chunk, Identifier.class)) { + if (!DeferredPlacementRecords.isPending(chunk, Identifier.class, record)) { + continue; + } + NativeBlockPoint local = record.position(); + int y = local.y() + minHeight; + if (y < level.minHeight() || y >= level.maxHeight() - 1) { + continue; + } + int blockX = baseX + (local.x() & 15); + int blockZ = baseZ + (local.z() & 15); + String key = record.value().toString(); + if (stale.carrierReplaced(key, level.getBlock(blockX, y, blockZ))) { + continue; + } + NativeBlockPoint position = new NativeBlockPoint(blockX, y, blockZ); + CustomBlockOwnership.Entry ownership = ModdedCustomContentRegistry.processBlockPlacement(engine, key, + () -> NativeWorldMaintenance.placement(level, position)); + if (ownership != null) { + DeferredPlacementRecords.remember(engine, chunk, local, ownership); + } + } + }); stale.report(level.name(), chunkX, chunkZ); } @@ -411,7 +427,8 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { carrier = carrierKey(key); carriers.put(key, carrier); } - if (carrier.equals(UNCHECKED) || carrier.equals(IrisProceduralBlocks.materialKey(live))) { + if (carrier.equals(UNCHECKED) || carrier.equals(IrisProceduralBlocks.materialKey(live)) + || ((carrier.equals("minecraft:fire") || carrier.equals("minecraft:soul_fire")) && live.isAir())) { return false; } skipped++; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java index 8c041c9a2..1ef12ab8d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java @@ -28,6 +28,10 @@ import art.arcane.iris.modded.ModdedSavedTerrainCapture; import java.util.concurrent.CompletableFuture; import art.arcane.iris.generation.runtime.EnginePlatformHooks; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.iris.modded.api.ModdedCustomContentRegistry; +import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldMaintenance; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimensionRuntimeContract; @@ -72,6 +76,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, private volatile ExecutorService executor; private long lastPollAt; + @Override + public boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) { + return ModdedCustomContentRegistry.removeGenerated(engine, entry, + () -> NativeWorldMaintenance.placement(engine.getWorld().platformWorld(), position)); + } + @Override public CompletableFuture flushSavedTerrainCapture(Engine engine) { return ModdedSavedTerrainCapture.flush(engine); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java index ef89b580d..f6077baaf 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java @@ -231,7 +231,7 @@ public class ModdedNativeStructureWorldgenAccessTest { recording.world(), generationCenter, (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - true, + true, true, position -> false, NativeBlockChangeSet.disabled()); int x = generationCenter.getMiddleBlockX(); @@ -250,7 +250,7 @@ public class ModdedNativeStructureWorldgenAccessTest { recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - false, + false, true, position::equals, NativeBlockChangeSet.disabled()); @@ -284,7 +284,7 @@ public class ModdedNativeStructureWorldgenAccessTest { delegate, generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - false, + false, true, protectedCell::equals, changes); BlockPos written = protectedCell.above(); @@ -353,7 +353,7 @@ public class ModdedNativeStructureWorldgenAccessTest { recording.world(), generationCenter(), (x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y, - false, + false, true, position -> false, NativeBlockChangeSet.disabled()); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/api/ModdedCustomContentRegistryTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/api/ModdedCustomContentRegistryTest.java index 4afc75ccb..0f656e800 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/api/ModdedCustomContentRegistryTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/api/ModdedCustomContentRegistryTest.java @@ -1,5 +1,9 @@ package art.arcane.iris.modded.api; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.integration.Identifier; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity; import net.minecraft.SharedConstants; @@ -9,6 +13,7 @@ import org.junit.Test; import java.util.Collection; import java.util.List; +import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -20,6 +25,33 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class ModdedCustomContentRegistryTest { + @Test + public void removalReturnsToOriginalProviderWithTokenAndPlacementState() { + ModdedDataProvider provider = mock(ModdedDataProvider.class); + Engine engine = mock(Engine.class); + NativeBlockPlacement placement = mock(NativeBlockPlacement.class); + ModdedBlockPlacementContext context = new ModdedBlockPlacementContext( + engine, placement, "provider_test:furniture/chair", Map.of("variant", "oak")); + when(provider.modId()).thenReturn("ownership_test"); + when(provider.isReady()).thenReturn(true); + when(provider.isValidProvider("provider_test:furniture/chair", ModdedDataType.BLOCK)).thenReturn(true); + when(provider.processBlockPlacement(context)).thenReturn("entity-token"); + when(provider.removeGenerated(context, "entity-token")).thenReturn(true); + ModdedCustomContentRegistry.Discovery discovery = ModdedCustomContentRegistry.discover(List.of(provider)); + try { + CustomBlockOwnership.Entry ownership = ModdedCustomContentRegistry.processBlockPlacement(engine, + "provider_test:furniture/chair[variant=oak]", () -> placement); + + assertEquals(new CustomBlockOwnership.Entry( + Identifier.fromString("ownership_test:provider_test/furniture/chair[variant=oak]"), + "entity-token", false), ownership); + assertTrue(ModdedCustomContentRegistry.removeGenerated(engine, ownership, () -> placement)); + verify(provider).removeGenerated(context, "entity-token"); + } finally { + discovery.rollback(); + } + } + @BeforeClass public static void bootstrapRegistries() { SharedConstants.tryDetectVersion(); 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 index b3b45d01e..b9e96e2f8 100644 --- 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 @@ -53,6 +53,8 @@ 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 String FIRE = MOD_ID + ":fire"; + private static final String SOUL_FIRE = MOD_ID + ":soul_fire"; private static final List PLACED = new CopyOnWriteArrayList<>(); @BeforeClass @@ -80,6 +82,7 @@ public class ModdedChunkUpdateServiceCarrierTest { 3, Blocks.COBBLESTONE.defaultBlockState(), 4, Blocks.COBBLESTONE.defaultBlockState())); List debug = new ArrayList<>(); + Engine engine = engine(chunk); try (MockedStatic logging = mockStatic(IrisLogging.class)) { logging.when(() -> IrisLogging.debug(anyString(), any(Object[].class))).thenAnswer((InvocationOnMock invocation) -> { @@ -88,7 +91,7 @@ public class ModdedChunkUpdateServiceCarrierTest { return null; }); - new ModdedChunkUpdateService().updateRegeneratedChunk(engine(chunk), level, 0, 0); + new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0); } assertEquals(List.of(new BlockPos(1, 6, 1), new BlockPos(4, 6, 4)), PLACED); @@ -98,6 +101,24 @@ public class ModdedChunkUpdateServiceCarrierTest { assertTrue(debug.getFirst(), debug.getFirst().contains("2 stale")); } + @Test + public void extinguishedFireCarriersArePlacedWhileCoveredCarriersAreSkipped() { + RecordMantleChunk chunk = new RecordMantleChunk(List.of( + new Placed(1, FIRE), new Placed(2, SOUL_FIRE), + new Placed(3, FIRE), new Placed(4, SOUL_FIRE))); + NativeWorld level = level(Map.of( + 1, Blocks.AIR.defaultBlockState(), + 2, Blocks.CAVE_AIR.defaultBlockState(), + 3, Blocks.COBBLESTONE.defaultBlockState(), + 4, Blocks.COBBLESTONE.defaultBlockState())); + Engine engine = engine(chunk); + + new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0); + + assertEquals(List.of(new BlockPos(1, 6, 1), new BlockPos(2, 6, 2)), PLACED); + assertTrue(chunk.customDeleted()); + } + private static NativeWorld level(Map live) { ServerLevel serverLevel = mock(ServerLevel.class); when(serverLevel.getBlockState(any(BlockPos.class))).thenReturn(Blocks.STONE.defaultBlockState()); @@ -163,6 +184,18 @@ public class ModdedChunkUpdateServiceCarrierTest { } } + @Override + public T get(int x, int y, int z, Class type) { + if (type == Identifier.class && y == 70 && x == z) { + for (Placed record : records) { + if (record.offset() == x) { + return type.cast(Identifier.fromString(record.key())); + } + } + } + return null; + } + @Override public void deleteSlices(Class type) { if (type == Identifier.class) { @@ -183,23 +216,29 @@ public class ModdedChunkUpdateServiceCarrierTest { @Override public Collection getTypes(ModdedDataType type) { - return type == ModdedDataType.BLOCK ? List.of(LAMP, CHAIR) : List.of(); + return type == ModdedDataType.BLOCK ? List.of(LAMP, CHAIR, FIRE, SOUL_FIRE) : List.of(); } @Override public boolean isValidProvider(String id, ModdedDataType type) { - return type == ModdedDataType.BLOCK && (LAMP.equals(id) || CHAIR.equals(id)); + return type == ModdedDataType.BLOCK && getTypes(type).contains(id); } @Override public ModdedBlockData getBlockData(String blockId, Map state) { - BlockState carrier = CHAIR.equals(blockId) ? Blocks.AIR.defaultBlockState() : Blocks.NOTE_BLOCK.defaultBlockState(); + BlockState carrier = switch (blockId) { + case CHAIR -> Blocks.AIR.defaultBlockState(); + case FIRE -> Blocks.FIRE.defaultBlockState(); + case SOUL_FIRE -> Blocks.SOUL_FIRE.defaultBlockState(); + default -> Blocks.NOTE_BLOCK.defaultBlockState(); + }; return ModdedBlockData.deferred(ModdedBlockState.of(carrier, null)); } @Override - public void processBlockPlacement(ModdedBlockPlacementContext context) { + public String processBlockPlacement(ModdedBlockPlacementContext context) { PLACED.add(context.placement().position()); + return null; } } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java index 3993e2a6a..d002cbab4 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java @@ -68,6 +68,29 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class ModdedChunkUpdateServiceTest { + @Test + @SuppressWarnings("unchecked") + public void regeneratedCleanupUsesSuppliedMantleAfterCurrentRuntimeChanges() { + Mantle selected = mock(Mantle.class); + Mantle replacement = mock(Mantle.class); + MantleDataAdapter adapter = mock(MantleDataAdapter.class); + MantleChunk chunk = new MantleChunk<>(1, 0, 0, adapter, MantleHooks.NONE); + when(selected.isChunkLoaded(0, 0)).thenReturn(true); + when(selected.useChunk(0, 0)).thenAnswer(invocation -> chunk.use()); + when(selected.hasFlag(0, 0, MantleFlag.ETCHED)).thenReturn(true); + NativeWorld level = mock(NativeWorld.class); + when(level.isChunkLoaded(0, 0)).thenReturn(true); + Engine engine = engineWith(replacement); + when(engine.getWorld().platformWorld()).thenReturn(level); + + new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, selected, 0, 0); + + verify(selected).useChunk(0, 0); + verify(replacement, never()).isChunkLoaded(anyInt(), anyInt()); + verify(replacement, never()).useChunk(anyInt(), anyInt()); + assertFalse(chunk.inUse()); + } + @Test public void fallingFluidUpdateRetainsLevelEightStateAndRequestsPhysics() { SharedConstants.tryDetectVersion(); @@ -154,7 +177,7 @@ public class ModdedChunkUpdateServiceTest { return null; }); - service.updateRegeneratedChunk(engine, level, 0, 0); + service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0); assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); @@ -162,7 +185,7 @@ public class ModdedChunkUpdateServiceTest { logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never()); logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never()); - service.updateRegeneratedChunk(engine, level, 0, 0); + service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0); assertTrue(chunk.isFlagged(MantleFlag.ETCHED)); assertTrue(chunk.deleted()); @@ -187,7 +210,7 @@ public class ModdedChunkUpdateServiceTest { }); assertThrows(SavedBiomeUnavailableException.class, - () -> new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, 0, 0)); + () -> new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0)); assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); @@ -215,13 +238,13 @@ public class ModdedChunkUpdateServiceTest { }); assertSame(failure, assertThrows(IllegalStateException.class, - () -> service.updateRegeneratedChunk(engine, level, 0, 0))); + () -> service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0))); assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); assertFalse(chunk.deleted()); verify(chunk.slice, never()).set(2, 5, 2, null); - service.updateRegeneratedChunk(engine, level, 0, 0); + service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0); assertTrue(chunk.isFlagged(MantleFlag.ETCHED)); assertTrue(chunk.deleted()); 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 ebbc12b13..e0b2e17a7 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/api/ModdedCustomContentRegistryTest.java": [ + { + "pattern": "\\bResourceLocation\\b", + "replacement": "Identifier" + } + ], "art/arcane/iris/modded/service/ModdedChunkUpdateServiceCarrierTest.java": [ { "pattern": "\\bResourceLocation\\b", diff --git a/core/src/externalProviderTest/java/art/arcane/iris/integration/data/CraftEngineDataProviderTest.java b/core/src/externalProviderTest/java/art/arcane/iris/integration/data/CraftEngineDataProviderTest.java index 325c095c0..e5bd73717 100644 --- a/core/src/externalProviderTest/java/art/arcane/iris/integration/data/CraftEngineDataProviderTest.java +++ b/core/src/externalProviderTest/java/art/arcane/iris/integration/data/CraftEngineDataProviderTest.java @@ -15,6 +15,7 @@ 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.entity.furniture.BukkitFurniture; import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine; import net.momirealms.craftengine.core.block.BlockDefinition; import net.momirealms.craftengine.core.block.ImmutableBlockState; @@ -24,10 +25,12 @@ 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.Chunk; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Entity; import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; import org.junit.Test; @@ -37,6 +40,7 @@ import java.util.List; import java.util.Map; import java.util.MissingResourceException; import java.util.Set; +import java.util.UUID; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -48,12 +52,59 @@ 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.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 CraftEngineDataProviderTest { + @Test + public void furniturePlacementReturnsExactEntityTokenAndCleanupKeepsReplacement() { + FurnitureDefinition definition = furnitureDefinition(); + Engine engine = mock(Engine.class); + SeedManager seeds = mock(SeedManager.class); + when(engine.getSeedManager()).thenReturn(seeds); + Block block = mock(Block.class); + when(block.getLocation()).thenAnswer(invocation -> new Location(null, 0, 70, 0)); + Chunk chunk = mock(Chunk.class); + when(block.getChunk()).thenReturn(chunk); + when(chunk.isEntitiesLoaded()).thenReturn(true); + Entity original = mock(Entity.class); + Entity replacement = mock(Entity.class); + UUID originalId = UUID.randomUUID(); + when(original.getUniqueId()).thenReturn(originalId); + when(replacement.getUniqueId()).thenReturn(UUID.randomUUID()); + when(chunk.getEntities()).thenReturn(new Entity[]{replacement, original}); + BukkitFurniture placed = mock(BukkitFurniture.class); + when(placed.baseEntity()).thenReturn(original); + CraftEngineDataProvider provider = new CraftEngineDataProvider(); + try (MockedStatic blocks = mockStatic(CraftEngineBlocks.class); + MockedStatic furniture = mockStatic(CraftEngineFurniture.class)) { + furniture.when(() -> CraftEngineFurniture.byId(Key.of("test:chair"))).thenReturn(definition); + furniture.when(() -> CraftEngineFurniture.place(any(Location.class), eq(definition), eq("alpha"), eq(false))) + .thenReturn(placed); + furniture.when(() -> CraftEngineFurniture.remove(original, false, false)).thenReturn(true); + + String token = provider.processUpdate(engine, block, Identifier.fromString("test:chair")); + + assertEquals(originalId.toString(), token); + assertTrue(provider.removeGenerated(block, token)); + furniture.verify(() -> CraftEngineFurniture.remove(original, false, false)); + furniture.verify(() -> CraftEngineFurniture.remove(replacement, false, false), never()); + } + } + + @Test + public void furnitureCleanupWaitsForEntitiesToLoad() { + Block block = mock(Block.class); + Chunk chunk = mock(Chunk.class); + when(block.getChunk()).thenReturn(chunk); + + assertFalse(new CraftEngineDataProvider().removeGenerated(block, UUID.randomUUID().toString())); + verify(chunk, never()).getEntities(); + } + @Test public void startupWaitsForReloadEventAndEmptyLoadedRegistriesAreReady() { CraftEngineDataProvider provider = spy(new CraftEngineDataProvider()); 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 index 38a2b8d58..d30bf2503 100644 --- a/core/src/externalProviderTest/java/art/arcane/iris/platform/generation/CraftEngineStaleRecordTest.java +++ b/core/src/externalProviderTest/java/art/arcane/iris/platform/generation/CraftEngineStaleRecordTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.platform.generation; import art.arcane.iris.generation.block.IrisCustomData; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.SeedManager; import art.arcane.iris.integration.ExternalDataProvider; @@ -15,6 +16,7 @@ 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 art.arcane.volmlib.util.matter.MatterSlice; import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks; import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture; import net.momirealms.craftengine.bukkit.api.CraftEngineItems; @@ -33,6 +35,7 @@ import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Entity; import org.junit.Test; import org.mockito.MockedStatic; import org.mockito.invocation.InvocationOnMock; @@ -40,9 +43,11 @@ import org.mockito.invocation.InvocationOnMock; import java.lang.reflect.Field; import java.util.List; import java.util.Map; +import java.util.UUID; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; @@ -100,8 +105,12 @@ public class CraftEngineStaleRecordTest { 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); + BukkitFurniture placed = mock(BukkitFurniture.class); + Entity entity = mock(Entity.class); + when(placed.baseEntity()).thenReturn(entity); + when(entity.getUniqueId()).thenReturn(UUID.randomUUID()); furniture.when(() -> CraftEngineFurniture.place(any(Location.class), eq(chairDefinition), eq("alpha"), eq(false))) - .thenReturn(mock(BukkitFurniture.class)); + .thenReturn(placed); provider.onReload(mock(CraftEngineReloadEvent.class)); Identifier lampRecord = generatedRecord(service, "test:lamp"); Identifier chairRecord = generatedRecord(service, "test:chair"); @@ -157,6 +166,16 @@ public class CraftEngineStaleRecordTest { @SuppressWarnings("unchecked") private static MantleChunk records(Placed... records) { MantleChunk chunk = mock(MantleChunk.class); + Matter section = mock(Matter.class); + MatterSlice ownership = mock(MatterSlice.class); + MatterSlice deferred = mock(MatterSlice.class); + when(chunk.getOrCreate(anyInt())).thenReturn(section); + when(section.slice(CustomBlockOwnership.class)).thenReturn(ownership); + when(section.getSlice(Identifier.class)).thenReturn(deferred); + for (Placed record : records) { + when(chunk.get(record.offset(), 10, record.offset(), Identifier.class)).thenReturn(record.identifier()); + when(deferred.get(record.offset(), 10, record.offset())).thenReturn(record.identifier()); + } doAnswer((InvocationOnMock invocation) -> { Consumer4 iterator = invocation.getArgument(1); for (Placed record : records) { diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/CustomBlockOwnership.java b/core/src/main/java/art/arcane/iris/generation/mantle/CustomBlockOwnership.java new file mode 100644 index 000000000..d55612f81 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/mantle/CustomBlockOwnership.java @@ -0,0 +1,43 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.integration.Identifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public record CustomBlockOwnership(List entries) { + public CustomBlockOwnership { + entries = List.copyOf(entries); + } + + public CustomBlockOwnership add(Entry entry) { + if (entries.contains(entry)) { + return this; + } + List updated = new ArrayList<>(entries); + updated.add(entry); + return new CustomBlockOwnership(updated); + } + + public CustomBlockOwnership invalidate() { + List updated = new ArrayList<>(entries.size()); + for (Entry entry : entries) { + updated.add(new Entry(entry.identifier(), entry.token(), true)); + } + return new CustomBlockOwnership(updated); + } + + public CustomBlockOwnership remove(Entry entry) { + List updated = new ArrayList<>(entries); + updated.remove(entry); + return updated.isEmpty() ? null : new CustomBlockOwnership(updated); + } + + public record Entry(Identifier identifier, String token, boolean invalidated) { + public Entry { + Objects.requireNonNull(identifier); + Objects.requireNonNull(token); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/DeferredPlacementRecords.java b/core/src/main/java/art/arcane/iris/generation/mantle/DeferredPlacementRecords.java index 88184f5b5..7c533895b 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/DeferredPlacementRecords.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/DeferredPlacementRecords.java @@ -1,18 +1,91 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EnginePlatformHooks; import art.arcane.iris.integration.Identifier; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet; +import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.MantleClosedException; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterSlice; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + public final class DeferredPlacementRecords { + private static final Set RETAINED_MARGIN_FLAGS = Set.of( + MantleFlag.TILE, MantleFlag.CUSTOM, MantleFlag.UPDATE, MantleFlag.ETCHED, + MantleFlag.INITIAL_SPAWNED, MantleFlag.INITIAL_SPAWNED_MARKER, MantleFlag.DISCOVERED); + private DeferredPlacementRecords() { } + public static void resetMantleChunk(Mantle mantle, int chunkX, int chunkZ, boolean target) { + MantleChunk chunk = mantle.useChunk(chunkX, chunkZ); + try { + synchronized (chunk) { + for (int index = 0; index < chunk.sectionCount(); index++) { + Matter section = chunk.get(index); + if (section == null) { + continue; + } + section.getSliceTypes().removeIf(type -> type != CustomBlockOwnership.class); + if (section.isEmpty()) { + chunk.delete(index); + } + } + for (ReservedFlag flag : ReservedFlag.values()) { + if (target || !RETAINED_MARGIN_FLAGS.contains(flag)) { + chunk.flag(flag, false); + } + } + for (int ordinal = MantleFlag.MIN_ORDINAL; ordinal <= MantleFlag.MAX_ORDINAL; ordinal++) { + chunk.flag(MantleFlag.of(ordinal), false); + } + } + } finally { + chunk.release(); + } + } + + public static void invalidateCustomBlockOwnership(MantleChunk chunk) { + synchronized (chunk) { + List> records = + snapshot(chunk, CustomBlockOwnership.class); + for (Pending record : records) { + NativeBlockPoint position = record.position(); + chunk.get(position.y() >> 4).slice(CustomBlockOwnership.class) + .set(position.x() & 15, position.y() & 15, position.z() & 15, record.value().invalidate()); + } + } + } + + public static List> snapshot(MantleChunk chunk, Class type) { + List> records = new ArrayList<>(); + synchronized (chunk) { + chunk.iterate(type, (x, y, z, value) -> records.add(new Pending<>(new NativeBlockPoint(x, y, z), value))); + } + return records; + } + + public static boolean isPending(MantleChunk chunk, Class type, Pending record) { + NativeBlockPoint position = record.position(); + synchronized (chunk) { + return Objects.equals(record.value(), chunk.get(position.x(), position.y(), position.z(), type)); + } + } + public static NativeBlockChangeSet track(Engine engine, NativeBlockChangeSet.Bounds bounds) { Mantle mantle = engine.getMantle().getMantle(); for (int chunkX = bounds.minChunkX(); chunkX <= bounds.maxChunkX(); chunkX++) { @@ -31,7 +104,163 @@ public final class DeferredPlacementRecords { } Mantle mantle = engine.getMantle().getMantle(); int minHeight = engine.getMinHeight(); - changes.forEachChunk((chunkX, chunkZ) -> invalidateChunk(mantle, changes, chunkX, chunkZ, minHeight)); + changes.forEachChunk((chunkX, chunkZ) -> { + MantleChunk chunk = invalidateChunk(mantle, changes, chunkX, chunkZ, minHeight); + if (chunk != null) { + scheduleRemoval(engine, chunk, minHeight); + } + }); + } + + public static NativeBlockChangeSet positions(Engine engine, NativeBlockChangeSet.Bounds bounds) { + Mantle mantle = engine.getMantle().getMantle(); + NativeBlockChangeSet positions = null; + int minHeight = engine.getMinHeight(); + for (int chunkX = bounds.minChunkX(); chunkX <= bounds.maxChunkX(); chunkX++) { + for (int chunkZ = bounds.minChunkZ(); chunkZ <= bounds.maxChunkZ(); chunkZ++) { + MantleChunk chunk = mantle.useChunk(chunkX, chunkZ); + try { + synchronized (chunk) { + if (!hasRecords(chunk)) { + continue; + } + if (positions == null) { + positions = NativeBlockChangeSet.create(bounds); + } + collectPositions(chunk, positions, Identifier.class, minHeight); + collectPositions(chunk, positions, TileWrapper.class, minHeight); + collectPositions(chunk, positions, CustomBlockOwnership.class, minHeight); + } + } finally { + chunk.release(); + } + } + } + return positions == null || positions.isEmpty() ? NativeBlockChangeSet.disabled() : positions; + } + + public static void remember(Engine engine, MantleChunk chunk, NativeBlockPoint position, + CustomBlockOwnership.Entry entry) { + boolean invalidated; + synchronized (chunk) { + Matter section = chunk.getOrCreate(position.y() >> 4); + MatterSlice deferred = section.getSlice(Identifier.class); + invalidated = entry.invalidated() || deferred == null + || deferred.get(position.x() & 15, position.y() & 15, position.z() & 15) == null; + CustomBlockOwnership.Entry completed = invalidated + ? new CustomBlockOwnership.Entry(entry.identifier(), entry.token(), true) : entry; + MatterSlice slice = section.slice(CustomBlockOwnership.class); + CustomBlockOwnership existing = slice.get(position.x() & 15, position.y() & 15, position.z() & 15); + CustomBlockOwnership updated = existing == null + ? new CustomBlockOwnership(List.of(completed)) : existing.add(completed); + slice.set(position.x() & 15, position.y() & 15, position.z() & 15, updated); + } + if (invalidated) { + scheduleRemoval(engine, chunk, engine.getMinHeight()); + } + } + + public static void removeInvalidated(Engine engine, int chunkX, int chunkZ) { + removeInvalidated(engine, engine.getMantle().getMantle(), chunkX, chunkZ); + } + + public static void removeInvalidated(Engine engine, Mantle mantle, int chunkX, int chunkZ) { + NativeWorld world = engine.getWorld().platformWorld(); + if (world == null || engine.isClosed() || !world.isChunkLoaded(chunkX, chunkZ)) { + return; + } + if (mantle.isClosed()) { + return; + } + MantleChunk chunk = mantle.useChunk(chunkX, chunkZ); + try { + removeInvalidated(engine, chunk, engine.getMinHeight()); + } finally { + chunk.release(); + } + } + + private static void scheduleRemoval(Engine engine, MantleChunk chunk, int minHeight) { + NativeWorld world = engine.getWorld().platformWorld(); + if (world != null && IrisPlatforms.isBound()) { + IrisPlatforms.get().scheduler().region(world, chunk.getX(), chunk.getZ(), + () -> removeScheduled(new ScheduledRemoval(engine, world, chunk, minHeight))); + } + } + + private static void removeScheduled(ScheduledRemoval removal) { + MantleChunk chunk = removal.chunk(); + if (removal.engine().isClosed() || chunk.isClosed() + || !removal.world().isChunkLoaded(chunk.getX(), chunk.getZ())) { + return; + } + try { + chunk.use(); + } catch (MantleClosedException closed) { + IrisLogging.reportError("Custom content cleanup deferred after mantle chunk " + + chunk.getX() + "," + chunk.getZ() + " closed.", closed); + return; + } + try { + removeInvalidated(removal.engine(), chunk, removal.minHeight()); + } finally { + chunk.release(); + } + } + + private static void removeInvalidated(Engine engine, MantleChunk chunk, int minHeight) { + EnginePlatformHooks hooks = IrisServices.getOrNull(EnginePlatformHooks.class); + if (hooks == null) { + return; + } + int baseX = chunk.getX() << 4; + int baseZ = chunk.getZ() << 4; + List removals = new ArrayList<>(); + synchronized (chunk) { + chunk.iterate(CustomBlockOwnership.class, (x, y, z, ownership) -> { + for (CustomBlockOwnership.Entry entry : ownership.entries()) { + if (entry.invalidated()) { + removals.add(new Removal(new NativeBlockPoint(baseX + (x & 15), + y + minHeight, baseZ + (z & 15)), entry)); + } + } + }); + } + for (Removal removal : removals) { + try { + if (hooks.removeCustomBlock(engine, removal.position(), removal.entry())) { + forgetRemoval(chunk, removal, minHeight); + } + } catch (RuntimeException | LinkageError error) { + IrisLogging.reportError("Failed to remove generated custom content at " + removal.position() + ".", error); + } + } + } + + private static void collectPositions(MantleChunk chunk, NativeBlockChangeSet positions, + Class type, int minHeight) { + int baseX = chunk.getX() << 4; + int baseZ = chunk.getZ() << 4; + chunk.iterate(type, (x, y, z, value) -> positions.add(baseX + (x & 15), y + minHeight, baseZ + (z & 15))); + } + + private static void forgetRemoval(MantleChunk chunk, Removal removal, int minHeight) { + NativeBlockPoint position = removal.position(); + int y = position.y() - minHeight; + synchronized (chunk) { + Matter section = chunk.get(y >> 4); + if (section == null) { + return; + } + MatterSlice slice = section.getSlice(CustomBlockOwnership.class); + if (slice == null) { + return; + } + CustomBlockOwnership ownership = slice.get(position.x() & 15, y & 15, position.z() & 15); + if (ownership != null) { + slice.set(position.x() & 15, y & 15, position.z() & 15, ownership.remove(removal.entry())); + } + } } private static boolean hasRecords(Mantle mantle, int chunkX, int chunkZ) { @@ -48,38 +277,51 @@ public final class DeferredPlacementRecords { private static boolean hasRecords(MantleChunk chunk) { for (int section = 0; section < chunk.sectionCount(); section++) { Matter matter = chunk.get(section); - if (matter != null && (matter.hasSlice(Identifier.class) || matter.hasSlice(TileWrapper.class))) { + if (matter != null && (matter.hasSlice(Identifier.class) || matter.hasSlice(TileWrapper.class) + || matter.hasSlice(CustomBlockOwnership.class))) { return true; } } return false; } - private static void invalidateChunk(Mantle mantle, NativeBlockChangeSet changes, + private static MantleChunk invalidateChunk(Mantle mantle, NativeBlockChangeSet changes, int chunkX, int chunkZ, int minHeight) { MantleChunk chunk = mantle.useChunk(chunkX, chunkZ); try { synchronized (chunk) { if (!hasRecords(chunk)) { - return; + return null; } - changes.forEachPosition(chunkX, chunkZ, (x, y, z) -> clearRecords(chunk, x & 15, y - minHeight, z & 15)); + boolean[] cleanup = new boolean[1]; + changes.forEachPosition(chunkX, chunkZ, (x, y, z) -> + cleanup[0] |= clearRecords(chunk, x & 15, y - minHeight, z & 15)); + return cleanup[0] ? chunk : null; } } finally { chunk.release(); } } - private static void clearRecords(MantleChunk chunk, int x, int y, int z) { + private static boolean clearRecords(MantleChunk chunk, int x, int y, int z) { if (y < 0 || (y >> 4) >= chunk.sectionCount()) { - return; + return false; } Matter section = chunk.get(y >> 4); if (section == null) { - return; + return false; } clearRecord(section.getSlice(Identifier.class), x, y & 15, z); clearRecord(section.getSlice(TileWrapper.class), x, y & 15, z); + MatterSlice ownership = section.getSlice(CustomBlockOwnership.class); + if (ownership != null) { + CustomBlockOwnership current = ownership.get(x, y & 15, z); + if (current != null) { + ownership.set(x, y & 15, z, current.invalidate()); + return true; + } + } + return false; } private static void clearRecord(MatterSlice slice, int x, int y, int z) { @@ -87,4 +329,13 @@ public final class DeferredPlacementRecords { slice.set(x, y, z, null); } } + + private record Removal(NativeBlockPoint position, CustomBlockOwnership.Entry entry) { + } + + public record Pending(NativeBlockPoint position, T value) { + } + + private record ScheduledRemoval(Engine engine, NativeWorld world, MantleChunk chunk, int minHeight) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java b/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java index f8dabff8c..255c34bbe 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java @@ -21,6 +21,8 @@ package art.arcane.iris.generation.runtime; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint; import art.arcane.iris.world.history.SavedTerrainChunk; import java.util.concurrent.CompletableFuture; @@ -28,6 +30,10 @@ import art.arcane.iris.generation.mantle.MantleComponent; import art.arcane.volmlib.util.collection.KList; public interface EnginePlatformHooks { + default boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) { + return false; + } + default CompletableFuture flushSavedTerrainCapture(Engine engine) { return CompletableFuture.failedFuture(new UnsupportedOperationException( "This platform does not support saved terrain checkpoints.")); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java index c173fbcc0..7cb513f6f 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisWorldManager.java @@ -44,6 +44,7 @@ import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockDropItemEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.world.EntitiesLoadEvent; import java.util.concurrent.Future; import java.util.concurrent.CompletableFuture; @@ -249,6 +250,16 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } } + @EventHandler + public void onEntitiesLoad(EntitiesLoadEvent event) { + runManagerTask("bukkit_world_manager_entities_load", () -> { + if (!event.getEntities().isEmpty() + && event.getWorld().equals(BukkitWorldBinding.world(getEngine().getWorld()))) { + chunkMaintenance.onEntitiesLoaded(event.getChunk()); + } + }); + } + @Override public void onChunkUnload(Chunk e) { final var future = cleanup.remove(Cache.key(e.getX(), e.getZ())); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/WorldChunkMaintenance.java b/core/src/main/java/art/arcane/iris/generation/runtime/WorldChunkMaintenance.java index 688e45074..8f8969e97 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/WorldChunkMaintenance.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/WorldChunkMaintenance.java @@ -104,6 +104,16 @@ final class WorldChunkMaintenance { } } + void onEntitiesLoaded(Chunk chunk) { + int chunkX = chunk.getX(); + int chunkZ = chunk.getZ(); + World world = chunk.getWorld(); + invalidateMaterialization(chunkX, chunkZ); + J.runRegion(world, chunkX, chunkZ, + manager.managedTask("bukkit_world_manager_entity_content_cleanup", + () -> scheduleChunkUpdate(world, chunkX, chunkZ)), 1); + } + void discoverChunks() { World world = BukkitWorldBinding.world(manager.getEngine().getWorld()); if (world == null) { diff --git a/core/src/main/java/art/arcane/iris/integration/ExternalDataProvider.java b/core/src/main/java/art/arcane/iris/integration/ExternalDataProvider.java index b05dd4e35..2524cf0e7 100644 --- a/core/src/main/java/art/arcane/iris/integration/ExternalDataProvider.java +++ b/core/src/main/java/art/arcane/iris/integration/ExternalDataProvider.java @@ -120,7 +120,13 @@ public abstract class ExternalDataProvider { * @param block The block where the block should be placed * @param blockId The blockId to place */ - public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {} + public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { + return null; + } + + public boolean removeGenerated(@NotNull Block block, @NotNull String token) { + return false; + } /** * Spawns a mob in the specified location using the given engine and entity identifier. diff --git a/core/src/main/java/art/arcane/iris/integration/ExternalDataSVC.java b/core/src/main/java/art/arcane/iris/integration/ExternalDataSVC.java index 185fa395e..2fed55c79 100644 --- a/core/src/main/java/art/arcane/iris/integration/ExternalDataSVC.java +++ b/core/src/main/java/art/arcane/iris/integration/ExternalDataSVC.java @@ -19,6 +19,7 @@ package art.arcane.iris.integration; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.integration.data.DataType; import art.arcane.volmlib.nativelib.terrain.BlockProperty; @@ -247,13 +248,24 @@ public class ExternalDataSVC implements IrisService { } } - public void processUpdate(Engine engine, Block block, Identifier blockId) { + public CustomBlockOwnership.Entry processUpdate(Engine engine, Block block, Identifier blockId) { Pair> state = parseState(blockId); BlockProvider match = findBlockProvider(state.getA()); if (match == null) { throw new MissingResourceException("No matching provider found for external block placement.", blockId.namespace(), blockId.key()); } - match.provider().processUpdate(engine, block, buildState(match.identifier(), state.getB())); + String token = match.provider().processUpdate(engine, block, buildState(match.identifier(), state.getB())); + return token == null ? null : new CustomBlockOwnership.Entry( + qualifyBlockId(match.provider(), buildState(match.identifier(), state.getB())), token, false); + } + + public boolean removeGenerated(Block block, CustomBlockOwnership.Entry entry) { + for (ExternalDataProvider provider : activeProviders) { + if (provider.getPluginId().equalsIgnoreCase(entry.identifier().namespace()) && provider.isReady()) { + return provider.removeGenerated(block, entry.token()); + } + } + return false; } public boolean placeBlock(Block block, Identifier blockId) { diff --git a/core/src/main/java/art/arcane/iris/integration/data/CraftEngineDataProvider.java b/core/src/main/java/art/arcane/iris/integration/data/CraftEngineDataProvider.java index 41a656d61..0fd0ec657 100644 --- a/core/src/main/java/art/arcane/iris/integration/data/CraftEngineDataProvider.java +++ b/core/src/main/java/art/arcane/iris/integration/data/CraftEngineDataProvider.java @@ -18,6 +18,7 @@ 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.entity.furniture.BukkitFurniture; import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine; import net.momirealms.craftengine.core.block.BlockDefinition; import net.momirealms.craftengine.core.block.ImmutableBlockState; @@ -27,9 +28,11 @@ 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.Chunk; import org.bukkit.Location; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Entity; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.inventory.ItemStack; @@ -43,6 +46,7 @@ import java.util.Map; import java.util.MissingResourceException; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -140,21 +144,38 @@ public class CraftEngineDataProvider extends ExternalDataProvider implements Lis } @Override - public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { + public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { Pair> statePair = ExternalDataSVC.parseState(blockId); Identifier baseBlockId = statePair.getA(); KMap state = statePair.getB(); ImmutableBlockState blockState = findBlockState(baseBlockId); if (blockState != null) { placeBlockState(block, applyProperties(baseBlockId, blockState, state), blockId); - return; + return null; } FurnitureDefinition furniture = requireFurniture(baseBlockId); KMap properties = validateFurnitureProperties(baseBlockId, furniture, state); Location location = parseYawAndPitch(engine, block, properties); - if (CraftEngineFurniture.place(location, furniture, properties.get("variant"), false) == null) { + BukkitFurniture placed = CraftEngineFurniture.place(location, furniture, properties.get("variant"), false); + if (placed == null) { throw missing(blockId, "Failed to place CraftEngine furniture."); } + return placed.baseEntity().getUniqueId().toString(); + } + + @Override + public boolean removeGenerated(@NotNull Block block, @NotNull String token) { + Chunk chunk = block.getChunk(); + if (!chunk.isEntitiesLoaded()) { + return false; + } + UUID entityId = UUID.fromString(token); + for (Entity entity : chunk.getEntities()) { + if (entityId.equals(entity.getUniqueId())) { + return CraftEngineFurniture.remove(entity, false, false); + } + } + return true; } @Override diff --git a/core/src/main/java/art/arcane/iris/integration/data/ItemAdderDataProvider.java b/core/src/main/java/art/arcane/iris/integration/data/ItemAdderDataProvider.java index 528186fdb..f5903ee85 100644 --- a/core/src/main/java/art/arcane/iris/integration/data/ItemAdderDataProvider.java +++ b/core/src/main/java/art/arcane/iris/integration/data/ItemAdderDataProvider.java @@ -84,10 +84,11 @@ public class ItemAdderDataProvider extends ExternalDataProvider implements Liste } @Override - public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { + public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { if (CustomBlock.place(blockId.toString(), block.getLocation()) == null) { throw new MissingResourceException("Failed to place ItemsAdder block.", blockId.namespace(), blockId.key()); } + return null; } @Override diff --git a/core/src/main/java/art/arcane/iris/integration/data/KGeneratorsDataProvider.java b/core/src/main/java/art/arcane/iris/integration/data/KGeneratorsDataProvider.java index e0a9a5bf4..758d94b66 100644 --- a/core/src/main/java/art/arcane/iris/integration/data/KGeneratorsDataProvider.java +++ b/core/src/main/java/art/arcane/iris/integration/data/KGeneratorsDataProvider.java @@ -47,17 +47,18 @@ public class KGeneratorsDataProvider extends ExternalDataProvider { } @Override - public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { - if (block.getType() != Material.STRUCTURE_VOID) return; + public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { + if (block.getType() != Material.STRUCTURE_VOID) return null; IGeneratorLocation existing = KGeneratorsAPI.getLoadedGeneratorLocation(block.getLocation()); - if (existing != null) return; + if (existing != null) return null; block.setBlockData(BukkitBlockResolution.getAir(), false); Generator generator = Main.getGenerators().get(blockId.key()); - if (generator == null) return; + if (generator == null) return null; GeneratorLocation location = new GeneratorLocation(-1, generator, block.getLocation(), Main.getPlacedGenerators().getChunkInfo(block.getChunk()), null, null); Main.getDatabases().getDb().saveGenerator(location); Main.getPlacedGenerators().addLoaded(location); Main.getSchedules().schedule(location, true); + return null; } @Override diff --git a/core/src/main/java/art/arcane/iris/integration/data/MythicCrucibleDataProvider.java b/core/src/main/java/art/arcane/iris/integration/data/MythicCrucibleDataProvider.java index 5a085628c..db4cb4ccc 100644 --- a/core/src/main/java/art/arcane/iris/integration/data/MythicCrucibleDataProvider.java +++ b/core/src/main/java/art/arcane/iris/integration/data/MythicCrucibleDataProvider.java @@ -112,15 +112,15 @@ public class MythicCrucibleDataProvider extends ExternalDataProvider { } @Override - public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { + public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { Pair> parsedState = ExternalDataSVC.parseState(blockId); KMap state = parsedState.getB(); blockId = parsedState.getA(); Optional item = itemManager.getItem(blockId.key()); - if (item.isEmpty()) return; + if (item.isEmpty()) return null; FurnitureItemContext furniture = item.get().getFurnitureData(); - if (furniture == null) return; + if (furniture == null) return null; Pair pair = parseYawAndFace(engine, block, state); BiomeColor type = null; @@ -130,10 +130,11 @@ public class MythicCrucibleDataProvider extends ExternalDataProvider { } catch (NullPointerException | IllegalArgumentException ignored) {} if (type != null) { Color biomeColor = INMS.get().getBiomeColor(block.getLocation(), type); - if (biomeColor == null) return; + if (biomeColor == null) return null; color = Chroma.of(biomeColor.getRGB()); } furniture.place(block, pair.getB(), pair.getA(), color); + return null; } @Override diff --git a/core/src/main/java/art/arcane/iris/integration/data/NexoDataProvider.java b/core/src/main/java/art/arcane/iris/integration/data/NexoDataProvider.java index a57d99393..7bb2e1e53 100644 --- a/core/src/main/java/art/arcane/iris/integration/data/NexoDataProvider.java +++ b/core/src/main/java/art/arcane/iris/integration/data/NexoDataProvider.java @@ -89,24 +89,24 @@ public class NexoDataProvider extends ExternalDataProvider { } @Override - public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { + public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { Pair> statePair = ExternalDataSVC.parseState(blockId); KMap state = statePair.getB(); blockId = statePair.getA(); if (NexoBlocks.isCustomBlock(blockId.key())) { NexoBlocks.place(blockId.key(), block.getLocation()); - return; + return null; } if (!NexoFurniture.isFurniture(blockId.key())) - return; + return null; Pair pair = parseYawAndFace(engine, block, state); ItemDisplay display = NexoFurniture.place(blockId.key(), block.getLocation(), pair.getA(), pair.getB()); - if (display == null) return; + if (display == null) return null; ItemStack itemStack = display.getItemStack(); - if (itemStack == null) return; + if (itemStack == null) return null; BiomeColor type = null; try { @@ -115,7 +115,7 @@ public class NexoDataProvider extends ExternalDataProvider { if (type != null) { Color biomeColor = INMS.get().getBiomeColor(block.getLocation(), type); - if (biomeColor == null) return; + if (biomeColor == null) return null; ItemMeta meta = itemStack.getItemMeta(); switch (meta) { case LeatherArmorMeta armor -> armor.setColor(fromARGB(biomeColor.getAlpha(), biomeColor.getRed(), biomeColor.getGreen(), biomeColor.getBlue())); @@ -126,6 +126,7 @@ public class NexoDataProvider extends ExternalDataProvider { itemStack.setItemMeta(meta); } display.setItemStack(itemStack); + return null; } @Override diff --git a/core/src/main/java/art/arcane/iris/integration/data/OraxenDataProvider.java b/core/src/main/java/art/arcane/iris/integration/data/OraxenDataProvider.java index 1e625ba30..71ef360ba 100644 --- a/core/src/main/java/art/arcane/iris/integration/data/OraxenDataProvider.java +++ b/core/src/main/java/art/arcane/iris/integration/data/OraxenDataProvider.java @@ -61,8 +61,9 @@ public final class OraxenDataProvider extends ExternalDataProvider implements Li } @Override - public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { + public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { OraxenBlocks.place(blockId.key(), block.getLocation()); + return null; } @Override 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 59e49b07f..15f418697 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 @@ -23,6 +23,9 @@ import art.arcane.iris.world.history.GenerationFindCatalog; import art.arcane.iris.world.event.IrisLootEvent; import art.arcane.iris.integration.Identifier; import art.arcane.iris.integration.ExternalDataSVC; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.iris.generation.mantle.DeferredPlacementRecords; +import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.locator.Locator; @@ -137,13 +140,17 @@ public final class EngineBukkitOps { Runnable tileTask = () -> materializeTiles(engine, c, chunk); Runnable customTask = () -> materializeCustomBlocks(engine, c, chunk); Runnable updateTask = () -> materializeUpdates(engine, c, chunk); + Runnable materialize = () -> { + DeferredPlacementRecords.removeInvalidated(engine, mantle, c.getX(), c.getZ()); + runMaterializationPasses(chunk, tileTask, customTask, updateTask); + }; if (shouldRunChunkUpdateInline(c)) { - runMaterializationPasses(chunk, tileTask, customTask, updateTask); + materialize.run(); return; } - runScheduledMaterializationPasses(c, () -> runMaterializationPasses(chunk, tileTask, customTask, updateTask)); + runScheduledMaterializationPasses(c, materialize); } finally { chunk.release(); } @@ -151,14 +158,19 @@ 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; + for (DeferredPlacementRecords.Pending record : DeferredPlacementRecords.snapshot(mantleChunk, TileWrapper.class)) { + if (!DeferredPlacementRecords.isPending(mantleChunk, TileWrapper.class, record)) { + continue; } - Block block = chunk.getBlock(x & 15, worldY, z & 15); + NativeBlockPoint position = record.position(); + TileWrapper value = record.value(); + int worldY = position.y() + engine.getWorld().minHeight(); + if (worldY < chunk.getWorld().getMinHeight() || worldY >= chunk.getWorld().getMaxHeight()) { + continue; + } + Block block = chunk.getBlock(position.x() & 15, worldY, position.z() & 15); if (stale.tileReplaced(block, value.getData())) { - return; + continue; } if (!TileData.setTileState(block, value.getData())) { NamespacedKey blockTypeKey = KeyedType.getKey(block.getType()); @@ -166,25 +178,33 @@ public final class EngineBukkitOps { String tileType = value.getData().getMaterialKey(); IrisLogging.warn("Failed to set tile entity data at [%d %d %d | %s] for tile %s!", block.getX(), block.getY(), block.getZ(), blockType, tileType); } - }); + } 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(); + for (DeferredPlacementRecords.Pending record : DeferredPlacementRecords.snapshot(mantleChunk, Identifier.class)) { + if (!DeferredPlacementRecords.isPending(mantleChunk, Identifier.class, record)) { + continue; + } + NativeBlockPoint position = record.position(); + Identifier value = record.value(); + int worldY = position.y() + engine.getWorld().minHeight(); if (worldY < chunk.getWorld().getMinHeight() || worldY >= chunk.getWorld().getMaxHeight()) { - return; + continue; } ExternalDataSVC service = IrisServices.get(ExternalDataSVC.class); - Block block = chunk.getBlock(x & 15, worldY, z & 15); + Block block = chunk.getBlock(position.x() & 15, worldY, position.z() & 15); if (stale.carrierReplaced(service, block, value)) { - return; + continue; } - service.processUpdate(engine, block, value); - }); + CustomBlockOwnership.Entry ownership = service.processUpdate(engine, block, value); + if (ownership != null) { + DeferredPlacementRecords.remember(engine, mantleChunk, position, ownership); + } + } mantleChunk.deleteSlices(Identifier.class); stale.report("custom block", chunk); } @@ -877,7 +897,10 @@ public final class EngineBukkitOps { carrier = carrierType(service, record); carriers.put(record, carrier); } - if (carrier == Material.AIR || block.getType() == carrier) { + Material live = block.getType(); + if (carrier == Material.AIR || live == carrier + || ((carrier == Material.FIRE || carrier == Material.SOUL_FIRE) + && (live == Material.AIR || live == Material.CAVE_AIR || live == Material.VOID_AIR))) { return false; } skipped++; diff --git a/core/src/main/java/art/arcane/iris/structure/nativegen/ImportedFeaturePolicy.java b/core/src/main/java/art/arcane/iris/structure/nativegen/ImportedFeaturePolicy.java index ecc7341cd..3b81610d2 100644 --- a/core/src/main/java/art/arcane/iris/structure/nativegen/ImportedFeaturePolicy.java +++ b/core/src/main/java/art/arcane/iris/structure/nativegen/ImportedFeaturePolicy.java @@ -1,7 +1,9 @@ package art.arcane.iris.structure.nativegen; import art.arcane.volmlib.nativelib.terrain.NativeBlockPositionPredicate; +import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet; +import art.arcane.iris.generation.mantle.DeferredPlacementRecords; import art.arcane.iris.generation.runtime.DimensionStackContext; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.biome.IrisBiome; @@ -48,6 +50,16 @@ public class ImportedFeaturePolicy implements NativeImportedFeaturePolicy visibleBiomeKeys() { Set keys = new LinkedHashSet<>(); diff --git a/core/src/main/java/art/arcane/iris/world/runtime/InPlaceChunkRegenerator.java b/core/src/main/java/art/arcane/iris/world/runtime/InPlaceChunkRegenerator.java index 9c2cc156b..319536a6f 100644 --- a/core/src/main/java/art/arcane/iris/world/runtime/InPlaceChunkRegenerator.java +++ b/core/src/main/java/art/arcane/iris/world/runtime/InPlaceChunkRegenerator.java @@ -28,11 +28,14 @@ import art.arcane.iris.generation.chunk.TerrainChunkBiomeHunkView; import art.arcane.iris.platform.generation.EngineBukkitOps; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.mantle.EngineMantle; +import art.arcane.iris.generation.mantle.DeferredPlacementRecords; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.platform.bukkit.plugin.VolmitSender; import art.arcane.iris.world.task.J; import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; import org.bukkit.Chunk; import org.bukkit.ChunkSnapshot; import org.bukkit.Material; @@ -96,11 +99,12 @@ public final class InPlaceChunkRegenerator { private void resetMantleMargin() { EngineMantle engineMantle = engine.getMantle(); - Mantle mantle = engineMantle.getMantle(); + Mantle mantle = engineMantle.getMantle(); int margin = radius + Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1; for (int dx = -margin; dx <= margin; dx++) { for (int dz = -margin; dz <= margin; dz++) { - mantle.deleteChunk(centerChunkX + dx, centerChunkZ + dz); + DeferredPlacementRecords.resetMantleChunk(mantle, centerChunkX + dx, centerChunkZ + dz, + Math.abs(dx) <= radius && Math.abs(dz) <= radius); } } } @@ -188,7 +192,14 @@ public final class InPlaceChunkRegenerator { } } - EngineBukkitOps.updateChunk(engine, chunk); + Mantle mantle = engine.getMantle().getMantle(); + MantleChunk records = mantle.useChunk(chunkX, chunkZ); + try { + DeferredPlacementRecords.invalidateCustomBlockOwnership(records); + } finally { + records.release(); + } + EngineBukkitOps.updateChunk(engine, chunk, mantle); world.refreshChunk(chunkX, chunkZ); } diff --git a/core/src/main/java/art/arcane/iris/world/storage/matter/CustomBlockOwnershipMatter.java b/core/src/main/java/art/arcane/iris/world/storage/matter/CustomBlockOwnershipMatter.java new file mode 100644 index 000000000..f9ed45261 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/storage/matter/CustomBlockOwnershipMatter.java @@ -0,0 +1,53 @@ +package art.arcane.iris.world.storage.matter; + +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.iris.integration.Identifier; +import art.arcane.volmlib.util.data.palette.Palette; +import art.arcane.volmlib.util.matter.Sliced; +import art.arcane.volmlib.util.matter.slices.RawMatter; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Sliced +public final class CustomBlockOwnershipMatter extends RawMatter { + public CustomBlockOwnershipMatter() { + this(1, 1, 1); + } + + public CustomBlockOwnershipMatter(int width, int height, int depth) { + super(width, height, depth, CustomBlockOwnership.class); + } + + @Override + public Palette getGlobalPalette() { + return null; + } + + @Override + public void writeNode(CustomBlockOwnership ownership, DataOutputStream output) throws IOException { + output.writeInt(ownership.entries().size()); + for (CustomBlockOwnership.Entry entry : ownership.entries()) { + output.writeUTF(entry.identifier().toString()); + output.writeUTF(entry.token()); + output.writeBoolean(entry.invalidated()); + } + } + + @Override + public CustomBlockOwnership readNode(DataInputStream input) throws IOException { + int size = input.readInt(); + if (size < 0 || size > 65_536) { + throw new IOException("Invalid custom block ownership count: " + size); + } + List entries = new ArrayList<>(size); + for (int index = 0; index < size; index++) { + entries.add(new CustomBlockOwnership.Entry(Identifier.fromString(input.readUTF()), + input.readUTF(), input.readBoolean())); + } + return new CustomBlockOwnership(entries); + } +} diff --git a/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java b/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java index cf16b4c0d..a67a35db7 100644 --- a/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java +++ b/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java @@ -40,6 +40,7 @@ public final class IrisMatterSupport { } IrisMatter.registerSliceType(new EntityMatter()); + IrisMatter.registerSliceType(new CustomBlockOwnershipMatter()); IrisMatter.registerSliceType(new ObjectContinuationMatter()); IrisMatter.registerSliceType("art.arcane.iris.core.link.Identifier", new IdentifierMatter()); IrisMatter.registerSliceType("art.arcane.iris.engine.framework.NativeStructureOwnershipBundle", new NativeStructureOwnershipMatter()); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/DeferredPlacementRegenerationTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/DeferredPlacementRegenerationTest.java new file mode 100644 index 000000000..1c46c3b15 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/DeferredPlacementRegenerationTest.java @@ -0,0 +1,154 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisEngineMantle; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import art.arcane.iris.world.storage.matter.PreObjectMatterTest; +import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint; +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.MantleHooks; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.List; +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 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.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class DeferredPlacementRegenerationTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private static final Identifier ID = Identifier.fromString("craftengine:example/chair"); + private static final CustomBlockOwnership.Entry ORIGINAL = new CustomBlockOwnership.Entry(ID, "original", false); + private static final NativeBlockPoint POSITION = new NativeBlockPoint(3, 70, 5); + + private Mantle mantle; + private MantleChunk chunk; + + @Before + @SuppressWarnings("unchecked") + public void setUp() { + PreObjectMatterTest.setUpBukkit(); + IrisMatterSupport.ensureRegistered(); + chunk = spy(new MantleChunk<>(24, 1, -1, + IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class)), MantleHooks.NONE)); + mantle = mock(Mantle.class); + when(mantle.useChunk(1, -1)).thenAnswer(invocation -> chunk.use()); + chunk.getOrCreate(POSITION.y() >> 4).slice(CustomBlockOwnership.class) + .set(POSITION.x(), POSITION.y() & 15, POSITION.z(), new CustomBlockOwnership(List.of(ORIGINAL))); + chunk.getOrCreate(POSITION.y() >> 4).slice(Identifier.class) + .set(POSITION.x(), POSITION.y() & 15, POSITION.z(), ID); + chunk.flag(MantleFlag.OBJECT, true); + chunk.flag(MantleFlag.of(200), true); + chunk.flag(MantleFlag.TILE, true); + chunk.flag(MantleFlag.CUSTOM, true); + chunk.flag(MantleFlag.UPDATE, true); + chunk.flag(MantleFlag.ETCHED, true); + chunk.flag(MantleFlag.INITIAL_SPAWNED_MARKER, true); + } + + @Test + public void marginResetKeepsOwnershipAndPreventsDuplicateMaterialization() { + CustomBlockOwnership before = ownership(); + + DeferredPlacementRecords.resetMantleChunk(mantle, 1, -1, false); + + assertSame(before, ownership()); + assertNull(chunk.get(POSITION.x(), POSITION.y(), POSITION.z(), Identifier.class)); + assertFalse(chunk.isFlagged(MantleFlag.OBJECT)); + assertFalse(chunk.isFlagged(MantleFlag.of(200))); + assertTrue(chunk.isFlagged(MantleFlag.TILE)); + assertTrue(chunk.isFlagged(MantleFlag.CUSTOM)); + assertTrue(chunk.isFlagged(MantleFlag.UPDATE)); + assertTrue(chunk.isFlagged(MantleFlag.INITIAL_SPAWNED_MARKER)); + AtomicInteger placements = new AtomicInteger(); + chunk.raiseFlagUnchecked(MantleFlag.ETCHED, placements::incrementAndGet); + assertEquals(0, placements.get()); + verify(mantle, never()).deleteChunk(1, -1); + } + + @Test + public void targetResetKeepsOwnershipUntilSuccessfulLiveReplacement() { + DeferredPlacementRecords.resetMantleChunk(mantle, 1, -1, true); + + assertEquals(List.of(ORIGINAL), ownership().entries()); + assertFalse(chunk.isFlagged(MantleFlag.TILE)); + assertFalse(chunk.isFlagged(MantleFlag.CUSTOM)); + assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); + assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); + assertFalse(chunk.isFlagged(MantleFlag.INITIAL_SPAWNED_MARKER)); + } + + @Test(timeout = 5000) + public void successfulTargetReplacementInvalidatesOldTokensWithoutDeletingNewRecords() { + DeferredPlacementRecords.resetMantleChunk(mantle, 1, -1, true); + chunk.getOrCreate(POSITION.y() >> 4).slice(Identifier.class) + .set(POSITION.x(), POSITION.y() & 15, POSITION.z(), ID); + + DeferredPlacementRecords.invalidateCustomBlockOwnership(chunk); + + assertEquals(List.of(new CustomBlockOwnership.Entry(ID, "original", true)), ownership().entries()); + assertEquals(ID, chunk.get(POSITION.x(), POSITION.y(), POSITION.z(), Identifier.class)); + } + + @Test + public void ownershipAddedDuringResetSurvivesInTheSameChunk() throws Exception { + CountDownLatch resetting = new CountDownLatch(1); + CountDownLatch resume = new CountDownLatch(1); + AtomicBoolean firstRead = new AtomicBoolean(true); + doAnswer(invocation -> { + if (firstRead.compareAndSet(true, false)) { + resetting.countDown(); + assertTrue(resume.await(5, TimeUnit.SECONDS)); + } + return invocation.callRealMethod(); + }).when(chunk).get(0); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(mock(IrisWorld.class)); + CustomBlockOwnership.Entry concurrent = new CustomBlockOwnership.Entry(ID, "concurrent", false); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future reset = executor.submit(() -> DeferredPlacementRecords.resetMantleChunk(mantle, 1, -1, false)); + assertTrue(resetting.await(5, TimeUnit.SECONDS)); + Future remember = executor.submit(() -> DeferredPlacementRecords.remember(engine, chunk, POSITION, concurrent)); + resume.countDown(); + reset.get(5, TimeUnit.SECONDS); + remember.get(5, TimeUnit.SECONDS); + + assertEquals(List.of(ORIGINAL, new CustomBlockOwnership.Entry(ID, "concurrent", true)), ownership().entries()); + assertFalse(chunk.inUse()); + } finally { + resume.countDown(); + executor.shutdownNow(); + } + } + + private CustomBlockOwnership ownership() { + return chunk.get(POSITION.x(), POSITION.y(), POSITION.z(), CustomBlockOwnership.class); + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/generation/CustomBlockOwnershipTest.java b/core/src/test/java/art/arcane/iris/platform/generation/CustomBlockOwnershipTest.java new file mode 100644 index 000000000..4182834aa --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/generation/CustomBlockOwnershipTest.java @@ -0,0 +1,299 @@ +package art.arcane.iris.platform.generation; + +import art.arcane.iris.generation.mantle.CustomBlockOwnership; +import art.arcane.iris.generation.mantle.DeferredPlacementRecords; +import art.arcane.iris.generation.mantle.EngineMantle; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EnginePlatformHooks; +import art.arcane.iris.generation.runtime.IrisEngineMantle; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.integration.ExternalDataSVC; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.spi.PlatformScheduler; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.storage.matter.CustomBlockOwnershipMatter; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import art.arcane.iris.world.storage.matter.PreObjectMatterTest; +import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet; +import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.MantleHooks; +import art.arcane.volmlib.util.matter.Matter; +import org.bukkit.Chunk; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CustomBlockOwnershipTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private static final int MIN_HEIGHT = -64; + private static final NativeBlockPoint WORLD_POSITION = new NativeBlockPoint(19, 70, -11); + private static final NativeBlockPoint MANTLE_POSITION = new NativeBlockPoint(3, 134, 5); + private static final CustomBlockOwnership.Entry ORIGINAL = new CustomBlockOwnership.Entry( + Identifier.fromString("craftengine:example/chair"), "original-entity", false); + private static final CustomBlockOwnership.Entry REPLACEMENT = new CustomBlockOwnership.Entry( + Identifier.fromString("craftengine:example/chair"), "replacement-entity", false); + + private IrisPlatform previous; + private PlatformScheduler scheduler; + private EnginePlatformHooks hooks; + private Engine engine; + private NativeWorld world; + private MantleChunk chunk; + + @Before + @SuppressWarnings("unchecked") + public void setUp() { + PreObjectMatterTest.setUpBukkit(); + IrisMatterSupport.ensureRegistered(); + previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block("AIR")).thenReturn(mock(NativeBlockState.class)); + when(platform.registries()).thenReturn(registries); + scheduler = mock(PlatformScheduler.class); + when(platform.scheduler()).thenReturn(scheduler); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + chunk = new MantleChunk<>(24, 1, -1, + IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class)), MantleHooks.NONE); + Mantle mantle = mock(Mantle.class); + when(mantle.useChunk(1, -1)).thenAnswer(invocation -> chunk.use()); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getMantle()).thenReturn(mantle); + world = mock(NativeWorld.class); + when(world.isChunkLoaded(1, -1)).thenReturn(true); + IrisWorld irisWorld = mock(IrisWorld.class); + when(irisWorld.platformWorld()).thenReturn(world); + when(irisWorld.minHeight()).thenReturn(MIN_HEIGHT); + engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(irisWorld); + when(engine.getMantle()).thenReturn(engineMantle); + when(engine.getMinHeight()).thenReturn(MIN_HEIGHT); + hooks = mock(EnginePlatformHooks.class); + IrisServices.register(EnginePlatformHooks.class, hooks); + } + + @After + public void tearDown() { + IrisServices.remove(EnginePlatformHooks.class); + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + + @Test + public void materializedFurnitureEnablesTrackingAndRemovesOnOwningThread() { + remember(ORIGINAL); + NativeBlockChangeSet changes = changes(); + assertNotSame(NativeBlockChangeSet.disabled(), changes); + + DeferredPlacementRecords.invalidate(engine, changes); + + verify(hooks, never()).removeCustomBlock(any(), any(), any()); + CustomBlockOwnership.Entry pending = pendingOriginal(); + assertEquals(List.of(pending), ownership().entries()); + when(hooks.removeCustomBlock(engine, WORLD_POSITION, pending)).thenReturn(true); + scheduledTask().run(); + + verify(hooks).removeCustomBlock(engine, WORLD_POSITION, pending); + assertNull(ownership()); + } + + @Test + public void queuedRemovalKeepsReplacementOwnership() { + remember(ORIGINAL); + DeferredPlacementRecords.invalidate(engine, changes()); + remember(REPLACEMENT); + when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true); + + scheduledTask().run(); + + assertEquals(List.of(REPLACEMENT), ownership().entries()); + verify(hooks, never()).removeCustomBlock(eq(engine), eq(WORLD_POSITION), eq(REPLACEMENT)); + } + + @Test + public void unloadedChunkRetainsRemovalUntilLoadedAndRetriesDeclinedCleanup() { + remember(ORIGINAL); + DeferredPlacementRecords.invalidate(engine, changes()); + when(world.isChunkLoaded(1, -1)).thenReturn(false); + + scheduledTask().run(); + + verify(hooks, never()).removeCustomBlock(any(), any(), any()); + when(world.isChunkLoaded(1, -1)).thenReturn(true); + DeferredPlacementRecords.removeInvalidated(engine, 1, -1); + assertEquals(List.of(pendingOriginal()), ownership().entries()); + when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true); + + DeferredPlacementRecords.removeInvalidated(engine, 1, -1); + + assertNull(ownership()); + } + + @Test + public void ownershipPersistsPendingAndReplacementTokens() throws Exception { + CustomBlockOwnership expected = new CustomBlockOwnership(List.of(pendingOriginal(), REPLACEMENT)); + CustomBlockOwnershipMatter matter = new CustomBlockOwnershipMatter(); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.writeNode(expected, new DataOutputStream(bytes)); + + assertEquals(expected, matter.readNode(new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())))); + } + + @Test + public void invalidationDuringProviderCallbackRemovesCompletedPlacement() { + chunk.getOrCreate(MANTLE_POSITION.y() >> 4).slice(Identifier.class) + .set(MANTLE_POSITION.x(), MANTLE_POSITION.y() & 15, MANTLE_POSITION.z(), ORIGINAL.identifier()); + DeferredPlacementRecords.invalidate(engine, changes()); + + DeferredPlacementRecords.remember(engine, chunk, MANTLE_POSITION, ORIGINAL); + + assertEquals(List.of(pendingOriginal()), ownership().entries()); + when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true); + scheduledTask().run(); + assertNull(ownership()); + } + + @Test(timeout = 10000) + public void providerCallbackAllowsConcurrentInvalidationAndSkipsCanceledSnapshotEntries() { + chunk.getOrCreate(MANTLE_POSITION.y() >> 4).slice(Identifier.class) + .set(MANTLE_POSITION.x(), MANTLE_POSITION.y() & 15, MANTLE_POSITION.z(), ORIGINAL.identifier()); + chunk.getOrCreate(MANTLE_POSITION.y() >> 4).slice(Identifier.class) + .set(MANTLE_POSITION.x() + 1, MANTLE_POSITION.y() & 15, MANTLE_POSITION.z(), ORIGINAL.identifier()); + NativeBlockChangeSet changes = changes(); + changes.add(WORLD_POSITION.x() + 1, WORLD_POSITION.y(), WORLD_POSITION.z()); + World bukkitWorld = mock(World.class); + when(bukkitWorld.getMinHeight()).thenReturn(MIN_HEIGHT); + when(bukkitWorld.getMaxHeight()).thenReturn(320); + Chunk bukkitChunk = mock(Chunk.class); + when(bukkitChunk.getWorld()).thenReturn(bukkitWorld); + Block started = mock(Block.class); + Block canceled = mock(Block.class); + when(bukkitChunk.getBlock(3, 70, 5)).thenReturn(started); + when(bukkitChunk.getBlock(4, 70, 5)).thenReturn(canceled); + ExternalDataSVC provider = mock(ExternalDataSVC.class); + IrisServices.register(ExternalDataSVC.class, provider); + try (ExecutorService worker = Executors.newVirtualThreadPerTaskExecutor()) { + when(provider.processUpdate(engine, started, ORIGINAL.identifier())).thenAnswer(invocation -> { + CompletableFuture.runAsync(() -> DeferredPlacementRecords.invalidate(engine, changes), worker) + .get(3, TimeUnit.SECONDS); + return ORIGINAL; + }); + + EngineBukkitOps.materializeCustomBlocks(engine, bukkitChunk, chunk); + + verify(provider).processUpdate(engine, started, ORIGINAL.identifier()); + verify(provider, never()).processUpdate(eq(engine), eq(canceled), any()); + assertEquals(List.of(pendingOriginal()), ownership().entries()); + when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true); + scheduledTask().run(); + assertNull(ownership()); + } finally { + IrisServices.remove(ExternalDataSVC.class); + } + } + + @Test + @SuppressWarnings("unchecked") + public void queuedInvalidationKeepsSelectedMantleAndHeightAfterRuntimeChanges() { + remember(ORIGINAL); + DeferredPlacementRecords.invalidate(engine, changes()); + Mantle replacementMantle = mock(Mantle.class); + EngineMantle replacementRuntime = mock(EngineMantle.class); + when(replacementRuntime.getMantle()).thenReturn(replacementMantle); + when(engine.getMantle()).thenReturn(replacementRuntime); + when(engine.getMinHeight()).thenReturn(0); + when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true); + + scheduledTask().run(); + + assertNull(ownership()); + verify(hooks).removeCustomBlock(engine, WORLD_POSITION, pendingOriginal()); + verify(replacementMantle, never()).useChunk(anyInt(), anyInt()); + } + + @Test + @SuppressWarnings("unchecked") + public void completedPlacementUsesSuppliedChunkWhenEngineSelectsAnotherMantle() { + Mantle replacementMantle = mock(Mantle.class); + EngineMantle replacementRuntime = mock(EngineMantle.class); + when(replacementRuntime.getMantle()).thenReturn(replacementMantle); + when(engine.getMantle()).thenReturn(replacementRuntime); + + DeferredPlacementRecords.remember(engine, chunk, MANTLE_POSITION, ORIGINAL); + + when(engine.getMinHeight()).thenReturn(0); + when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true); + scheduledTask().run(); + + assertNull(ownership()); + verify(hooks).removeCustomBlock(engine, WORLD_POSITION, pendingOriginal()); + verify(replacementMantle, never()).useChunk(anyInt(), anyInt()); + } + + private void remember(CustomBlockOwnership.Entry entry) { + chunk.getOrCreate(MANTLE_POSITION.y() >> 4).slice(Identifier.class) + .set(MANTLE_POSITION.x(), MANTLE_POSITION.y() & 15, MANTLE_POSITION.z(), entry.identifier()); + DeferredPlacementRecords.remember(engine, chunk, MANTLE_POSITION, entry); + } + + private NativeBlockChangeSet changes() { + NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, + new NativeBlockChangeSet.Bounds(1, -1, 1, -1, MIN_HEIGHT, 384)); + changes.add(WORLD_POSITION.x(), WORLD_POSITION.y(), WORLD_POSITION.z()); + return changes; + } + + private Runnable scheduledTask() { + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).region(eq(world), eq(1), eq(-1), task.capture()); + return task.getValue(); + } + + private CustomBlockOwnership ownership() { + return chunk.get(MANTLE_POSITION.y() >> 4).getSlice(CustomBlockOwnership.class) + .get(MANTLE_POSITION.x(), MANTLE_POSITION.y() & 15, MANTLE_POSITION.z()); + } + + private static CustomBlockOwnership.Entry pendingOriginal() { + return new CustomBlockOwnership.Entry(ORIGINAL.identifier(), ORIGINAL.token(), true); + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/generation/DeferredPlacementInvalidationTest.java b/core/src/test/java/art/arcane/iris/platform/generation/DeferredPlacementInvalidationTest.java index 497f1fa1f..ab34903dc 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/DeferredPlacementInvalidationTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/DeferredPlacementInvalidationTest.java @@ -1,7 +1,9 @@ package art.arcane.iris.platform.generation; import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.block.IrisCustomData; import art.arcane.iris.generation.mantle.DeferredPlacementRecords; +import art.arcane.iris.generation.mantle.CustomBlockOwnership; import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.IrisEngineMantle; @@ -12,6 +14,7 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.structure.nativegen.ImportedFeaturePolicy; import art.arcane.iris.testsupport.PlatformLeakGuard; import art.arcane.iris.world.IrisWorld; import art.arcane.iris.world.storage.matter.IrisMatterSupport; @@ -26,19 +29,24 @@ import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; import art.arcane.volmlib.util.mantle.runtime.MantleHooks; 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.data.BlockData; import org.junit.ClassRule; import org.junit.Test; import org.mockito.MockedStatic; import java.util.HashMap; import java.util.Map; +import java.util.List; +import java.util.Optional; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; @@ -105,6 +113,52 @@ public class DeferredPlacementInvalidationTest { assertSame(NativeBlockChangeSet.disabled(), DeferredPlacementRecords.track(engine(chunks, adapter), bounds())); + assertSame(NativeBlockChangeSet.disabled(), + DeferredPlacementRecords.positions(engine(chunks, adapter), bounds())); + }); + } + + @Test + public void importedFeatureCutRemovesFireRecordBeforeMaterialization() { + withMantleData(adapter -> { + Map> chunks = new HashMap<>(); + MantleChunk stored = chunk(chunks, adapter, 0, 0); + Identifier fire = Identifier.fromString("craftengine:example/fire"); + setRecord(stored, 3, 70, 5, Identifier.class, fire); + setRecord(stored, 4, 70, 5, Identifier.class, fire); + Engine engine = engine(chunks, adapter); + ImportedFeaturePolicy policy = new ImportedFeaturePolicy(engine); + NativeBlockChangeSet candidates = policy.trackedBlockPositions(bounds()); + assertEquals(2, candidates.size()); + NativeBlockChangeSet changes = NativeBlockChangeSet.create(candidates.bounds()); + changes.add(3, 70, 5); + + policy.invalidateBlockChanges(changes); + + assertNull(record(stored, 3, 70, 5, Identifier.class)); + World world = mock(World.class); + when(world.getMinHeight()).thenReturn(MIN_HEIGHT); + when(world.getMaxHeight()).thenReturn(320); + Chunk bukkitChunk = mock(Chunk.class); + when(bukkitChunk.getWorld()).thenReturn(world); + Block cut = mock(Block.class); + Block extinguished = mock(Block.class); + when(cut.getType()).thenReturn(Material.AIR); + when(extinguished.getType()).thenReturn(Material.AIR); + when(bukkitChunk.getBlock(3, 70, 5)).thenReturn(cut); + when(bukkitChunk.getBlock(4, 70, 5)).thenReturn(extinguished); + BlockData carrier = mock(BlockData.class); + when(carrier.getMaterial()).thenReturn(Material.FIRE); + ExternalDataSVC provider = mock(ExternalDataSVC.class); + when(provider.getBlockData(fire)).thenReturn(Optional.of(IrisCustomData.of(carrier, fire))); + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(provider); + + EngineBukkitOps.materializeCustomBlocks(engine, bukkitChunk, stored); + } + + verify(provider, never()).processUpdate(eq(engine), eq(cut), any()); + verify(provider).processUpdate(engine, extinguished, fire); }); } @@ -124,6 +178,27 @@ public class DeferredPlacementInvalidationTest { }); } + @Test + public void importedFeatureCandidatesIncludeNeighbourOwnershipAndDeduplicateSlices() { + withMantleData(adapter -> { + Map> chunks = new HashMap<>(); + MantleChunk stored = chunk(chunks, adapter, -1, 1); + Identifier identifier = Identifier.fromString("craftengine:example/chair"); + setRecord(stored, 15, 70, 2, Identifier.class, identifier); + setRecord(stored, 15, 70, 2, TileWrapper.class, + new TileWrapper(new TileData("minecraft:chest", new KMap<>()))); + setRecord(stored, 14, 71, 3, CustomBlockOwnership.class, + new CustomBlockOwnership(List.of(new CustomBlockOwnership.Entry(identifier, "entity-token", false)))); + setRecord(chunk(chunks, adapter, 2, 0), 0, 70, 0, Identifier.class, identifier); + + NativeBlockChangeSet positions = DeferredPlacementRecords.positions(engine(chunks, adapter), bounds()); + + assertEquals(2, positions.size()); + assertTrue(positions.contains(-1, 70, 18)); + assertTrue(positions.contains(-2, 71, 19)); + }); + } + private static NativeBlockChangeSet.Bounds bounds() { return new NativeBlockChangeSet.Bounds(-1, -1, 1, 1, MIN_HEIGHT, 384); } diff --git a/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsDeferredMaterializationTest.java b/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsDeferredMaterializationTest.java index ad538f68e..b003e4d57 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsDeferredMaterializationTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsDeferredMaterializationTest.java @@ -51,6 +51,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -76,6 +77,7 @@ public class EngineBukkitOpsDeferredMaterializationTest { iterator.accept(1, 2, 3, identifier); return null; }).when(stored).iterate(eq(Identifier.class), any()); + doReturn(identifier).when(stored).get(1, 2, 3, Identifier.class); Engine engine = mock(Engine.class); when(engine.getWorld()).thenReturn(mock(IrisWorld.class)); when(engine.getMetrics()).thenReturn(new EngineMetrics(8)); 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 index 6636655ad..ad0df8e2b 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsStaleRecordTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsStaleRecordTest.java @@ -89,6 +89,26 @@ public class EngineBukkitOpsStaleRecordTest { verify(fixture.service).processUpdate(fixture.engine, intact, ORE); } + @Test + public void extinguishedFireCarrierIsPlacedButCoveredFireIsSkipped() { + for (Material carrier : List.of(Material.FIRE, Material.SOUL_FIRE)) { + Fixture fixture = new Fixture(); + fixture.carrier(ORE, carrier); + Block extinguished = fixture.block(1, 10, 1, Material.AIR); + Block caveAir = fixture.block(2, 10, 2, Material.CAVE_AIR); + Block wall = fixture.block(3, 10, 3, Material.COBBLESTONE); + + fixture.materializeCustomBlocks(customRecords( + new Placed<>(1, 10, 1, ORE), + new Placed<>(2, 10, 2, ORE), + new Placed<>(3, 10, 3, ORE))); + + verify(fixture.service).processUpdate(fixture.engine, extinguished, ORE); + verify(fixture.service).processUpdate(fixture.engine, caveAir, ORE); + verify(fixture.service, never()).processUpdate(any(), eq(wall), any()); + } + } + @Test public void airCarrierFurnitureRecordIsPlacedWhateverTheLiveBlock() { Fixture fixture = new Fixture(); @@ -200,6 +220,9 @@ public class EngineBukkitOpsStaleRecordTest { @SuppressWarnings("unchecked") private static MantleChunk records(Class type, Placed[] records) { MantleChunk chunk = mock(MantleChunk.class); + for (Placed record : records) { + when(chunk.get(record.x(), record.y(), record.z(), type)).thenReturn(record.value()); + } doAnswer((InvocationOnMock invocation) -> { Consumer4 iterator = invocation.getArgument(1); for (Placed record : records) {