From 56178b0e0a0e3026e30fddd73efe668707277669 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 13 Oct 2026 16:39:39 -0400 Subject: [PATCH 1/7] Fixed loot materialization - Container loot finishes on its owning thread before pending markers clear - Failed fills restore container contents and deferred native loot state - Failed materialization remains pending without refilling completed containers - Vanilla tables resolve from the current registry after reload - Studio loot previews follow the player scheduler - Tests cover loot rollback, retries, cancellation, and container classification --- .../arcane/iris/command/CommandStudio.java | 39 +- .../service/ModdedChunkUpdateService.java | 12 +- .../ModdedBlockResolutionContractTest.java | 39 ++ .../iris/modded/ModdedLootApplierTest.java | 203 +++++++- .../service/ModdedChunkUpdateServiceTest.java | 44 +- .../runtime/WorldChunkMaintenance.java | 11 +- .../platform/generation/EngineBukkitOps.java | 147 +++--- .../structure/object/WorldObjectPlacer.java | 6 +- .../iris/world/event/IrisLootEvent.java | 40 +- .../iris/world/loot/IrisVanillaLootTable.java | 11 +- .../WorldChunkMaintenanceRoutingTest.java | 5 + .../BukkitStorageClassificationTest.java | 54 +++ .../generation/EngineBukkitOpsLootTest.java | 443 ++++++++++++++---- .../world/loot/IrisVanillaLootTableTest.java | 76 +++ 14 files changed, 875 insertions(+), 255 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/platform/bukkit/BukkitStorageClassificationTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/loot/IrisVanillaLootTableTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java index 493df97fb..f5074e0c7 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java @@ -81,7 +81,6 @@ import org.bukkit.FluidCollisionMode; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.entity.Player; -import org.bukkit.event.inventory.InventoryType; import org.bukkit.inventory.Inventory; import org.bukkit.util.Vector; @@ -309,23 +308,33 @@ public class CommandStudio implements DirectorExecutor { O ta = new O<>(); ta.set(-1); - var sender = sender(); - var player = player(); - var engine = engine(); + VolmitSender sender = sender(); + Player player = player(); + Engine engine = engine(); - ta.set(J.sr(() -> - { - if (!player.getOpenInventory().getType().equals(InventoryType.CHEST)) { + ta.set(J.sr(() -> { + if (!J.runEntity(player, () -> { + if (player.getOpenInventory().getTopInventory() != inv) { + J.csr(ta.get()); + sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_OPENED_INVENTORY)); + return; + } + if (!add) { + inv.clear(); + } + Location location = player.getLocation(); + try { + EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE, + location.getWorld(), location.getBlockX(), location.getBlockY(), location.getBlockZ()); + } catch (RuntimeException | Error failure) { + J.csr(ta.get()); + Iris.reportError(failure); + sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CANNOT_ADD_ITEMS_VIRTUAL_INVENTORY_BECAUSE, + MessageArgument.untrusted("value", String.valueOf(failure.getMessage())))); + } + }, 0, () -> J.csr(ta.get()))) { J.csr(ta.get()); - sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_OPENED_INVENTORY)); - return; } - - if (!add) { - inv.clear(); - } - - EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE, player.getWorld(), player.getLocation().getBlockX(), player.getLocation().getBlockY(), player.getLocation().getBlockZ()); }, fast ? 5 : 35)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_INVENTORY_NOW)); 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 5fe28ac2d..13356e977 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 @@ -30,7 +30,6 @@ import art.arcane.iris.studio.view.PregeneratorJob; import art.arcane.iris.integration.Identifier; import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.generation.runtime.Engine; -import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedBlockResolution; @@ -382,16 +381,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { if (!state.isStorageChest()) { return; } - try { - ModdedLootApplier.apply(engine, level, pos, state, chunk); - } catch (SavedBiomeUnavailableException unavailable) { - GenerationFailures.rethrowEngineFailure(unavailable); - IrisLogging.warnOnce("chunk-update-loot:" + level.name() + ":" + unavailable.getMessage(), - "Iris left the container at " + pos + " in " + level.name() + " without loot: " + unavailable.getMessage()); - } catch (Throwable e) { - GenerationFailures.rethrowEngineFailure(e); - IrisLogging.reportError(e); - } + ModdedLootApplier.apply(engine, level, pos, state, chunk); } else { NativeWorldMaintenance.refresh(level, pos); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java index f096efb51..81dd2a00d 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java @@ -8,9 +8,12 @@ import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.hunk.Hunk; import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; import net.minecraft.server.Bootstrap; +import net.minecraft.world.RandomizableContainer; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.EntityBlock; import net.minecraft.world.level.block.SpeleothemBlock; import net.minecraft.world.level.block.state.properties.SpeleothemThickness; import org.junit.BeforeClass; @@ -62,6 +65,42 @@ public class ModdedBlockResolutionContractTest { assertEquals(Blocks.OAK_LOG, state.handle().getBlock()); } + @Test + public void copperChestVariantsReceiveIrisLootAndPostLoadUpdates() { + for (String key : new String[]{"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"}) { + ModdedBlockState state = ModdedBlockResolution.BLOCKS.getOrNull("minecraft:" + key); + assertNotNull(key, state); + assertTrue(key, state.isStorage()); + assertTrue(key, state.isStorageChest()); + assertTrue(key, NativeBlockProperties.isUpdatable(state.handle())); + assertTrue(key, ((EntityBlock) state.handle().getBlock()).newBlockEntity(BlockPos.ZERO, state.handle()) + instanceof RandomizableContainer); + } + } + + @Test + public void lootContainerClassificationPreservesExistingContainerBoundaries() { + for (String key : new String[]{"chest", "trapped_chest", "barrel", "shulker_box", + "blue_shulker_box", "dispenser", "dropper", "hopper"}) { + ModdedBlockState state = ModdedBlockResolution.BLOCKS.getOrNull("minecraft:" + key); + assertNotNull(key, state); + assertTrue(key, state.isStorage()); + assertTrue(key, state.isStorageChest()); + } + for (Block block : new Block[]{Blocks.FURNACE, Blocks.BLAST_FURNACE, Blocks.SMOKER}) { + assertTrue(block.toString(), NativeBlockProperties.isStorage(block.defaultBlockState())); + assertFalse(block.toString(), NativeBlockProperties.isStorageChest(block.defaultBlockState())); + } + for (String key : new String[]{"stone", "air", "ender_chest", "copper_block"}) { + ModdedBlockState state = ModdedBlockResolution.BLOCKS.getOrNull("minecraft:" + key); + assertNotNull(key, state); + assertFalse(key, state.isStorage()); + assertFalse(key, state.isStorageChest()); + } + } + @Test public void cactusPlacementRequiresNativeCactusSupport() { assertTrue(NativeBlockProperties.canPlaceOnto(Blocks.CACTUS, Blocks.CACTUS)); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java index 2ceb3d812..261e935ab 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java @@ -1,33 +1,64 @@ package art.arcane.iris.modded; -import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeContainerLoot; - import art.arcane.iris.structure.placement.LootResolver; import art.arcane.iris.world.loot.IrisLootMode; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeContainerLoot; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeItemStack; +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.math.RNG; -import net.minecraft.resources.Identifier; -import net.minecraft.resources.ResourceKey; -import net.minecraft.core.BlockPos; -import net.minecraft.core.registries.Registries; -import net.minecraft.world.RandomizableContainer; -import net.minecraft.world.SimpleContainer; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.storage.loot.LootTable; -import org.junit.Test; - +import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.List; import java.util.Set; -import java.lang.reflect.Proxy; import java.util.concurrent.atomic.AtomicBoolean; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponentMap; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.CompoundContainer; +import net.minecraft.world.RandomizableContainer; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.ChestType; +import net.minecraft.world.level.storage.loot.LootTable; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; public class ModdedLootApplierTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + if (!Items.DIAMOND.builtInRegistryHolder().areComponentsBound()) { + Items.DIAMOND.builtInRegistryHolder().bindComponents(DataComponentMap.EMPTY); + } + } + @Test public void addAppendsSourcesInOrder() { List sources = new ArrayList<>(List.of("placement-native")); @@ -171,6 +202,152 @@ public class ModdedLootApplierTest { assertTrue(NativeContainerLoot.hasNativeLootTable(nativeLoot)); } + @Test + public void failedFillRestoresContentsAndDoesNotDuplicateLootOnRetry() { + RejectingContainer container = new RejectingContainer(); + container.setItem(0, diamondStack(2)); + assertEquals(2, container.getItem(0).getCount()); + container.reject = true; + ItemStack reward = diamondStack(7); + + assertThrows(IllegalStateException.class, + () -> NativeContainerLoot.fillContainer(container, List.of(reward), new RNG(17), message -> {})); + + assertEquals(2, container.getItem(0).getCount()); + assertTrue(container.getItem(1).isEmpty()); + assertEquals(7, reward.getCount()); + NativeContainerLoot.fillContainer(container, List.of(reward), new RNG(17), message -> {}); + assertEquals(9, container.getItem(0).getCount() + container.getItem(1).getCount()); + assertEquals(7, reward.getCount()); + } + + @Test + public void failedNativeUnpackRestoresTheLootTableSeedAndPriorContents() { + DeferredLootChest container = new DeferredLootChest(BlockPos.ZERO, Blocks.CHEST.defaultBlockState()); + container.setItem(0, diamondStack(2)); + container.reject = true; + ResourceKey table = ResourceKey.create(Registries.LOOT_TABLE, Identifier.parse("minecraft:chests/simple_dungeon")); + container.setLootTable(table, 41L); + + assertThrows(IllegalStateException.class, () -> NativeContainerLoot.fillContainer(container, + List.of(new ItemStack(Items.DIAMOND)), new RNG(17), message -> {})); + + assertSame(table, container.getLootTable()); + assertEquals(41L, container.getLootTableSeed()); + container.setLootTable(null); + assertEquals(2, container.getItem(0).getCount()); + for (int slot = 1; slot < container.getContainerSize(); slot++) { + assertTrue(container.getItem(slot).isEmpty()); + } + } + + @Test + public void failedDoubleChestFillRestoresBothDeferredHalvesBeforeRetry() { + BlockPos leftPos = new BlockPos(0, 64, 0); + BlockPos rightPos = new BlockPos(1, 64, 0); + BlockState leftState = Blocks.CHEST.defaultBlockState().setValue(ChestBlock.TYPE, ChestType.LEFT); + BlockState rightState = Blocks.CHEST.defaultBlockState().setValue(ChestBlock.TYPE, ChestType.RIGHT); + DeferredLootChest left = new DeferredLootChest(leftPos, leftState); + DeferredLootChest right = new DeferredLootChest(rightPos, rightState); + left.setItem(0, diamondStack(2)); + right.setItem(0, diamondStack(3)); + right.reject = true; + ResourceKey table = ResourceKey.create(Registries.LOOT_TABLE, Identifier.parse("minecraft:chests/simple_dungeon")); + left.setLootTable(table, 41L); + right.setLootTable(table, 42L); + ServerLevel level = mock(ServerLevel.class); + NativeWorld world = mock(NativeWorld.class); + NativeBlockState state = mock(NativeBlockState.class); + when(world.nativeHandle()).thenReturn(level); + when(state.nativeHandle()).thenReturn(leftState); + when(level.getBlockEntity(leftPos)).thenReturn(left); + when(level.getBlockEntity(rightPos)).thenReturn(right); + CompoundContainer inventory = new CompoundContainer(left, right); + ItemStack reward = diamondStack(7); + try (MockedStatic chests = mockStatic(ChestBlock.class)) { + chests.when(() -> ChestBlock.getContainer((ChestBlock) Blocks.CHEST, leftState, level, leftPos, true)) + .thenReturn(inventory); + chests.when(() -> ChestBlock.getConnectedBlockPos(leftPos, leftState)).thenReturn(rightPos); + NativeContainerLoot access = new NativeContainerLoot(new NativeContainerLoot.Target( + world, new NativeBlockPoint(0, 64, 0), state)); + NativeContainerLoot.Session session = access.open(); + assertNotNull(session); + session.add(List.of(NativeItemStack.of(reward))); + + assertThrows(IllegalStateException.class, () -> session.fill(new RNG(17), message -> {})); + + assertSame(table, left.getLootTable()); + assertSame(table, right.getLootTable()); + assertEquals(41L, left.getLootTableSeed()); + assertEquals(42L, right.getLootTableSeed()); + left.setLootTable(null); + right.setLootTable(null); + assertEquals(2, countItems(left)); + assertEquals(3, countItems(right)); + assertEquals(7, reward.getCount()); + left.setLootTable(table, 41L); + right.setLootTable(table, 42L); + right.reject = false; + + session.fill(new RNG(17), message -> {}); + + assertNull(left.getLootTable()); + assertNull(right.getLootTable()); + assertEquals(15, countItems(left) + countItems(right)); + assertEquals(7, reward.getCount()); + } + } + + private static int countItems(ChestBlockEntity container) { + int total = 0; + for (int slot = 0; slot < container.getContainerSize(); slot++) { + total += container.getItem(slot).getCount(); + } + return total; + } + + private static ItemStack diamondStack(int count) { + ItemStack stack = new ItemStack(Items.DIAMOND, count); + stack.set(DataComponents.MAX_STACK_SIZE, 64); + return stack; + } + + private static final class RejectingContainer extends SimpleContainer { + private boolean reject; + + private RejectingContainer() { + super(2); + } + + @Override + public void setItem(int slot, ItemStack stack) { + super.setItem(slot, stack); + if (reject) { + reject = false; + throw new IllegalStateException("Inventory write failed"); + } + } + } + + private static final class DeferredLootChest extends ChestBlockEntity { + private boolean reject; + + private DeferredLootChest(BlockPos position, BlockState state) { + super(position, state); + } + + @Override + public void unpackLootTable(Player player) { + if (getLootTable() != null) { + setLootTable(null); + setItem(0, diamondStack(4)); + if (reject) { + throw new IllegalStateException("Native loot failed"); + } + } + } + } + private RandomizableContainer containerWithLootTable(ResourceKey key) { return (RandomizableContainer) Proxy.newProxyInstance( RandomizableContainer.class.getClassLoader(), 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 dfc35ba19..3993e2a6a 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 @@ -57,7 +57,6 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; @@ -171,7 +170,7 @@ public class ModdedChunkUpdateServiceTest { } @Test - public void permanentSavedBiomeUnavailabilityCompletesThePassWithAWarning() { + public void permanentSavedBiomeUnavailabilityRetainsPendingLoot() { ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk(); Engine engine = updateEngine(chunk); NativeWorld level = chestLevel(); @@ -187,13 +186,46 @@ public class ModdedChunkUpdateServiceTest { return null; }); - new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, 0, 0); + assertThrows(SavedBiomeUnavailableException.class, + () -> new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, 0, 0)); + + assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); + assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); + assertFalse(chunk.deleted()); + verify(chunk.slice, never()).set(2, 5, 2, null); + + } + } + + @Test + public void ordinaryLootFailureRetainsPendingWorkAndRetries() { + ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk(); + Engine engine = updateEngine(chunk); + NativeWorld level = chestLevel(); + AtomicInteger attempts = new AtomicInteger(); + ModdedChunkUpdateService service = new ModdedChunkUpdateService(); + IllegalStateException failure = new IllegalStateException("Loot provider unavailable"); + try (MockedStatic loot = mockStatic(ModdedLootApplier.class)) { + loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer(invocation -> { + NativeBlockPoint position = invocation.getArgument(2); + if (position.x() == 2 && attempts.getAndIncrement() == 0) { + throw failure; + } + return null; + }); + + assertSame(failure, assertThrows(IllegalStateException.class, + () -> service.updateRegeneratedChunk(engine, level, 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); assertTrue(chunk.isFlagged(MantleFlag.ETCHED)); assertTrue(chunk.deleted()); - logging.verify(() -> IrisLogging.warnOnce(anyString(), contains(reason))); - logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never()); - logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never()); + assertEquals(2, attempts.get()); } } 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 4f6b6d082..688e45074 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 @@ -289,6 +289,8 @@ final class WorldChunkMaintenance { boolean scheduled = J.runRegion(world, chunkX, chunkZ, manager.managedTask("bukkit_world_manager_chunk_update", () -> { try { updateChunkRegion(world, chunkX, chunkZ); + } catch (RuntimeException | Error failure) { + reportMaterializationFailure(new MaterializationTarget(world, chunkX, chunkZ), failure); } finally { chunkUpdateQueue.remove(key); } @@ -508,6 +510,13 @@ final class WorldChunkMaintenance { return loadedChunkPositions; } + private void reportMaterializationFailure(MaterializationTarget target, Throwable failure) { + String context = "Failed to materialize chunk " + target.x() + "," + target.z() + " in " + target.world().getName() + "."; + if (IrisLogging.errorOnce("chunk-materialization:" + target.world().getUID() + ":" + target.x() + "," + target.z() + ":" + failure, context)) { + IrisLogging.reportError(context, failure); + } + } + private record MaterializationTarget(World world, int x, int z) { private long key() { return Cache.key(x, z); @@ -614,7 +623,7 @@ final class WorldChunkMaintenance { } spawnInitially(chunk); } catch (Throwable failure) { - IrisLogging.reportError("Failed to materialize saved chunk at " + target.x() + "," + target.z() + ".", failure); + reportMaterializationFailure(target, failure); } finally { synchronized (this) { applying = false; 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 fddf19988..6a1bb99ce 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 @@ -20,7 +20,6 @@ package art.arcane.iris.platform.generation; import art.arcane.iris.world.history.GenerationFindCatalog; -import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.world.event.IrisLootEvent; import art.arcane.iris.integration.Identifier; import art.arcane.iris.integration.ExternalDataSVC; @@ -73,6 +72,7 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.BlockState; +import org.bukkit.block.DoubleChest; import org.bukkit.block.data.BlockData; import org.bukkit.block.data.type.Chest; import org.bukkit.entity.Player; @@ -82,6 +82,8 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.loot.Lootable; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; @@ -89,9 +91,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicLong; -import java.util.function.BooleanSupplier; import java.util.function.Consumer; -import java.util.function.Predicate; import art.arcane.iris.localization.BukkitRuntimeMessages; import art.arcane.iris.localization.IrisLanguage; @@ -124,14 +124,7 @@ public final class EngineBukkitOps { } if (!J.isFolia() && !J.isPrimaryThread()) { - CompletableFuture scheduled = J.sfut(() -> updateChunk(engine, c, mantle)); - if (scheduled != null) { - try { - scheduled.join(); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } + awaitMaterialization(J.sfut(() -> updateChunk(engine, c, mantle))); return; } @@ -303,21 +296,25 @@ public final class EngineBukkitOps { }); if (!scheduled) { - if (J.isPrimaryThread()) { - runnable.run(); - } - return; + throw new IllegalStateException("Failed to schedule chunk materialization for " + + contextChunk.getWorld().getName() + " at chunk " + contextChunk.getX() + "," + contextChunk.getZ() + "."); } + awaitMaterialization(completion); + } + + private static void awaitMaterialization(CompletableFuture completion) { try { completion.join(); } catch (CompletionException error) { Throwable cause = error.getCause(); - IrisLogging.reportError( - "Failed deferred chunk materialization for " + contextChunk.getWorld().getName() - + " at chunk " + contextChunk.getX() + "," + contextChunk.getZ() + ".", - cause == null ? error : cause - ); + if (cause instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (cause instanceof Error fatalFailure) { + throw fatalFailure; + } + throw error; } } @@ -351,8 +348,12 @@ public final class EngineBukkitOps { InventoryHolder m = (InventoryHolder) block.getState(); addItems(engine, false, m.getInventory(), tables, slot, c.getWorld(), worldX, y, worldZ); - } catch (Throwable e) { - IrisLogging.reportError(e); + } catch (RuntimeException | Error failure) { + if (IrisLogging.errorOnce("container-loot:" + world.getUID() + ":" + worldX + "," + y + "," + worldZ + ":" + failure, + "Failed to populate loot at " + world.getName() + " [" + worldX + "," + y + "," + worldZ + "].")) { + IrisLogging.reportError(failure); + } + throw failure; } } } else { @@ -515,6 +516,10 @@ public final class EngineBukkitOps { } public static void addItems(Engine engine, boolean debug, Inventory inv, KList tables, InventorySlotType slot, World world, int x, int y, int z) { + if (!J.isOwnedByCurrentRegion(world, x >> 4, z >> 4)) { + throw new IllegalStateException("Loot inventory mutation requires the owning region at " + + world.getName() + " [" + x + "," + y + "," + z + "]."); + } KList items = new KList<>(); for (IrisLootTable i : tables) { @@ -525,57 +530,69 @@ public final class EngineBukkitOps { if (IrisLootEvent.callLootEvent(items, inv, world, x, y, z)) return; - if (PaperLib.isPaper() && engine.getWorld().hasPlatformWorld()) { - PaperLib.getChunkAtAsync(BukkitWorldBinding.world(engine.getWorld()), x >> 4, z >> 4).thenAccept((c) -> { - Runnable mutation = () -> { - for (ItemStack i : items) { - inv.addItem(i); - } + populateInventory(engine, inv, items, LootResolver.containerRng(engine.getSeedManager().getLoot(), x, y, z)); + } - scramble(engine, inv, LootResolver.containerRng(engine.getSeedManager().getLoot(), x, y, z)); - }; - - LootMutationTask task = new LootMutationTask(world, x >> 4, z >> 4, mutation); - boolean scheduled = dispatchLootMutation( - task, - J.isFolia(), - Bukkit::isPrimaryThread, - regionTask -> J.runRegion( - regionTask.world(), - regionTask.chunkX(), - regionTask.chunkZ(), - regionTask.mutation()), - J::s - ); - if (!scheduled) { - IrisLogging.error("Failed to schedule loot inventory mutation for " + world.getName() + "@" + (x >> 4) + "," + (z >> 4) + "."); - } - }); + static void populateInventory(Engine engine, Inventory inv, List items, RNG rng) { + List states = new ArrayList<>(2); + InventoryHolder holder = inv.getHolder(); + if (holder instanceof DoubleChest chest) { + snapshotContainer(states, chest.getLeftSide()); + snapshotContainer(states, chest.getRightSide()); } else { - for (ItemStack i : items) { - inv.addItem(i); + snapshotContainer(states, holder); + } + ItemStack[] contents = null; + try { + if (states.isEmpty()) { + contents = copyContents(inv.getContents()); } - - scramble(engine, inv, LootResolver.containerRng(engine.getSeedManager().getLoot(), x, y, z)); + for (ItemStack i : items) { + if (i != null) { + inv.addItem(i.clone()); + } + } + scramble(engine, inv, rng); + } catch (RuntimeException | Error failure) { + if (states.isEmpty()) { + if (contents != null) { + try { + inv.setContents(contents); + } catch (RuntimeException | Error rollbackFailure) { + if (rollbackFailure != failure) { + failure.addSuppressed(rollbackFailure); + } + } + } + } else { + for (BlockState state : states) { + try { + if (!state.update(true, false)) { + throw new IllegalStateException("Failed to restore loot container at " + state.getLocation()); + } + } catch (RuntimeException | Error rollbackFailure) { + if (rollbackFailure != failure) { + failure.addSuppressed(rollbackFailure); + } + } + } + } + throw failure; } } - static boolean dispatchLootMutation( - LootMutationTask task, - boolean folia, - BooleanSupplier primaryThread, - Predicate regionScheduler, - Consumer syncScheduler) { - if (folia) { - return regionScheduler.test(task); + private static void snapshotContainer(List snapshots, InventoryHolder holder) { + if (holder instanceof BlockState state) { + snapshots.add(state.getBlock().getState()); } + } - if (primaryThread.getAsBoolean()) { - task.mutation().run(); - } else { - syncScheduler.accept(task.mutation()); + private static ItemStack[] copyContents(ItemStack[] contents) { + ItemStack[] copy = new ItemStack[contents.length]; + for (int index = 0; index < contents.length; index++) { + copy[index] = contents[index] == null ? null : contents[index].clone(); } - return true; + return copy; } public static IrisBiome getBiome(Engine engine, Location l) { @@ -838,6 +855,4 @@ public final class EngineBukkitOps { } } - record LootMutationTask(World world, int chunkX, int chunkZ, Runnable mutation) { - } } diff --git a/core/src/main/java/art/arcane/iris/structure/object/WorldObjectPlacer.java b/core/src/main/java/art/arcane/iris/structure/object/WorldObjectPlacer.java index e48f158f1..11a6eb55f 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/WorldObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/structure/object/WorldObjectPlacer.java @@ -93,8 +93,10 @@ public class WorldObjectPlacer implements IObjectPlacer { } InventoryHolder holder = (InventoryHolder) block.getState(); EngineBukkitOps.addItems(engine, false, holder.getInventory(), tables, InventorySlotType.STORAGE, world, x, y, z); - } catch (Throwable e) { - IrisLogging.reportError(e); + } catch (RuntimeException | Error failure) { + IrisLogging.reportError("Failed to populate placed container at " + world.getName() + + " [" + x + "," + y + "," + z + "].", failure); + throw failure; } } diff --git a/core/src/main/java/art/arcane/iris/world/event/IrisLootEvent.java b/core/src/main/java/art/arcane/iris/world/event/IrisLootEvent.java index e3acddfdf..4c89ddf8b 100644 --- a/core/src/main/java/art/arcane/iris/world/event/IrisLootEvent.java +++ b/core/src/main/java/art/arcane/iris/world/event/IrisLootEvent.java @@ -4,7 +4,6 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.world.loot.InventorySlotType; import art.arcane.iris.world.loot.IrisLootTable; import art.arcane.iris.platform.bukkit.BukkitPlatform; -import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.world.task.J; import lombok.Getter; @@ -92,6 +91,10 @@ public class IrisLootEvent extends Event { * @return true when the event was canceled */ public static boolean callLootEvent(KList loot, Inventory inv, World world, int x, int y, int z) { + if (!J.isOwnedByCurrentRegion(world, x >> 4, z >> 4)) { + throw new IllegalStateException("LootGenerateEvent requires the owning region at " + + world.getName() + " [" + x + "," + y + "," + z + "]."); + } InventoryHolder holder = inv.getHolder(); Location loc = new Location(world, x, y, z); if (holder == null) { @@ -106,39 +109,8 @@ public class IrisLootEvent extends Event { LootContext context = new LootContext.Builder(loc).build(); LootGenerateEvent event = new LootGenerateEvent(world, null, holder, EMPTY, context, loot, true); - if (!Bukkit.isPrimaryThread()) { - IrisLogging.warn("LootGenerateEvent was not called on the main thread, please report this issue."); - Thread.dumpStack(); - J.sfut(() -> { - try { - Bukkit.getPluginManager().callEvent(event); - } catch (Throwable e) { - IrisLogging.reportError("LootGenerateEvent dispatch failed at " - + world.getName() + " [" + x + "," + y + "," + z + "].", e); - if (e instanceof RuntimeException runtimeException) { - throw runtimeException; - } - if (e instanceof Error error) { - throw error; - } - throw new IllegalStateException(e); - } - }).join(); - } else { - try { - Bukkit.getPluginManager().callEvent(event); - } catch (Throwable e) { - IrisLogging.reportError("LootGenerateEvent dispatch failed at " - + world.getName() + " [" + x + "," + y + "," + z + "].", e); - if (e instanceof RuntimeException runtimeException) { - throw runtimeException; - } - if (e instanceof Error error) { - throw error; - } - throw new IllegalStateException(e); - } - } + + Bukkit.getPluginManager().callEvent(event); return event.isCancelled(); } diff --git a/core/src/main/java/art/arcane/iris/world/loot/IrisVanillaLootTable.java b/core/src/main/java/art/arcane/iris/world/loot/IrisVanillaLootTable.java index 7f8284b08..a96a9c20b 100644 --- a/core/src/main/java/art/arcane/iris/world/loot/IrisVanillaLootTable.java +++ b/core/src/main/java/art/arcane/iris/world/loot/IrisVanillaLootTable.java @@ -33,13 +33,12 @@ public class IrisVanillaLootTable extends IrisLootTable { private final String lootTableKey; /** - * The resolved table, looked up once. Every chest pull resolved it again - a NamespacedKey parse plus a registry - * lookup per container, and a dungeon room is a lot of containers. + * Cache the parsed key; resolve its table from the current registry for every container. *

* Transient for the same reason the field above is a String: it keeps the Bukkit type argument out of the Gson * field walk and out of the pack purity gate, both of which resolve generic field types for the fields they visit. */ - private final transient AtomicCache resolvedTable = new AtomicCache<>(); + private final transient AtomicCache resolvedKey = new AtomicCache<>(); @Override public String getName() { @@ -83,10 +82,8 @@ public class IrisVanillaLootTable extends IrisLootTable { } private LootTable resolveTable() { - return resolvedTable.aquire(() -> { - NamespacedKey key = NamespacedKey.fromString(lootTableKey); - return key == null ? null : Bukkit.getLootTable(key); - }); + NamespacedKey key = resolvedKey.aquire(() -> NamespacedKey.fromString(lootTableKey)); + return key == null ? null : Bukkit.getLootTable(key); } @Override diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldChunkMaintenanceRoutingTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldChunkMaintenanceRoutingTest.java index 8b4f00aaf..016a19477 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/WorldChunkMaintenanceRoutingTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldChunkMaintenanceRoutingTest.java @@ -175,6 +175,7 @@ public class WorldChunkMaintenanceRoutingTest { public void failedMaterializationRetainsRetryAndReportsTheFailure() throws Exception { try (Fixture fixture = new Fixture()) { IllegalStateException failure = new IllegalStateException("Provider placement failed"); + fixture.logging.when(() -> IrisLogging.errorOnce(anyString(), anyString())).thenReturn(true, false); fixture.updates.when(() -> EngineBukkitOps.updateChunk(fixture.engine, fixture.chunk, fixture.savedMantle)).thenThrow(failure); fixture.schedule(); fixture.prepare(); @@ -182,6 +183,10 @@ public class WorldChunkMaintenanceRoutingTest { fixture.apply(); fixture.awaitRelease(); + fixture.schedule(); + fixture.prepare(); + fixture.apply(); + fixture.awaitRelease(); fixture.logging.verify(() -> IrisLogging.reportError(anyString(), eq(failure))); fixture.schedule(); assertEquals(1, fixture.async.size()); diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitStorageClassificationTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitStorageClassificationTest.java new file mode 100644 index 000000000..59d22b974 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitStorageClassificationTest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.platform.bukkit; + +import art.arcane.volmlib.util.data.BSupport; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.data.BlockData; +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class BukkitStorageClassificationTest { + @Test + public void copperChestsAndExistingLootContainersReceiveStorageUpdates() { + BlockData air = blockData(Material.AIR); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.createBlockData(Material.AIR)).thenReturn(air); + BSupport support = new BSupport() { + }; + for (Material material : new Material[]{Material.COPPER_CHEST, Material.EXPOSED_COPPER_CHEST, + Material.WEATHERED_COPPER_CHEST, Material.OXIDIZED_COPPER_CHEST, + Material.WAXED_COPPER_CHEST, Material.WAXED_EXPOSED_COPPER_CHEST, + Material.WAXED_WEATHERED_COPPER_CHEST, Material.WAXED_OXIDIZED_COPPER_CHEST, + Material.CHEST, Material.TRAPPED_CHEST, Material.BARREL, Material.SHULKER_BOX, + Material.BLUE_SHULKER_BOX, Material.DISPENSER, Material.DROPPER, Material.HOPPER}) { + BlockData state = blockData(material); + assertTrue(material.name(), support.isStorage(state)); + assertTrue(material.name(), support.isStorageChest(state)); + assertTrue(material.name(), support.isUpdatable(state)); + } + for (Material material : new Material[]{Material.FURNACE, Material.BLAST_FURNACE, Material.SMOKER}) { + BlockData state = blockData(material); + assertTrue(material.name(), support.isStorage(state)); + assertFalse(material.name(), support.isStorageChest(state)); + } + for (Material material : new Material[]{Material.STONE, Material.AIR, Material.ENDER_CHEST, + Material.COPPER_BLOCK, Material.COPPER_CHESTPLATE}) { + BlockData state = blockData(material); + assertFalse(material.name(), support.isStorage(state)); + assertFalse(material.name(), support.isStorageChest(state)); + } + } + } + + private static BlockData blockData(Material material) { + BlockData state = mock(BlockData.class); + when(state.getMaterial()).thenReturn(material); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsLootTest.java b/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsLootTest.java index b3c331a63..9ad22498f 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsLootTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/EngineBukkitOpsLootTest.java @@ -1,20 +1,66 @@ package art.arcane.iris.platform.generation; -import org.bukkit.World; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; -import org.bukkit.block.data.type.Chest; -import org.bukkit.loot.LootTable; -import org.junit.Test; - +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EngineMetrics; +import art.arcane.iris.platform.bukkit.BukkitBlockResolution; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.testsupport.BukkitTestServer; +import art.arcane.iris.world.event.IrisLootEvent; +import art.arcane.iris.world.loot.InventorySlotType; +import art.arcane.iris.world.loot.IrisLootTable; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.function.Consumer4; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; +import art.arcane.volmlib.util.mantle.runtime.MantleHooks; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterUpdate; +import art.arcane.volmlib.util.matter.slices.UpdateMatter; +import io.papermc.paper.registry.RegistryAccess; +import java.util.HashMap; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Barrel; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockState; +import org.bukkit.block.DoubleChest; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.Chest; +import org.bukkit.event.world.LootGenerateEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.loot.LootTable; +import org.bukkit.plugin.PluginManager; +import org.junit.Test; +import org.mockito.MockedStatic; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; public class EngineBukkitOpsLootTest { @@ -46,117 +92,314 @@ public class EngineBukkitOpsLootTest { } @Test - public void foliaLootMutationUsesOwningRegionOnly() { - World world = mock(World.class); - AtomicInteger primaryChecks = new AtomicInteger(); - AtomicInteger regionSchedules = new AtomicInteger(); - AtomicInteger syncSchedules = new AtomicInteger(); - AtomicBoolean mutated = new AtomicBoolean(); - Runnable mutation = () -> mutated.set(true); - EngineBukkitOps.LootMutationTask task = new EngineBukkitOps.LootMutationTask(world, 12, -3, mutation); + public void failedInsertionRestoresExistingLootBeforeRetry() { + Inventory inventory = mock(Inventory.class); + AtomicReference contents = new AtomicReference<>(new ItemStack[]{item(2), null}); + AtomicBoolean reject = new AtomicBoolean(true); + when(inventory.getSize()).thenReturn(2); + when(inventory.getContents()).thenAnswer(invocation -> contents.get()); + doAnswer(invocation -> { + contents.set(invocation.getArgument(0)); + return null; + }).when(inventory).setContents(any(ItemStack[].class)); + doAnswer(invocation -> { + ItemStack incoming = invocation.getArgument(0); + contents.get()[0].setAmount(contents.get()[0].getAmount() + incoming.getAmount()); + if (reject.getAndSet(false)) { + throw new IllegalStateException("Insertion failed"); + } + return new HashMap(); + }).when(inventory).addItem(any(ItemStack.class)); + ItemStack reward = item(7); - boolean scheduled = EngineBukkitOps.dispatchLootMutation( - task, - true, - () -> { - primaryChecks.incrementAndGet(); - return true; - }, - regionTask -> { - regionSchedules.incrementAndGet(); - assertSame(world, regionTask.world()); - assertEquals(12, regionTask.chunkX()); - assertEquals(-3, regionTask.chunkZ()); - assertSame(mutation, regionTask.mutation()); - regionTask.mutation().run(); - return true; - }, - runnable -> syncSchedules.incrementAndGet() - ); + assertThrows(IllegalStateException.class, + () -> EngineBukkitOps.populateInventory(null, inventory, List.of(reward), new RNG(17))); - assertTrue(scheduled); - assertTrue(mutated.get()); - assertEquals(1, regionSchedules.get()); - assertEquals(0, primaryChecks.get()); - assertEquals(0, syncSchedules.get()); + assertEquals(2, contents.get()[0].getAmount()); + EngineBukkitOps.populateInventory(null, inventory, List.of(reward), new RNG(17)); + int total = 0; + for (ItemStack item : contents.get()) { + total += item == null ? 0 : item.getAmount(); + } + assertEquals(9, total); + assertEquals(7, reward.getAmount()); } @Test - public void failedFoliaRegionScheduleDoesNotMutateInventoryElsewhere() { - World world = mock(World.class); - AtomicInteger primaryChecks = new AtomicInteger(); - AtomicInteger syncSchedules = new AtomicInteger(); - AtomicBoolean mutated = new AtomicBoolean(); - EngineBukkitOps.LootMutationTask task = new EngineBukkitOps.LootMutationTask(world, 1, 2, () -> mutated.set(true)); + public void failedNativeInventoryAccessRestoresBothChestSnapshots() { + Inventory inventory = mock(Inventory.class); + DoubleChest holder = mock(DoubleChest.class); + org.bukkit.block.Chest left = mock(org.bukkit.block.Chest.class); + org.bukkit.block.Chest right = mock(org.bukkit.block.Chest.class); + Block leftBlock = mock(Block.class); + Block rightBlock = mock(Block.class); + BlockState leftSnapshot = mock(BlockState.class); + BlockState rightSnapshot = mock(BlockState.class); + when(inventory.getHolder()).thenReturn(holder); + when(holder.getLeftSide()).thenReturn(left); + when(holder.getRightSide()).thenReturn(right); + when(left.getBlock()).thenReturn(leftBlock); + when(right.getBlock()).thenReturn(rightBlock); + when(leftBlock.getState()).thenReturn(leftSnapshot); + when(rightBlock.getState()).thenReturn(rightSnapshot); + when(leftSnapshot.update(true, false)).thenReturn(true); + when(rightSnapshot.update(true, false)).thenReturn(true); + IllegalStateException failure = new IllegalStateException("Native loot failed"); + doThrow(failure).when(inventory).addItem(any(ItemStack.class)); - boolean scheduled = EngineBukkitOps.dispatchLootMutation( - task, - true, - () -> { - primaryChecks.incrementAndGet(); - return true; - }, - regionTask -> false, - runnable -> syncSchedules.incrementAndGet() - ); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> EngineBukkitOps.populateInventory(null, inventory, + List.of(item(1)), new RNG(17)))); - assertFalse(scheduled); - assertFalse(mutated.get()); - assertEquals(0, primaryChecks.get()); - assertEquals(0, syncSchedules.get()); + verify(leftSnapshot).update(true, false); + verify(rightSnapshot).update(true, false); } @Test - public void nonFoliaPrimaryThreadMutatesInline() { - World world = mock(World.class); - AtomicInteger regionSchedules = new AtomicInteger(); - AtomicInteger syncSchedules = new AtomicInteger(); - AtomicBoolean mutated = new AtomicBoolean(); - EngineBukkitOps.LootMutationTask task = new EngineBukkitOps.LootMutationTask(world, 4, 5, () -> mutated.set(true)); + public void failedBarrelFillRestoresItsBlockSnapshot() { + Inventory inventory = mock(Inventory.class); + Barrel holder = mock(Barrel.class); + Block block = mock(Block.class); + Barrel snapshot = mock(Barrel.class); + when(inventory.getHolder()).thenReturn(holder); + when(holder.getBlock()).thenReturn(block); + when(block.getState()).thenReturn(snapshot); + when(snapshot.update(true, false)).thenReturn(true); + IllegalStateException failure = new IllegalStateException("Native barrel loot failed"); + when(inventory.getContents()).thenThrow(failure); - boolean scheduled = EngineBukkitOps.dispatchLootMutation( - task, - false, - () -> true, - regionTask -> { - regionSchedules.incrementAndGet(); - return true; - }, - runnable -> syncSchedules.incrementAndGet() - ); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> EngineBukkitOps.populateInventory(null, inventory, List.of(), new RNG(17)))); - assertTrue(scheduled); - assertTrue(mutated.get()); - assertEquals(0, regionSchedules.get()); - assertEquals(0, syncSchedules.get()); + verify(snapshot).update(true, false); } @Test - public void nonFoliaAsyncCompletionUsesSyncScheduler() { + public void failedRollbackPreservesTheCauseAndRestoresTheOtherHalf() { + Inventory inventory = mock(Inventory.class); + DoubleChest holder = mock(DoubleChest.class); + org.bukkit.block.Chest left = mock(org.bukkit.block.Chest.class, RETURNS_DEEP_STUBS); + org.bukkit.block.Chest right = mock(org.bukkit.block.Chest.class, RETURNS_DEEP_STUBS); + BlockState leftSnapshot = mock(BlockState.class); + BlockState rightSnapshot = mock(BlockState.class); + when(inventory.getHolder()).thenReturn(holder); + when(holder.getLeftSide()).thenReturn(left); + when(holder.getRightSide()).thenReturn(right); + when(left.getBlock().getState()).thenReturn(leftSnapshot); + when(right.getBlock().getState()).thenReturn(rightSnapshot); + when(rightSnapshot.update(true, false)).thenReturn(true); + IllegalStateException failure = new IllegalStateException("Native loot failed"); + IllegalStateException rollbackFailure = new IllegalStateException("Snapshot restore failed"); + when(leftSnapshot.update(true, false)).thenThrow(rollbackFailure); + doThrow(failure).when(inventory).addItem(any(ItemStack.class)); + + assertSame(failure, assertThrows(IllegalStateException.class, + () -> EngineBukkitOps.populateInventory(null, inventory, List.of(item(1)), new RNG(17)))); + + assertEquals(1, failure.getSuppressed().length); + assertSame(rollbackFailure, failure.getSuppressed()[0]); + verify(rightSnapshot).update(true, false); + } + + @Test + public void cancelledLootLeavesTheInventoryUntouched() { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + Inventory inventory = mock(Inventory.class); World world = mock(World.class); - AtomicInteger regionSchedules = new AtomicInteger(); - AtomicInteger syncSchedules = new AtomicInteger(); - AtomicBoolean mutated = new AtomicBoolean(); - EngineBukkitOps.LootMutationTask task = new EngineBukkitOps.LootMutationTask(world, 6, 7, () -> mutated.set(true)); + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic events = mockStatic(IrisLootEvent.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(world, 2, -2)).thenReturn(true); + events.when(() -> IrisLootEvent.callLootEvent(any(), eq(inventory), eq(world), eq(33), eq(64), eq(-17))) + .thenReturn(true); - boolean scheduled = EngineBukkitOps.dispatchLootMutation( - task, - false, - () -> false, - regionTask -> { - regionSchedules.incrementAndGet(); - return true; - }, - runnable -> { - syncSchedules.incrementAndGet(); - runnable.run(); - } - ); + EngineBukkitOps.addItems(engine, false, inventory, new KList<>(), InventorySlotType.STORAGE, world, 33, 64, -17); - assertTrue(scheduled); - assertTrue(mutated.get()); - assertEquals(0, regionSchedules.get()); - assertEquals(1, syncSchedules.get()); + verifyNoInteractions(inventory); + } + } + + @Test + public void offRegionLootDoesNotRollOrTouchInventory() { + Engine engine = mock(Engine.class); + Inventory inventory = mock(Inventory.class); + World world = mock(World.class); + IrisLootTable table = mock(IrisLootTable.class); + try (MockedStatic scheduling = mockStatic(J.class)) { + assertThrows(IllegalStateException.class, () -> EngineBukkitOps.addItems(engine, false, inventory, + new KList<>(table), InventorySlotType.STORAGE, world, 33, 64, -17)); + + scheduling.verify(() -> J.isOwnedByCurrentRegion(world, 2, -2)); + verifyNoInteractions(table, inventory, engine); + } + } + + @Test + public void owningRegionLootCompletesInlineWithoutGlobalDispatch() { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + Inventory inventory = mock(Inventory.class); + World world = mock(World.class); + when(inventory.getContents()).thenReturn(new ItemStack[0]); + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic events = mockStatic(IrisLootEvent.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(world, 2, -2)).thenReturn(true); + + EngineBukkitOps.addItems(engine, false, inventory, new KList<>(), InventorySlotType.STORAGE, world, 33, 64, -17); + + verify(inventory).setContents(any(ItemStack[].class)); + scheduling.verify(() -> J.isOwnedByCurrentRegion(world, 2, -2)); + scheduling.verifyNoMoreInteractions(); + } + } + + @Test + public void foliaLootEventRunsOnTheOwningRegionWithoutGlobalDispatch() { + World world = mock(World.class); + Inventory inventory = mock(Inventory.class); + PluginManager manager = mock(PluginManager.class); + Thread owner = Thread.currentThread(); + AtomicInteger eventCalls = new AtomicInteger(); + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic bukkit = mockStatic(Bukkit.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(world, 2, -2)).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + doAnswer(invocation -> { + assertSame(owner, Thread.currentThread()); + eventCalls.incrementAndGet(); + return null; + }).when(manager).callEvent(any(LootGenerateEvent.class)); + + assertFalse(IrisLootEvent.callLootEvent(new KList<>(), inventory, world, 33, 64, -17)); + + assertEquals(1, eventCalls.get()); + scheduling.verify(() -> J.isOwnedByCurrentRegion(world, 2, -2)); + scheduling.verifyNoMoreInteractions(); + } + } + + @Test + public void lootEventRetainsListenerChangesAndCancellation() { + World world = mock(World.class); + Inventory inventory = mock(Inventory.class); + PluginManager manager = mock(PluginManager.class); + ItemStack original = item(2); + ItemStack replacement = item(3); + KList items = new KList<>(original); + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic bukkit = mockStatic(Bukkit.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(world, 2, -2)).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + doAnswer(invocation -> { + LootGenerateEvent event = invocation.getArgument(0); + event.setLoot(List.of(replacement)); + event.setCancelled(true); + return null; + }).when(manager).callEvent(any(LootGenerateEvent.class)); + + assertTrue(IrisLootEvent.callLootEvent(items, inventory, world, 33, 64, -17)); + + assertEquals(List.of(replacement), items); + } + } + + @Test + @SuppressWarnings("unchecked") + public void failedLootRollKeepsPendingMarkerUntilSuccessfulRetry() { + BukkitTestServer.install(); + AtomicBoolean pending = new AtomicBoolean(true); + AtomicInteger attempts = new AtomicInteger(); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + when(engine.getMetrics()).thenReturn(new EngineMetrics(8)); + World world = mock(World.class); + when(world.getMaxHeight()).thenReturn(256); + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + Block block = mock(Block.class); + BlockData state = mock(BlockData.class); + org.bukkit.block.Chest chest = mock(org.bukkit.block.Chest.class); + Inventory inventory = mock(Inventory.class); + when(chunk.getBlock(1, 64, 1)).thenReturn(block); + when(block.getBlockData()).thenReturn(state); + when(block.getX()).thenReturn(1); + when(block.getZ()).thenReturn(1); + when(block.getState()).thenReturn(chest); + when(chest.getInventory()).thenReturn(inventory); + when(inventory.getContents()).thenReturn(new ItemStack[0]); + MantleChunk mantle = spy(new MantleChunk<>(16, 0, 0, mock(MantleDataAdapter.class), MantleHooks.NONE)); + doAnswer(invocation -> { + if (pending.get()) { + Consumer4 visitor = invocation.getArgument(1); + visitor.accept(1, 64, 1, UpdateMatter.ON); + } + return null; + }).when(mantle).iterate(eq(MatterUpdate.class), any()); + doAnswer(invocation -> { + pending.set(false); + return null; + }).when(mantle).deleteSlices(MatterUpdate.class); + IrisLootTable table = mock(IrisLootTable.class); + IllegalStateException failure = new IllegalStateException("Loot provider unavailable"); + when(table.getLoot(false, 0L, InventorySlotType.STORAGE, world, 1, 64, 1)).thenAnswer(invocation -> { + if (attempts.getAndIncrement() < 2) { + throw failure; + } + return new KList(); + }); + KList tables = new KList<>(table); + PluginManager manager = mock(PluginManager.class); + try (MockedStatic registries = mockStatic(RegistryAccess.class)) { + registries.when(RegistryAccess::registryAccess).thenReturn(BukkitTestServer.registryAccess()); + try (MockedStatic operations = mockStatic(EngineBukkitOps.class); + MockedStatic resolution = mockStatic(BukkitBlockResolution.class); + MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic events = mockStatic(IrisLootEvent.class); + MockedStatic scheduling = mockStatic(J.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + logging.when(() -> IrisLogging.errorOnce(anyString(), anyString())).thenReturn(true, false); + scheduling.when(() -> J.isOwnedByCurrentRegion(world, 0, 0)).thenReturn(true); + resolution.when(() -> BukkitBlockResolution.isStorage(state)).thenReturn(true); + resolution.when(() -> BukkitBlockResolution.isStorageChest(state)).thenReturn(true); + operations.when(() -> EngineBukkitOps.isCanonicalContainer(block)).thenReturn(true); + operations.when(() -> EngineBukkitOps.getLootTables(eq(engine), any(RNG.class), eq(block), eq(mantle), eq(false))).thenReturn(tables); + operations.when(() -> EngineBukkitOps.addItems(engine, false, inventory, tables, InventorySlotType.STORAGE, world, 1, 64, 1)).thenCallRealMethod(); + operations.when(() -> EngineBukkitOps.populateInventory(eq(engine), eq(inventory), any(), any())).thenCallRealMethod(); + operations.when(() -> EngineBukkitOps.update(engine, 1, 64, 1, chunk, mantle)).thenCallRealMethod(); + operations.when(() -> EngineBukkitOps.materializeUpdates(engine, chunk, mantle)).thenCallRealMethod(); + operations.when(() -> EngineBukkitOps.materializeUpdates(eq(engine), eq(chunk), eq(mantle), any(EngineBukkitOps.UpdateDispatcher.class))).thenCallRealMethod(); + operations.when(() -> EngineBukkitOps.runMaterializationPasses(eq(mantle), any(Runnable.class), any(Runnable.class), any(Runnable.class))).thenCallRealMethod(); + Runnable pass = () -> EngineBukkitOps.runMaterializationPasses(mantle, () -> {}, () -> {}, + () -> EngineBukkitOps.materializeUpdates(engine, chunk, mantle)); + + assertSame(failure, assertThrows(IllegalStateException.class, pass::run)); + assertTrue(pending.get()); + assertFalse(mantle.isFlagged(MantleFlag.UPDATE)); + assertFalse(mantle.isFlagged(MantleFlag.ETCHED)); + assertSame(failure, assertThrows(IllegalStateException.class, pass::run)); + assertTrue(pending.get()); + logging.verify(() -> IrisLogging.reportError(failure)); + + pass.run(); + pass.run(); + + assertFalse(pending.get()); + assertTrue(mantle.isFlagged(MantleFlag.UPDATE)); + assertTrue(mantle.isFlagged(MantleFlag.ETCHED)); + assertEquals(3, attempts.get()); + } + } + } + + private ItemStack item(int amount) { + ItemStack item = mock(ItemStack.class); + AtomicInteger count = new AtomicInteger(amount); + when(item.getType()).thenReturn(Material.DIAMOND); + when(item.getAmount()).thenAnswer(invocation -> count.get()); + when(item.clone()).thenAnswer(invocation -> item(count.get())); + doAnswer(invocation -> { + count.set(invocation.getArgument(0)); + return null; + }).when(item).setAmount(anyInt()); + return item; } private Block chestBlock(int x, int z, Chest.Type type, BlockFace facing) { diff --git a/core/src/test/java/art/arcane/iris/world/loot/IrisVanillaLootTableTest.java b/core/src/test/java/art/arcane/iris/world/loot/IrisVanillaLootTableTest.java new file mode 100644 index 000000000..c2e04eb15 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/loot/IrisVanillaLootTableTest.java @@ -0,0 +1,76 @@ +package art.arcane.iris.world.loot; + +import art.arcane.iris.spi.IrisLogging; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.inventory.ItemStack; +import org.bukkit.loot.LootTable; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.List; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisVanillaLootTableTest { + @Test + public void registryReplacementRemovalAndRestorationApplyToTheNextContainer() { + NamespacedKey key = new NamespacedKey("test", "reloadable_loot"); + World world = mock(World.class); + LootTable before = mock(LootTable.class); + LootTable after = mock(LootTable.class); + ItemStack previousItem = mock(ItemStack.class); + ItemStack currentItem = mock(ItemStack.class); + when(before.populateLoot(any(), any())).thenReturn(List.of(previousItem)); + when(after.populateLoot(any(), any())).thenReturn(List.of(currentItem)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + IrisVanillaLootTable table = new IrisVanillaLootTable(key.toString()); + bukkit.when(() -> Bukkit.getLootTable(key)).thenReturn(before); + assertSame(previousItem, table.getLoot(false, 1L, InventorySlotType.STORAGE, world, 0, 64, 0).getFirst()); + + bukkit.when(() -> Bukkit.getLootTable(key)).thenReturn(after); + assertSame(currentItem, table.getLoot(false, 1L, InventorySlotType.STORAGE, world, 1, 64, 0).getFirst()); + + bukkit.when(() -> Bukkit.getLootTable(key)).thenReturn(null); + assertTrue(table.getLoot(false, 1L, InventorySlotType.STORAGE, world, 2, 64, 0).isEmpty()); + + bukkit.when(() -> Bukkit.getLootTable(key)).thenReturn(after); + assertSame(currentItem, table.getLoot(false, 1L, InventorySlotType.STORAGE, world, 3, 64, 0).getFirst()); + verify(before).populateLoot(any(), any()); + verify(after, times(2)).populateLoot(any(), any()); + } + } + + @Test + public void failedTableGenerationDoesNotRetainTheReplacedTable() { + NamespacedKey key = new NamespacedKey("test", "repaired_loot"); + World world = mock(World.class); + LootTable failed = mock(LootTable.class); + LootTable repaired = mock(LootTable.class); + ItemStack item = mock(ItemStack.class); + IllegalStateException failure = new IllegalStateException("Loot generation failed"); + when(failed.populateLoot(any(), any())).thenThrow(failure); + when(repaired.populateLoot(any(), any())).thenReturn(List.of(item)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + IrisVanillaLootTable table = new IrisVanillaLootTable(key.toString()); + bukkit.when(() -> Bukkit.getLootTable(key)).thenReturn(failed); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> table.getLoot(false, 1L, InventorySlotType.STORAGE, world, 0, 64, 0))); + + bukkit.when(() -> Bukkit.getLootTable(key)).thenReturn(repaired); + assertSame(item, table.getLoot(false, 1L, InventorySlotType.STORAGE, world, 0, 64, 0).getFirst()); + } + } +} From ba2ecb7c0fffc5b0f45ef6b7e910c95f6466606c Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 13 Oct 2026 16:42:41 -0400 Subject: [PATCH 2/7] Added treasure loot coverage - Gameplay opens normal and copper chests before inspecting loot - Map markers and saved centers match verified structure targets - Missing and disabled destinations retain unrelated chest loot - Storage variants, insertion rollback, region ownership, and resource reloads have runtime assertions - Separate data packs cover Minecraft 26.2 and 26.3 loot formats --- .../resources/plugin.yml | 1 + .../iris/fixture/NativeLifecycleFixture.java | 219 ++++++++++++++- .../iris/fixture/TreasureLootChecks.java | 259 ++++++++++++++++++ .../data/iris_qa/loot_table/blocked.json | 33 +++ .../data/iris_qa/loot_table/disabled.json | 33 +++ .../data/iris_qa/loot_table/found.json | 33 +++ .../data/iris_qa/loot_table/mixed.json | 33 +++ .../data/iris_qa/loot_table/reload.json | 14 + .../tags/worldgen/structure/blocked.json | 5 + .../tags/worldgen/structure/disabled.json | 5 + .../tags/worldgen/structure/found.json | 5 + .../tags/worldgen/structure/mixed.json | 6 + .../treasure-map-datapack-26.2/pack.mcmeta | 13 + .../data/iris_qa/loot_table/blocked.json | 31 +++ .../data/iris_qa/loot_table/disabled.json | 31 +++ .../data/iris_qa/loot_table/found.json | 31 +++ .../data/iris_qa/loot_table/mixed.json | 31 +++ .../data/iris_qa/loot_table/reload.json | 14 + .../tags/worldgen/structure/blocked.json | 5 + .../tags/worldgen/structure/disabled.json | 5 + .../tags/worldgen/structure/found.json | 5 + .../tags/worldgen/structure/mixed.json | 6 + .../treasure-map-datapack/pack.mcmeta | 13 + .../treasure-map-pack/biomes/flat.json | 32 +++ .../dimensions/treasure-map-fixture.json | 64 +++++ .../treasure-map-pack/generators/flat.json | 15 + .../loot/fixture-diamond.json | 15 + .../treasure-map-pack/regions/flat.json | 12 + .../src/test/gameplay/treasure-maps.mjs | 168 ++++++++++++ 29 files changed, 1131 insertions(+), 6 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/TreasureLootChecks.java create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/blocked.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/disabled.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/found.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/mixed.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/loot_table/reload.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/blocked.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/disabled.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/found.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/data/iris_qa/tags/worldgen/structure/mixed.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack-26.2/pack.mcmeta create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/blocked.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/disabled.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/found.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/mixed.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/loot_table/reload.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/blocked.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/disabled.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/found.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/data/iris_qa/tags/worldgen/structure/mixed.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-datapack/pack.mcmeta create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/biomes/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/dimensions/treasure-map-fixture.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/generators/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/loot/fixture-diamond.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-map-pack/regions/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs 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') + } + } + }) + } +} From ee3af26f119d8370e65379bd5d4ec8d3b0857dc1 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 13 Oct 2026 16:49:03 -0400 Subject: [PATCH 3/7] Captured fixture movement packets - Gameplay reports the last 64 movement packets with player position and rotation - Non-finite packet fields remain visible in failed scenario reports --- .../src/test/gameplay/treasure-maps.mjs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs index a31dab2e9..82c532583 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs @@ -8,10 +8,40 @@ const simplifyNbt = value => { return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, simplifyNbt(entry)])) } +const traceMovement = context => { + const client = context.bot._client + const write = client.write + const records = context.report.treasureMovement = [] + const numeric = value => typeof value === 'number' && !Number.isFinite(value) ? String(value) : value + const record = (direction, name, packet) => { + const entity = context.bot.entity + records.push({ direction, name, at: new Date().toISOString(), + packet: Object.fromEntries(['teleportId', 'x', 'y', 'z', 'yaw', 'pitch', 'flags'] + .filter(key => key in packet).map(key => [key, numeric(packet[key])])), + entity: Object.fromEntries(['x', 'y', 'z'].map(key => [key, numeric(entity.position[key])]) + .concat(['yaw', 'pitch'].map(key => [key, numeric(entity[key])]))) }) + if (records.length > 64) records.shift() + } + const incoming = packet => record('incoming', 'position', packet) + const rotation = packet => record('incoming', 'player_rotation', packet) + client.prependListener('position', incoming) + client.prependListener('player_rotation', rotation) + client.write = function (name, packet, ...rest) { + if (['position', 'position_look', 'look', 'teleport_confirm'].includes(name)) record('outgoing', name, packet) + return write.call(this, name, packet, ...rest) + } + context.signal.addEventListener('abort', () => { + client.write = write + client.removeListener('position', incoming) + client.removeListener('player_rotation', rotation) + }, { once: true }) +} + export default { name: 'iris-treasure-maps', description: 'Verify exploration-map targets, bounded misses, retained loot, and normal/copper storage on Iris.', async run(context) { + traceMovement(context) context.bot.physicsEnabled = false context.bot.clearControlStates() context.bot.entity.velocity.set(0, 0, 0) From 9dbb6b6e855e672028688513bd29745d3914f4de Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 13 Oct 2026 16:52:12 -0400 Subject: [PATCH 4/7] Completed fixture client ticks - Stationary gameplay sends tick delimiters after movement packets - An initial delimiter closes movement sent before the scenario starts --- adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs index 82c532583..76d4d5b4a 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs @@ -27,13 +27,18 @@ const traceMovement = context => { client.prependListener('position', incoming) client.prependListener('player_rotation', rotation) client.write = function (name, packet, ...rest) { - if (['position', 'position_look', 'look', 'teleport_confirm'].includes(name)) record('outgoing', name, packet) + if (['position', 'position_look', 'look', 'teleport_confirm', 'tick_end'].includes(name)) record('outgoing', name, packet) return write.call(this, name, packet, ...rest) } + context.expect(context.bot.supportFeature('sendsClientTickEndPacket'), 'Fixture client supports tick delimiters') + const finishTick = () => client.write('tick_end', {}) + context.bot.on('move', finishTick) + finishTick() context.signal.addEventListener('abort', () => { client.write = write client.removeListener('position', incoming) client.removeListener('player_rotation', rotation) + context.bot.removeListener('move', finishTick) }, { once: true }) } From f17a9e0977e794b6c7fa2bd1eff646c196ddfb9e Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 13 Oct 2026 16:55:00 -0400 Subject: [PATCH 5/7] Bounded treasure inventory actions - Chest activation and diamond withdrawal stop after ten seconds - Partial chest evidence records the active phase and window layout - Folia reports its unavailable reload command without changing pack files --- .../src/test/gameplay/treasure-maps.mjs | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs index 76d4d5b4a..929187f6a 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs @@ -59,13 +59,17 @@ export default { context.expect(response.includes(expected), response) return response } + const bounded = (label, action) => context.waitUntil(async () => { + await action() + return true + }, { label, timeoutMs: 10000 }) 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)) + await bounded(`Activate ${material}`, () => context.bot.activateBlock(context.bot.blockAt(position))) const [window] = await opened return window } @@ -88,11 +92,17 @@ export default { 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 evidence = { destination, material, phase: 'prepare' } + context.report.treasureMaps ??= [] + context.report.treasureMaps.push(evidence) 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()}`) + evidence.phase = 'open' const window = await openChest(material) + evidence.windowType = window.type + evidence.inventoryStart = window.inventoryStart let targetId let diamondType let diamondsBefore @@ -128,13 +138,18 @@ export default { } diamondType = diamond.type diamondsBefore = context.bot.inventory.count(diamondType) - await context.bot.clickWindow(diamond.slot, 0, 1) + evidence.phase = 'withdraw' + evidence.diamondSlot = diamond.slot + await bounded(`Withdraw diamond from ${material} ${destination}`, + () => context.bot.clickWindow(diamond.slot, 0, 1)) + evidence.phase = 'withdrawn' await context.waitUntil(() => chestItems(window).every(item => item.name !== 'diamond'), { label: 'Diamond transferred out of the chest', timeoutMs: 10000 }) } finally { context.bot.closeWindow(window) } + evidence.phase = 'reopen' const reopened = await openChest(material) try { await context.waitUntil(() => chestItems(reopened).some(item => item.name === mapName), { @@ -149,8 +164,7 @@ export default { 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, + Object.assign(evidence, { phase: 'complete', mapItem: mapName, mapId: targetId ?? null, marker: marker ?? null, verified, reopened: true }) } finally { context.bot.closeWindow(reopened) @@ -164,7 +178,15 @@ export default { 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 () => { + await context.step('verify native loot table reload support', async () => { + await command('treasure-reload-before', 'TREASURE_RELOAD material=DIAMOND') + if (context.report.treasurePlatform.folia) { + const response = await context.command('/minecraft:reload', /Unknown or incomplete command|Reloading/i, 10000) + context.expect(response.includes('Unknown or incomplete command'), + 'Folia fixture expects its unavailable data pack reload command', { response }) + context.report.treasureReload = { status: 'unavailable', before: 'diamond', evidence: response } + return + } 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', @@ -184,13 +206,12 @@ export default { 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' } + context.report.treasureReload = { status: 'passed', before: 'diamond', after: 'emerald' } } finally { await writeFile(tablePath, original) if (!context.signal.aborted) { From 718b13df46ee6d5c6d667fce7f2db14b67c4b68c Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 13 Oct 2026 16:57:30 -0400 Subject: [PATCH 6/7] Paced treasure chest interactions - Chest activations respect the server interaction rate limit - Stationary clients finish idle ticks every fifty milliseconds - Missing inventory windows fail within thirty seconds --- .../plugin/src/test/gameplay/treasure-maps.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs index 929187f6a..bf1b57fbe 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs @@ -31,10 +31,16 @@ const traceMovement = context => { return write.call(this, name, packet, ...rest) } context.expect(context.bot.supportFeature('sendsClientTickEndPacket'), 'Fixture client supports tick delimiters') - const finishTick = () => client.write('tick_end', {}) + let tickTimer + const finishTick = () => { + clearTimeout(tickTimer) + client.write('tick_end', {}) + tickTimer = setTimeout(finishTick, 50) + } context.bot.on('move', finishTick) finishTick() context.signal.addEventListener('abort', () => { + clearTimeout(tickTimer) client.write = write client.removeListener('position', incoming) client.removeListener('player_rotation', rotation) @@ -63,13 +69,17 @@ export default { await action() return true }, { label, timeoutMs: 10000 }) + let lastActivation = 0 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) + const delay = 150 - (performance.now() - lastActivation) + if (delay > 0) await context.sleep(delay) + const opened = context.waitForEvent('windowOpen', undefined, 30000) await bounded(`Activate ${material}`, () => context.bot.activateBlock(context.bot.blockAt(position))) + lastActivation = performance.now() const [window] = await opened return window } From 09d36ec99076ad08e5de0007e213b2dd47fee8fb Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 13 Oct 2026 16:58:45 -0400 Subject: [PATCH 7/7] Removed temporary movement trace - Gameplay retains tick delimiters without intercepting packet writes - Chest outcome and failure phase evidence remain in reports --- .../src/test/gameplay/treasure-maps.mjs | 27 ++----------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs index bf1b57fbe..adc928b7a 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs +++ b/adapters/bukkit/plugin/src/test/gameplay/treasure-maps.mjs @@ -8,28 +8,8 @@ const simplifyNbt = value => { return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, simplifyNbt(entry)])) } -const traceMovement = context => { +const completeClientTicks = context => { const client = context.bot._client - const write = client.write - const records = context.report.treasureMovement = [] - const numeric = value => typeof value === 'number' && !Number.isFinite(value) ? String(value) : value - const record = (direction, name, packet) => { - const entity = context.bot.entity - records.push({ direction, name, at: new Date().toISOString(), - packet: Object.fromEntries(['teleportId', 'x', 'y', 'z', 'yaw', 'pitch', 'flags'] - .filter(key => key in packet).map(key => [key, numeric(packet[key])])), - entity: Object.fromEntries(['x', 'y', 'z'].map(key => [key, numeric(entity.position[key])]) - .concat(['yaw', 'pitch'].map(key => [key, numeric(entity[key])]))) }) - if (records.length > 64) records.shift() - } - const incoming = packet => record('incoming', 'position', packet) - const rotation = packet => record('incoming', 'player_rotation', packet) - client.prependListener('position', incoming) - client.prependListener('player_rotation', rotation) - client.write = function (name, packet, ...rest) { - if (['position', 'position_look', 'look', 'teleport_confirm', 'tick_end'].includes(name)) record('outgoing', name, packet) - return write.call(this, name, packet, ...rest) - } context.expect(context.bot.supportFeature('sendsClientTickEndPacket'), 'Fixture client supports tick delimiters') let tickTimer const finishTick = () => { @@ -41,9 +21,6 @@ const traceMovement = context => { finishTick() context.signal.addEventListener('abort', () => { clearTimeout(tickTimer) - client.write = write - client.removeListener('position', incoming) - client.removeListener('player_rotation', rotation) context.bot.removeListener('move', finishTick) }, { once: true }) } @@ -52,7 +29,7 @@ export default { name: 'iris-treasure-maps', description: 'Verify exploration-map targets, bounded misses, retained loot, and normal/copper storage on Iris.', async run(context) { - traceMovement(context) + completeClientTicks(context) context.bot.physicsEnabled = false context.bot.clearControlStates() context.bot.entity.velocity.set(0, 0, 0)