diff --git a/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/resources/plugin.yml b/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/resources/plugin.yml index 965cbc3fd..2a763547c 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/resources/plugin.yml +++ b/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/resources/plugin.yml @@ -2,6 +2,7 @@ name: IrisNativeLifecycleFixture version: 1.0.0 main: art.arcane.iris.fixture.NativeLifecycleFixture api-version: '26.1.2' +folia-supported: true depend: [Iris] commands: irislifecycle: diff --git a/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/NativeLifecycleFixture.java b/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/NativeLifecycleFixture.java index da21e7187..33dd08149 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/NativeLifecycleFixture.java +++ b/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/NativeLifecycleFixture.java @@ -1,8 +1,12 @@ package art.arcane.iris.fixture; +import art.arcane.iris.Iris; import art.arcane.volmlib.nativelib.NativeAdapters; import art.arcane.iris.world.runtime.RuntimeInjection; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.world.IrisCreator; +import art.arcane.iris.world.task.J; import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.world.IrisWorldStorage; import art.arcane.iris.world.WorldCreatorCompat; @@ -13,6 +17,15 @@ import org.bukkit.entity.Player; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.Material; import org.bukkit.block.Biome; +import org.bukkit.block.Container; +import org.bukkit.loot.Lootable; +import org.bukkit.loot.LootTable; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.MapMeta; +import org.bukkit.map.MapView; +import org.bukkit.generator.structure.Structure; +import org.bukkit.util.StructureSearchResult; import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess; import art.arcane.volmlib.nativelib.terrain.NativeStructureReader; import art.arcane.volmlib.nativelib.terrain.NativeWorldRuntime; @@ -38,12 +51,15 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe"); private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe"); private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe"); + private static final NamespacedKey TREASURE_KEY = new NamespacedKey("iris", "treasure_map_probe"); private static final long WORLD_SEED = 78264193L; private NativeWorldRuntime runtime; + private TreasureLootChecks treasureLootChecks; @Override public void onEnable() { runtime = NativeAdapters.require(NativeWorldRuntime.class); + treasureLootChecks = new TreasureLootChecks(this); NativeTerrainAccess terrain = NativeAdapters.require(NativeTerrainAccess.class); getLogger().info("Native lifecycle ready: " + runtime.description() + "; terrain provider=" + terrain.getClass().getName()); @@ -56,12 +72,32 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { - if (arguments.length != 1) { + if (arguments.length == 0) { sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry"); return true; } try { switch (arguments[0]) { + case "treasure-map-create" -> createTreasureMapWorld(sender); + case "treasure-map-limit" -> runTreasureRegion(sender, () -> checkTreasureMapLimit(sender)); + case "treasure-map-chest" -> { + if (arguments.length != 3) { + throw new IllegalArgumentException("Use treasure-map-chest "); + } + runTreasureRegion(sender, () -> prepareTreasureMapChest(sender, arguments[1], arguments[2])); + } + case "treasure-map-inspect" -> { + if (arguments.length != 2) { + throw new IllegalArgumentException("Use treasure-map-inspect "); + } + runTreasureRegion(sender, () -> inspectTreasureMapChest(sender, arguments[1])); + } + case "treasure-map-check" -> runTreasureRegion(sender, () -> verifyTreasureMapLoot(sender)); + case "treasure-extra-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verify(sender)); + case "treasure-region-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyRegion(sender)); + case "treasure-reload-before" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, false)); + case "treasure-reload-after" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, true)); + case "treasure-resources" -> treasureLootChecks.reportResourceReloads(sender); case "create" -> create(sender); case "unload" -> unload(sender); case "structures" -> structures(sender); @@ -80,6 +116,177 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun return true; } + private void createTreasureMapWorld(CommandSender sender) { + if (!(sender instanceof Player player)) { + throw new IllegalStateException("Treasure map fixture requires a player"); + } + runTreasureRegion(sender, () -> prepareTreasureMapWorld(player)); + } + + private void runTreasureRegion(CommandSender sender, Runnable task) { + ensureTreasureMapWorld() + .thenCompose(ignored -> J.runRegionFuture(Bukkit.getWorld(TREASURE_KEY), 0, 0, task)) + .whenComplete((ignored, failure) -> { + if (failure != null) { + fail(sender, failure); + } + }); + } + + private CompletableFuture ensureTreasureMapWorld() { + if (Bukkit.getWorld(TREASURE_KEY) != null) { + return CompletableFuture.completedFuture(null); + } + if (WorldCreatorCompat.persistentLevelRoot(TREASURE_KEY).exists()) { + return Iris.instance.worldReconciler().loadWorld(ServerProperties.BUKKIT_YML, TREASURE_KEY.toString()); + } + return runAsync(() -> { + try { + new IrisCreator().name(TREASURE_KEY.toString()).dimension("treasure-map-fixture") + .seed(WORLD_SEED).create(); + } catch (Exception failure) { + throw new IllegalStateException("Treasure map fixture creation failed", failure); + } + }); + } + + private void prepareTreasureMapWorld(Player player) { + World world = Bukkit.getWorld(TREASURE_KEY); + if (world == null || !IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED + || !"treasure-map-fixture".equals(IrisToolbelt.access(world).getEngine().getDimension().getLoadKey())) { + throw new IllegalStateException("Treasure map fixture world identity is invalid"); + } + for (int x = 0; x <= 8; x++) { + for (int z = 0; z <= 8; z++) { + world.getBlockAt(x, 160, z).setType(Material.STONE, false); + for (int y = 161; y <= 164; y++) { + world.getBlockAt(x, y, z).setType(Material.AIR, false); + } + } + } + if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 0.5, 161, 0.5)).whenComplete((teleported, failure) -> { + if (failure != null) { + fail(player, failure); + } else if (!Boolean.TRUE.equals(teleported)) { + fail(player, new IllegalStateException("Treasure map fixture teleport failed")); + } else { + player.sendMessage("TREASURE_MAP_READY world=" + TREASURE_KEY + + " version=" + Bukkit.getMinecraftVersion() + " folia=" + J.isFolia()); + } + }))) { + throw new IllegalStateException("Could not schedule treasure map fixture teleport"); + } + } + + private void checkTreasureMapLimit(CommandSender sender) { + World world = Bukkit.getWorld(TREASURE_KEY); + if (world == null) { + throw new IllegalStateException("Treasure map fixture world is absent"); + } + Engine engine = IrisToolbelt.access(world).getEngine(); + IrisStructureLocator.LocateResult located = IrisStructureLocator.locateInPlacementRings( + engine, "minecraft:buried_treasure", 2, 2, 50, (x, z) -> true); + if (located.status() != IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { + throw new IllegalStateException("Blocked treasure placement returned " + located.status()); + } + sender.sendMessage("TREASURE_MAP_LIMIT status=" + located.status()); + } + + private void prepareTreasureMapChest(CommandSender sender, String destination, String material) { + World world = Bukkit.getWorld(TREASURE_KEY); + treasureMapChest(world, destination, material); + sender.sendMessage("TREASURE_MAP_CHEST destination=" + destination + " material=" + material); + } + + private Container treasureMapChest(World world, String destination, String material) { + requireTreasureDestination(destination); + if (!List.of("CHEST", "COPPER_CHEST").contains(material)) { + throw new IllegalArgumentException("Unknown treasure map container: " + material); + } + LootTable loot = Bukkit.getLootTable(new NamespacedKey("iris_qa", destination)); + if (loot == null) { + throw new IllegalStateException("Treasure map loot table is absent: " + destination); + } + world.getBlockAt(2, 161, 2).setType(Material.AIR, false); + world.getBlockAt(2, 161, 2).setType(Material.valueOf(material), false); + Container chest = (Container) world.getBlockAt(2, 161, 2).getState(); + ((Lootable) chest).setLootTable(loot); + ((Lootable) chest).setSeed(WORLD_SEED); + if (!chest.update(true, false)) { + throw new IllegalStateException("Treasure map loot chest was not installed"); + } + return (Container) world.getBlockAt(2, 161, 2).getState(); + } + + private void verifyTreasureMapLoot(CommandSender sender) { + checkTreasureMapLimit(sender); + World world = Bukkit.getWorld(TREASURE_KEY); + for (String destination : List.of("blocked", "disabled", "found", "mixed")) { + Inventory inventory = treasureMapChest(world, destination, "CHEST").getInventory(); + inspectTreasureMapChest(sender, destination); + inventory.remove(Material.DIAMOND); + Inventory reopened = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory(); + if (reopened.contains(Material.DIAMOND) + || !(reopened.contains(Material.FILLED_MAP) || reopened.contains(Material.MAP))) { + throw new IllegalStateException("Reopening changed " + destination + " chest loot"); + } + } + sender.sendMessage("TREASURE_MAP_CHECK destinations=4 retained=true"); + } + + private void inspectTreasureMapChest(CommandSender sender, String destination) { + requireTreasureDestination(destination); + World world = Bukkit.getWorld(TREASURE_KEY); + Inventory inventory = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory(); + ItemStack map = null; + int diamonds = 0; + for (ItemStack item : inventory.getContents()) { + if (item == null) { + continue; + } + if ((item.getType() == Material.MAP || item.getType() == Material.FILLED_MAP) + && item.getAmount() == 1 && map == null) { + map = item; + } else if (item.getType() == Material.DIAMOND) { + diamonds += item.getAmount(); + } else { + throw new IllegalStateException("Unexpected chest loot: " + item); + } + } + if (map == null || diamonds != 1) { + throw new IllegalStateException("Invalid " + destination + " chest: map=" + map + " diamonds=" + diamonds); + } + boolean expectedLocated = destination.equals("found") || destination.equals("mixed"); + MapView view = map.getItemMeta() instanceof MapMeta meta && meta.hasMapView() ? meta.getMapView() : null; + if ((view != null) != expectedLocated) { + throw new IllegalStateException("Unexpected map data for " + destination + ": " + map); + } + String result = "TREASURE_MAP_INSPECT destination=" + destination + " diamonds=1 located=" + expectedLocated; + if (view != null) { + StructureSearchResult target = world.locateNearestStructure( + new Location(world, 2, 161, 2), Structure.DESERT_PYRAMID, 50, false); + if (target == null || target.getStructure() != Structure.DESERT_PYRAMID) { + throw new IllegalStateException("Map has no verified pyramid target"); + } + int targetX = target.getLocation().getBlockX(); + int targetZ = target.getLocation().getBlockZ(); + if (view.getWorld() != world || view.getScale() != MapView.Scale.CLOSEST + || view.getCenterX() != Math.floorDiv(targetX + 64, 128) * 128 + || view.getCenterZ() != Math.floorDiv(targetZ + 64, 128) * 128) { + throw new IllegalStateException("Map data does not cover the verified pyramid at " + target.getLocation()); + } + result += " mapId=" + view.getId() + " targetX=" + targetX + " targetZ=" + targetZ + + " centerX=" + view.getCenterX() + " centerZ=" + view.getCenterZ(); + } + sender.sendMessage(result); + } + + private void requireTreasureDestination(String destination) { + if (!List.of("blocked", "disabled", "found", "mixed").contains(destination)) { + throw new IllegalArgumentException("Unknown treasure map destination: " + destination); + } + } + private void lifecycleStatus(CommandSender sender) throws Exception { World world = Bukkit.getWorld(COMMAND_KEY); Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath(); @@ -282,11 +489,11 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun } public boolean regionized() { - return false; + return J.isFolia(); } public boolean primaryThread() { - return Bukkit.isPrimaryThread(); + return J.isFolia() ? Bukkit.isGlobalTickThread() : Bukkit.isPrimaryThread(); } public CompletableFuture runGlobal(Runnable task) { @@ -299,10 +506,10 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun result.completeExceptionally(failure); } }; - if (Bukkit.isPrimaryThread()) { + if (primaryThread()) { wrapped.run(); - } else { - Bukkit.getScheduler().runTask(this, wrapped); + } else if (!J.runGlobal(wrapped)) { + result.completeExceptionally(new IllegalStateException("Could not schedule fixture task")); } return result; } diff --git a/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/TreasureLootChecks.java b/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/TreasureLootChecks.java new file mode 100644 index 000000000..a67737bab --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/TreasureLootChecks.java @@ -0,0 +1,259 @@ +package art.arcane.iris.fixture; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.platform.bukkit.BukkitBlockResolution; +import art.arcane.iris.platform.generation.EngineBukkitOps; +import art.arcane.iris.world.IrisToolbelt; +import art.arcane.iris.world.loot.InventorySlotType; +import art.arcane.iris.world.loot.IrisVanillaLootTable; +import art.arcane.iris.world.loot.IrisLootTable; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.util.matter.Matter; +import io.papermc.paper.event.world.StructuresLocateEvent; +import io.papermc.paper.event.server.ServerResourcesReloadedEvent; +import io.papermc.paper.math.Position; +import org.bukkit.Bukkit; +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.Container; +import org.bukkit.block.Chest; +import org.bukkit.command.CommandSender; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.world.LootGenerateEvent; +import org.bukkit.generator.structure.Structure; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Inventory; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.util.StructureSearchResult; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +public final class TreasureLootChecks implements Listener { + private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "treasure_map_probe"); + private static final List CHESTS = List.of( + "CHEST", "COPPER_CHEST", "EXPOSED_COPPER_CHEST", "WEATHERED_COPPER_CHEST", + "OXIDIZED_COPPER_CHEST", "WAXED_COPPER_CHEST", "WAXED_EXPOSED_COPPER_CHEST", + "WAXED_WEATHERED_COPPER_CHEST", "WAXED_OXIDIZED_COPPER_CHEST"); + private final IrisVanillaLootTable reloadTable = new IrisVanillaLootTable("iris_qa:reload"); + private final AtomicInteger resourceReloads = new AtomicInteger(); + private LocateAction locateAction = LocateAction.PASS; + private int locateEvents; + private int lootEvents; + private boolean lootEventsOwned = true; + + public TreasureLootChecks(JavaPlugin plugin) { + Bukkit.getPluginManager().registerEvents(this, plugin); + } + + public void verify(CommandSender sender) { + World world = requireWorld(); + verifyCopperLoot(world); + sender.sendMessage("TREASURE_COPPER_LOOT variants=8 normal=true diamonds=1"); + verifyInventoryRollback(world); + sender.sendMessage("TREASURE_LOOT_ROLLBACK contents=true nativeTable=true seed=true retry=true"); + verifyLocateEvents(world); + sender.sendMessage("TREASURE_LOCATE_EVENTS cancelled=true replaced=true calls=2"); + sender.sendMessage("TREASURE_EXTRA_CHECK variants=9 rollback=true locateEvents=true"); + } + + public void verifyRegion(CommandSender sender) { + World world = requireWorld(); + if (!J.isOwnedByCurrentRegion(world, 0, 0)) { + throw new IllegalStateException("Fixture does not own its loot region"); + } + lootEvents = 0; + lootEventsOwned = true; + verifyCopperLoot(world); + if (lootEvents != CHESTS.size() || !lootEventsOwned) { + throw new IllegalStateException("Loot event ownership failed: count=" + lootEvents + + " owned=" + lootEventsOwned); + } + sender.sendMessage("TREASURE_REGION_LOOT copper=8 normal=1 events=" + lootEvents + + " owned=true folia=" + J.isFolia()); + } + + @EventHandler + public void onLoot(LootGenerateEvent event) { + if (event.getWorld().getKey().equals(WORLD_KEY) && event.isPlugin()) { + Location origin = event.getLootContext().getLocation(); + lootEvents++; + lootEventsOwned &= J.isOwnedByCurrentRegion(event.getWorld(), + origin.getBlockX() >> 4, origin.getBlockZ() >> 4); + } + } + + public void verifyReload(CommandSender sender, boolean reloaded) { + World world = requireWorld(); + Material expected = reloaded ? Material.EMERALD : Material.DIAMOND; + Collection items = reloadTable.getLoot( + false, 783L, InventorySlotType.STORAGE, world, 2, 161, 2); + if (items.size() != 1 || items.iterator().next().getType() != expected) { + throw new IllegalStateException("Reloaded loot did not contain " + expected + ": " + items); + } + sender.sendMessage("TREASURE_RELOAD material=" + expected); + } + + public void reportResourceReloads(CommandSender sender) { + sender.sendMessage("TREASURE_RESOURCES revision=" + resourceReloads.get()); + } + + @EventHandler + public void onResourcesReloaded(ServerResourcesReloadedEvent event) { + resourceReloads.incrementAndGet(); + } + + @EventHandler + public void onLocate(StructuresLocateEvent event) { + if (!event.getWorld().getKey().equals(WORLD_KEY) || locateAction == LocateAction.PASS) { + return; + } + locateEvents++; + if (locateAction == LocateAction.CANCEL) { + event.setCancelled(true); + } else { + event.setResult(new StructuresLocateEvent.Result(Position.block(123, 100, 456), Structure.DESERT_PYRAMID)); + } + } + + private void verifyCopperLoot(World world) { + Engine engine = IrisToolbelt.access(world).getEngine(); + Block block = world.getBlockAt(4, 161, 4); + MantleChunk mantle = engine.getMantle().getMantle().useChunk(block.getChunk()); + try { + for (String name : CHESTS) { + block.setType(Material.AIR, false); + block.setType(Material.valueOf(name), false); + if (!BukkitBlockResolution.isStorageChest(block.getBlockData())) { + throw new IllegalStateException("Chest is excluded from Iris loot: " + name); + } + EngineBukkitOps.update(engine, 4, 161, 4, block.getChunk(), mantle); + Container chest = (Container) block.getState(); + int diamonds = 0; + for (ItemStack item : chest.getInventory().getContents()) { + if (item == null) { + continue; + } + if (item.getType() != Material.DIAMOND) { + throw new IllegalStateException("Unexpected " + name + " loot: " + item); + } + diamonds += item.getAmount(); + } + if (diamonds != 1) { + throw new IllegalStateException(name + " contains " + diamonds + " diamonds"); + } + } + } finally { + mantle.release(); + } + } + + private void verifyInventoryRollback(World world) { + Engine engine = IrisToolbelt.access(world).getEngine(); + Block block = world.getBlockAt(6, 161, 4); + block.setType(Material.AIR, false); + block.setType(Material.CHEST, false); + Chest chest = (Chest) block.getState(); + chest.getBlockInventory().setItem(0, new ItemStack(Material.EMERALD, 3)); + chest = (Chest) block.getState(); + NamespacedKey key = new NamespacedKey("iris_qa", "reload"); + chest.setLootTable(Bukkit.getLootTable(key)); + chest.setSeed(913L); + if (!chest.update(true, false)) { + throw new IllegalStateException("Could not set native loot for rollback check"); + } + Inventory inventory = chest.getBlockInventory(); + IllegalStateException injected = new IllegalStateException("Fixture insertion interruption"); + Inventory interrupted = (Inventory) Proxy.newProxyInstance(Inventory.class.getClassLoader(), + new Class[]{Inventory.class}, (proxy, method, arguments) -> { + Object result; + try { + result = method.invoke(inventory, arguments); + } catch (InvocationTargetException failure) { + throw failure.getCause(); + } + if (method.getName().equals("addItem")) { + throw injected; + } + return result; + }); + KList tables = new KList<>(); + tables.add(new IrisVanillaLootTable("iris_qa:reload")); + try { + EngineBukkitOps.addItems(engine, false, interrupted, tables, InventorySlotType.STORAGE, + world, 6, 161, 4); + throw new IllegalStateException("Interrupted insertion did not fail"); + } catch (IllegalStateException failure) { + if (failure != injected) { + throw failure; + } + } + Chest restored = (Chest) block.getState(); + if (restored.getLootTable() == null || !restored.getLootTable().getKey().equals(key) + || restored.getSeed() != 913L) { + throw new IllegalStateException("Rollback lost the native loot table or seed"); + } + ItemStack existing = restored.getSnapshotInventory().getItem(0); + if (existing == null || existing.getType() != Material.EMERALD || existing.getAmount() != 3) { + throw new IllegalStateException("Rollback lost the original contents: " + existing); + } + EngineBukkitOps.addItems(engine, false, restored.getBlockInventory(), tables, InventorySlotType.STORAGE, + world, 6, 161, 4); + int diamonds = 0; + int emeralds = 0; + for (ItemStack item : restored.getBlockInventory().getContents()) { + if (item == null) { + continue; + } + if (item.getType() == Material.DIAMOND) { + diamonds += item.getAmount(); + } else if (item.getType() == Material.EMERALD) { + emeralds += item.getAmount(); + } + } + if (diamonds != 2 || emeralds != 3 || ((Chest) block.getState()).getLootTable() != null) { + throw new IllegalStateException("Loot retry failed: diamonds=" + diamonds + " emeralds=" + emeralds); + } + } + + private void verifyLocateEvents(World world) { + Location origin = new Location(world, 2, 161, 2); + locateEvents = 0; + try { + locateAction = LocateAction.CANCEL; + if (world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false) != null) { + throw new IllegalStateException("Cancelled structure search returned a target"); + } + locateAction = LocateAction.REPLACE; + StructureSearchResult result = world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false); + if (result == null || result.getStructure() != Structure.DESERT_PYRAMID + || result.getLocation().getBlockX() != 123 || result.getLocation().getBlockY() != 100 + || result.getLocation().getBlockZ() != 456 || locateEvents != 2) { + throw new IllegalStateException("Structure search ignored event result: " + result); + } + } finally { + locateAction = LocateAction.PASS; + } + } + + private World requireWorld() { + World world = Bukkit.getWorld(WORLD_KEY); + if (world == null || !IrisToolbelt.isIrisWorld(world)) { + throw new IllegalStateException("Treasure map fixture world is absent"); + } + return world; + } + + private enum LocateAction { + PASS, CANCEL, REPLACE + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/blocked.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/blocked.json new file mode 100644 index 000000000..c8ea247c4 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/blocked.json @@ -0,0 +1,33 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:map", + "functions": [ + { + "destination": "iris_qa:blocked", + "decoration": "minecraft:red_x", + "zoom": 0, + "search_radius": 50, + "skip_existing_chunks": false, + "function": "minecraft:exploration_map" + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/disabled.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/disabled.json new file mode 100644 index 000000000..a214a5496 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/disabled.json @@ -0,0 +1,33 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:map", + "functions": [ + { + "destination": "iris_qa:disabled", + "decoration": "minecraft:red_x", + "zoom": 0, + "search_radius": 50, + "skip_existing_chunks": false, + "function": "minecraft:exploration_map" + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/found.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/found.json new file mode 100644 index 000000000..24affc712 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/found.json @@ -0,0 +1,33 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:map", + "functions": [ + { + "destination": "iris_qa:found", + "decoration": "minecraft:red_x", + "zoom": 0, + "search_radius": 50, + "skip_existing_chunks": false, + "function": "minecraft:exploration_map" + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/mixed.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/mixed.json new file mode 100644 index 000000000..ef2b41bf5 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/mixed.json @@ -0,0 +1,33 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:map", + "functions": [ + { + "destination": "iris_qa:mixed", + "decoration": "minecraft:red_x", + "zoom": 0, + "search_radius": 50, + "skip_existing_chunks": false, + "function": "minecraft:exploration_map" + } + ] + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/reload.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/reload.json new file mode 100644 index 000000000..ddfca0b67 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/reload.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/blocked.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/blocked.json new file mode 100644 index 000000000..99809b671 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/blocked.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:buried_treasure" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/disabled.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/disabled.json new file mode 100644 index 000000000..977f1c380 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/disabled.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:mansion" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/found.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/found.json new file mode 100644 index 000000000..5476244c9 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/found.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:desert_pyramid" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/mixed.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/mixed.json new file mode 100644 index 000000000..a35886d36 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/mixed.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:buried_treasure", + "minecraft:desert_pyramid" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/pack.mcmeta b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/pack.mcmeta new file mode 100644 index 000000000..07add3260 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/pack.mcmeta @@ -0,0 +1,13 @@ +{ + "pack": { + "description": "Treasure map gameplay fixtures", + "min_format": [ + 107, + 1 + ], + "max_format": [ + 107, + 1 + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/blocked.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/blocked.json new file mode 100644 index 000000000..1a7504c5c --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/blocked.json @@ -0,0 +1,31 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:filled_map", + "modifier": { + "destination": "#iris_qa:blocked", + "decoration": "minecraft:red_x", + "zoom": 0, + "search_radius": 50, + "skip_existing_chunks": false, + "type": "minecraft:exploration_map" + } + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/disabled.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/disabled.json new file mode 100644 index 000000000..3a470cd9d --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/disabled.json @@ -0,0 +1,31 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:filled_map", + "modifier": { + "destination": "#iris_qa:disabled", + "decoration": "minecraft:red_x", + "zoom": 0, + "search_radius": 50, + "skip_existing_chunks": false, + "type": "minecraft:exploration_map" + } + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/found.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/found.json new file mode 100644 index 000000000..efbf8ca12 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/found.json @@ -0,0 +1,31 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:filled_map", + "modifier": { + "destination": "#iris_qa:found", + "decoration": "minecraft:red_x", + "zoom": 0, + "search_radius": 50, + "skip_existing_chunks": false, + "type": "minecraft:exploration_map" + } + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/mixed.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/mixed.json new file mode 100644 index 000000000..ffca33908 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/mixed.json @@ -0,0 +1,31 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:filled_map", + "modifier": { + "destination": "#iris_qa:mixed", + "decoration": "minecraft:red_x", + "zoom": 0, + "search_radius": 50, + "skip_existing_chunks": false, + "type": "minecraft:exploration_map" + } + } + ] + }, + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/reload.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/reload.json new file mode 100644 index 000000000..ddfca0b67 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/reload.json @@ -0,0 +1,14 @@ +{ + "type": "minecraft:chest", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "minecraft:diamond" + } + ] + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/blocked.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/blocked.json new file mode 100644 index 000000000..99809b671 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/blocked.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:buried_treasure" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/disabled.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/disabled.json new file mode 100644 index 000000000..977f1c380 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/disabled.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:mansion" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/found.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/found.json new file mode 100644 index 000000000..5476244c9 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/found.json @@ -0,0 +1,5 @@ +{ + "values": [ + "minecraft:desert_pyramid" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/mixed.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/mixed.json new file mode 100644 index 000000000..a35886d36 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/mixed.json @@ -0,0 +1,6 @@ +{ + "values": [ + "minecraft:buried_treasure", + "minecraft:desert_pyramid" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/pack.mcmeta b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/pack.mcmeta new file mode 100644 index 000000000..3dc9e5297 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/pack.mcmeta @@ -0,0 +1,13 @@ +{ + "pack": { + "description": "Treasure map gameplay fixtures", + "min_format": [ + 121, + 0 + ], + "max_format": [ + 121, + 0 + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/biomes/flat.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/biomes/flat.json new file mode 100644 index 000000000..ba493aa72 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-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/treasure-map-pack/dimensions/treasure-map-fixture.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/dimensions/treasure-map-fixture.json new file mode 100644 index 000000000..227c11566 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/dimensions/treasure-map-fixture.json @@ -0,0 +1,64 @@ +{ + "name": "Treasure Map 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:" + ] + }, + "structures": [ + { + "placementId": "blocked-treasure", + "nativeStructures": [ + { + "structure": "minecraft:buried_treasure" + } + ], + "distribution": "RANDOM_SPREAD", + "spacing": 1, + "separation": 0, + "minHeight": 0, + "maxHeight": 0 + }, + { + "placementId": "reachable-pyramid", + "nativeStructures": [ + { + "structure": "minecraft:desert_pyramid" + } + ], + "distribution": "RANDOM_SPREAD", + "spacing": 16, + "separation": 4 + } + ], + "loot": { + "tables": [ + "fixture-diamond" + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/generators/flat.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/generators/flat.json new file mode 100644 index 000000000..7cd98b811 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-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/treasure-map-pack/loot/fixture-diamond.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/loot/fixture-diamond.json new file mode 100644 index 000000000..878929f8d --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/loot/fixture-diamond.json @@ -0,0 +1,15 @@ +{ + "name": "Fixture Diamond", + "rarity": 1, + "minPicked": 1, + "maxPicked": 1, + "maxTries": 1, + "loot": [ + { + "type": "diamond", + "rarity": 1, + "minAmount": 1, + "maxAmount": 1 + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/regions/flat.json b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/regions/flat.json new file mode 100644 index 000000000..d6fff925f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-map-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/treasure-maps.mjs b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs new file mode 100644 index 000000000..a31dab2e9 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs @@ -0,0 +1,168 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +const simplifyNbt = value => { + if (Array.isArray(value)) return value.map(simplifyNbt) + if (value === null || typeof value !== 'object') return value + if ('type' in value && 'value' in value) return simplifyNbt(value.value) + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, simplifyNbt(entry)])) +} + +export default { + name: 'iris-treasure-maps', + description: 'Verify exploration-map targets, bounded misses, retained loot, and normal/copper storage on Iris.', + async run(context) { + context.bot.physicsEnabled = false + context.bot.clearControlStates() + context.bot.entity.velocity.set(0, 0, 0) + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Treasure map fixtures require an isolated instance') + const command = async (operation, expected, timeoutMs = 30000) => { + const token = expected.split(' ')[0] + const response = await context.command(`/irislifecycle ${operation}`, + new RegExp(`${token}|NATIVE_FAILED|TREASURE_.*_FAILED`), timeoutMs) + context.expect(response.includes(expected), response) + return response + } + const openChest = async material => { + const position = context.bot.entity.position.clone().set(2, 161, 2) + await context.waitUntil(() => context.bot.blockAt(position)?.name === material, { + label: `${material} reached the client`, timeoutMs: 10000 + }) + const opened = context.waitForEvent('windowOpen', undefined, 120000) + await context.bot.activateBlock(context.bot.blockAt(position)) + const [window] = await opened + return window + } + const chestItems = window => window.slots.slice(0, window.inventoryStart).filter(Boolean) + const mapId = item => item.componentMap?.get('map_id')?.data + let missItem + await context.step('create and enter the Iris treasure map world', async () => { + const response = await command('treasure-map-create', 'TREASURE_MAP_READY world=iris:treasure_map_probe', 180000) + const version = response.match(/version=(\S+)/)?.[1] + context.expect(/^26\.[23](?:[.-]|$)/.test(version ?? ''), 'Fixture requires its matching 26.2 or 26.3 data pack', { version }) + missItem = version.startsWith('26.2') ? 'map' : 'filled_map' + context.report.treasurePlatform = { version, folia: response.includes('folia=true'), missItem } + await context.waitUntil(() => context.bot.entity.position.distanceTo( + context.bot.entity.position.clone().set(0.5, 161, 0.5) + ) < 2, { label: 'Fixture teleport reached the client', timeoutMs: 30000 }) + }) + await context.step('confirm the missing target exhausts the placement search budget', async () => { + await command('treasure-map-limit', 'TREASURE_MAP_LIMIT status=SEARCH_LIMIT_REACHED', 120000) + }) + for (const material of ['chest', 'copper_chest']) { + for (const destination of ['blocked', 'disabled', 'found', 'mixed']) { + await context.step(`open ${material} with ${destination} map target and retain all loot`, async () => { + const located = destination === 'found' || destination === 'mixed' + const mapName = located ? 'filled_map' : missItem + await command(`treasure-map-chest ${destination} ${material.toUpperCase()}`, + `TREASURE_MAP_CHEST destination=${destination} material=${material.toUpperCase()}`) + const window = await openChest(material) + let targetId + let diamondType + let diamondsBefore + let marker + let verified + try { + await context.waitUntil(() => { + const items = chestItems(window) + return items.some(item => item.name === mapName) && items.some(item => item.name === 'diamond') + }, { label: `${destination} chest contains map and diamond`, timeoutMs: 30000 }) + const items = chestItems(window) + const map = items.find(item => item.name === mapName) + const diamond = items.find(item => item.name === 'diamond') + targetId = mapId(map) + context.expect(items.length === 2 && map.count === 1 && diamond.count === 1, + 'Loot table must produce exactly one map and one diamond') + context.expect((targetId !== undefined) === located, 'Only reachable destinations receive map data', { + destination, components: map.components + }) + const response = await command(`treasure-map-inspect ${destination}`, + `TREASURE_MAP_INSPECT destination=${destination} diamonds=1 located=${located}`, 120000) + verified = Object.fromEntries([...response.matchAll(/(mapId|targetX|targetZ|centerX|centerZ)=(-?\d+)/g)] + .map(([, key, value]) => [key, Number(value)])) + const decorations = simplifyNbt(map.componentMap?.get('map_decorations')?.data) + marker = decorations?.['+'] + if (located) { + context.expect(targetId === verified.mapId, 'Client and saved map IDs agree', { targetId, verified }) + context.expect(marker?.type === 'minecraft:red_x' + && marker.x === verified.targetX && marker.z === verified.targetZ, + 'Map marker points to the verified pyramid', { marker, verified, components: map.components }) + } else { + context.expect(marker === undefined, 'Missing destinations must not invent a map marker', { decorations }) + } + diamondType = diamond.type + diamondsBefore = context.bot.inventory.count(diamondType) + await context.bot.clickWindow(diamond.slot, 0, 1) + await context.waitUntil(() => chestItems(window).every(item => item.name !== 'diamond'), { + label: 'Diamond transferred out of the chest', timeoutMs: 10000 + }) + } finally { + context.bot.closeWindow(window) + } + const reopened = await openChest(material) + try { + await context.waitUntil(() => chestItems(reopened).some(item => item.name === mapName), { + label: 'Saved map remains in the reopened chest', timeoutMs: 10000 + }) + const retained = chestItems(reopened) + context.expect(retained.length === 1 && retained[0].name === mapName && retained[0].count === 1, + 'Reopening must not regenerate chest loot') + context.expect(mapId(retained[0]) === targetId, 'Reopening must retain the same map data') + context.expect(JSON.stringify(simplifyNbt(retained[0].componentMap?.get('map_decorations')?.data)?.['+']) + === JSON.stringify(marker), 'Reopening must retain the same destination marker') + await context.waitUntil(() => context.bot.inventory.count(diamondType) === diamondsBefore + 1, { + label: 'Chest awards exactly one diamond', timeoutMs: 10000 + }) + context.report.treasureMaps ??= [] + context.report.treasureMaps.push({ destination, material, mapItem: mapName, + mapId: targetId ?? null, marker: marker ?? null, verified, reopened: true }) + } finally { + context.bot.closeWindow(reopened) + } + }) + } + } + await context.step('verify storage variants, insertion rollback, and locate event results', async () => { + await command('treasure-extra-check', 'TREASURE_EXTRA_CHECK variants=9 rollback=true locateEvents=true', 120000) + }) + await context.step('verify plugin loot runs on its owning region', async () => { + await command('treasure-region-check', 'TREASURE_REGION_LOOT copper=8 normal=1 events=9 owned=true', 120000) + }) + await context.step('reload a data pack without reusing the previous native loot table', async () => { + const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8') + const levelName = properties.match(/^level-name=(.+)$/m)?.[1].trim() ?? 'world' + const tablePath = path.join(context.server.directory, levelName, 'datapacks', 'iris_treasure', + 'data', 'iris_qa', 'loot_table', 'reload.json') + const original = await readFile(tablePath, 'utf8') + const replacement = JSON.parse(original) + context.expect(replacement.pools[0].entries[0].name === 'minecraft:diamond', + 'Reload fixture must start with its diamond table') + const revision = async () => { + const response = await command('treasure-resources', 'TREASURE_RESOURCES revision=') + return Number(response.match(/revision=(\d+)/)?.[1]) + } + const reload = async () => { + const before = await revision() + await context.command('/minecraft:reload', /Reloading/i, 30000) + await context.waitUntil(async () => await revision() > before, { + label: 'Server resource reload completed', timeoutMs: 120000, intervalMs: 500 + }) + } + await command('treasure-reload-before', 'TREASURE_RELOAD material=DIAMOND') + replacement.pools[0].entries[0].name = 'minecraft:emerald' + try { + await writeFile(tablePath, `${JSON.stringify(replacement, null, 2)}\n`) + await reload() + await command('treasure-reload-after', 'TREASURE_RELOAD material=EMERALD') + context.report.treasureReload = { before: 'diamond', after: 'emerald' } + } finally { + await writeFile(tablePath, original) + if (!context.signal.aborted) { + await reload() + await command('treasure-reload-before', 'TREASURE_RELOAD material=DIAMOND') + } + } + }) + } +}