package ar.com.companeros; import ar.com.companeros.action.ToolResult; import ar.com.companeros.body.PlayerSessions; import ar.com.companeros.identity.AgentRegistry; import ar.com.companeros.identity.AgentState; import ar.com.companeros.world.PlayerCrafting; import ar.com.companeros.world.PlayerOperations; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import net.minecraft.core.BlockPos; import net.minecraft.core.GlobalPos; import net.minecraft.gametest.framework.BeforeBatch; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.item.ItemEntity; 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.entity.ChestBlockEntity; import net.minecraft.world.phys.AABB; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.entity.player.PlayerInteractEvent; import net.minecraftforge.event.level.BlockEvent; import net.minecraftforge.eventbus.api.EventPriority; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.gametest.GameTestHolder; import net.minecraftforge.gametest.PrefixGameTestTemplate; /** Cada batch usa una única sesión real, respetando el límite del servidor GameTest. */ @GameTestHolder(Companeros.ID) @PrefixGameTestTemplate(false) public final class PlayerOperationTests { // Los datos de protección se reinician exclusivamente en este mundo de fixtures. @BeforeBatch(batch = "playerops_place") public static void resetPlace(ServerLevel level) { reset(level); } @BeforeBatch(batch = "playerops_store") public static void resetStore(ServerLevel level) { reset(level); } @BeforeBatch(batch = "playerops_store_veto") public static void resetStoreVeto(ServerLevel level) { reset(level); } @BeforeBatch(batch = "playerops_crafting") public static void resetCrafting(ServerLevel level) { reset(level); } @BeforeBatch(batch = "playerops_remainders") public static void resetRemainders(ServerLevel level) { reset(level); } @BeforeBatch(batch = "playerops_mining") public static void resetMining(ServerLevel level) { reset(level); } @BeforeBatch(batch = "playerops_mining_veto") public static void resetMiningVeto(ServerLevel level) { reset(level); } private static void reset(ServerLevel level) { level.getDataStorage().set("companeros_zonas", new WorkArea()); } @GameTest(template = "empty", batch = "playerops_place") public void registeredPlayerPlacementConsumesMaterialAndHonorsVeto(GameTestHelper helper) { Fixture fixture = create(helper); BlockPos target = helper.absolutePos(new BlockPos(4, 2, 3)); try { fixture.player.getInventory().setItem(0, new ItemStack(Items.OAK_PLANKS, 2)); PlayerOperations.Operation operation = PlayerOperations.place(fixture.player, UUID.randomUUID(), target, Items.OAK_PLANKS); ToolResult result = operation.tick(helper.getLevel().getServer().getTickCount()); helper.assertTrue(result.succeeded() && helper.getLevel().getBlockState(target).is(Blocks.OAK_PLANKS) && fixture.player.getInventory().countItem(Items.OAK_PLANKS) == 1, "Coloca un bloque real pagando una tabla"); operation.tick(helper.getLevel().getServer().getTickCount()); helper.assertTrue(fixture.player.getInventory().countItem(Items.OAK_PLANKS) == 1, "El resultado terminado no repite el consumo"); helper.getLevel().setBlock(target, Blocks.AIR.defaultBlockState(), 3); PlacementVeto veto = new PlacementVeto(fixture.id, target); MinecraftForge.EVENT_BUS.register(veto); try { ToolResult blocked = PlayerOperations.place(fixture.player, UUID.randomUUID(), target, Items.OAK_PLANKS) .tick(helper.getLevel().getServer().getTickCount()); helper.assertTrue(veto.events.get() > 0 && !blocked.succeeded() && helper.getLevel().getBlockState(target).isAir() && fixture.player.getInventory().countItem(Items.OAK_PLANKS) == 1, "Un veto Forge revierte la colocación y conserva el material"); } finally { MinecraftForge.EVENT_BUS.unregister(veto); } helper.succeed(); } finally { fixture.close(); } } @GameTest(template = "empty", batch = "playerops_store") public void fullAndPartialChestTransfersConserveItems(GameTestHelper helper) { Fixture fixture = create(helper); BlockPos target = helper.absolutePos(new BlockPos(4, 2, 3)); try { helper.getLevel().setBlock(target, Blocks.CHEST.defaultBlockState(), 3); ChestBlockEntity chest = (ChestBlockEntity) helper.getLevel().getBlockEntity(target); for (int slot = 0; slot < chest.getContainerSize(); slot++) chest.setItem(slot, new ItemStack(Items.STONE, 64)); fixture.player.getInventory().setItem(0, new ItemStack(Items.STONE, 5)); int before = chestCount(chest); ToolResult full = PlayerOperations.store(fixture.player, UUID.randomUUID(), target, Items.STONE, 5) .tick(helper.getLevel().getServer().getTickCount()); helper.assertTrue(!full.succeeded() && full.reason() == ToolResult.Reason.INVENTORY_FULL && fixture.player.getInventory().countItem(Items.STONE) == 5 && chestCount(chest) == before, "Un cofre lleno no consume ni duplica los materiales"); chest.setItem(0, new ItemStack(Items.STONE, 60)); before = chestCount(chest); ToolResult partial = PlayerOperations.store(fixture.player, UUID.randomUUID(), target, Items.STONE, 5) .tick(helper.getLevel().getServer().getTickCount()); helper.assertTrue(partial.succeeded() && fixture.player.getInventory().countItem(Items.STONE) == 1 && chestCount(chest) == before + 4 && partial.inventoryDelta().getOrDefault("minecraft:stone", 0) == -4, "Una transferencia parcial cobra exactamente lo que el cofre aceptó"); helper.succeed(); } finally { fixture.close(); } } @GameTest(template = "empty", batch = "playerops_store_veto") public void containerInteractionVetoPreventsDirectCapabilityDeposit(GameTestHelper helper) { Fixture fixture = create(helper); BlockPos target = helper.absolutePos(new BlockPos(4, 2, 3)); try { helper.getLevel().setBlock(target, Blocks.CHEST.defaultBlockState(), 3); ChestBlockEntity chest = (ChestBlockEntity) helper.getLevel().getBlockEntity(target); fixture.player.getInventory().setItem(0, new ItemStack(Items.STONE, 5)); InteractionVeto veto = new InteractionVeto(fixture.id, target); MinecraftForge.EVENT_BUS.register(veto); try { ToolResult result = PlayerOperations.store(fixture.player, UUID.randomUUID(), target, Items.STONE, 5) .tick(helper.getLevel().getServer().getTickCount()); helper.assertTrue(veto.events.get() > 0 && !result.succeeded() && fixture.player.getInventory().countItem(Items.STONE) == 5 && chestCount(chest) == 0, "La capacidad del cofre no permite eludir un veto de interacción"); } finally { MinecraftForge.EVENT_BUS.unregister(veto); } helper.succeed(); } finally { fixture.close(); } } @GameTest(template = "empty", batch = "playerops_crafting") public void canonicalCraftingConsumesIngredientsAndRejectsFullInventory(GameTestHelper helper) { Fixture fixture = create(helper); try { fixture.player.getInventory().setItem(0, new ItemStack(Items.OAK_LOG)); ToolResult made = PlayerCrafting.craft(fixture.player, UUID.randomUUID(), Items.OAK_PLANKS); helper.assertTrue(made.succeeded() && fixture.player.getInventory().countItem(Items.OAK_LOG) == 0 && fixture.player.getInventory().countItem(Items.OAK_PLANKS) == 4, "La receta real consume un tronco y produce cuatro tablas en el inventario del jugador"); fixture.player.getInventory().clearContent(); for (int slot = 0; slot < 36; slot++) fixture.player.getInventory().setItem(slot, new ItemStack(Items.STONE, 64)); fixture.player.getInventory().setItem(0, new ItemStack(Items.OAK_LOG, 64)); ToolResult full = PlayerCrafting.craft(fixture.player, UUID.randomUUID(), Items.OAK_PLANKS); helper.assertTrue(!full.succeeded() && full.reason() == ToolResult.Reason.INVENTORY_FULL && fixture.player.getInventory().countItem(Items.OAK_LOG) == 64 && fixture.player.getInventory().countItem(Items.STONE) == 35 * 64 && fixture.player.getInventory().countItem(Items.OAK_PLANKS) == 0, "Si no cabe el resultado, la transacción conserva todos los ingredientes"); helper.succeed(); } finally { fixture.close(); } } @GameTest(template = "empty", batch = "playerops_remainders") public void craftingNeedsWorkbenchAndReturnsRealContainers(GameTestHelper helper) { Fixture fixture = create(helper); try { fixture.player.getInventory().setItem(0, new ItemStack(Items.OAK_PLANKS, 3)); fixture.player.getInventory().setItem(1, new ItemStack(Items.STICK, 2)); ToolResult missing = PlayerCrafting.craft(fixture.player, UUID.randomUUID(), Items.WOODEN_PICKAXE); helper.assertTrue(!missing.succeeded() && fixture.player.getInventory().countItem(Items.OAK_PLANKS) == 3 && fixture.player.getInventory().countItem(Items.STICK) == 2, "Una receta 3×3 no aparece sin mesa"); helper.setBlock(new BlockPos(4, 2, 3), Blocks.CRAFTING_TABLE); ToolResult pickaxe = PlayerCrafting.craft(fixture.player, UUID.randomUUID(), Items.WOODEN_PICKAXE); helper.assertTrue(pickaxe.succeeded() && fixture.player.getInventory().countItem(Items.WOODEN_PICKAXE) == 1 && fixture.player.getInventory().countItem(Items.OAK_PLANKS) == 0 && fixture.player.getInventory().countItem(Items.STICK) == 0, "Usa una mesa alcanzable y materiales reales"); fixture.player.getInventory().clearContent(); for (int slot = 0; slot < 3; slot++) fixture.player.getInventory().setItem(slot, new ItemStack(Items.MILK_BUCKET)); fixture.player.getInventory().setItem(3, new ItemStack(Items.SUGAR, 2)); fixture.player.getInventory().setItem(4, new ItemStack(Items.EGG)); fixture.player.getInventory().setItem(5, new ItemStack(Items.WHEAT, 3)); ToolResult cake = PlayerCrafting.craft(fixture.player, UUID.randomUUID(), Items.CAKE); helper.assertTrue(cake.succeeded() && fixture.player.getInventory().countItem(Items.CAKE) == 1 && fixture.player.getInventory().countItem(Items.MILK_BUCKET) == 0 && fixture.player.getInventory().countItem(Items.BUCKET) == 3, "La receta devuelve los tres recipientes sin perderlos ni duplicarlos"); helper.succeed(); } finally { fixture.close(); } } @GameTest(template = "empty", batch = "playerops_mining", timeoutTicks = 100) public void canonicalMiningProducesOneDropAndToolWear(GameTestHelper helper) { Fixture fixture = create(helper); BlockPos target = helper.absolutePos(new BlockPos(4, 2, 3)); helper.getLevel().setBlock(target, Blocks.STONE.defaultBlockState(), 3); fixture.player.getInventory().setItem(0, new ItemStack(Items.WOODEN_PICKAXE)); PlayerOperations.Operation operation = PlayerOperations.mine(fixture.player, UUID.randomUUID(), target); ToolResult[] result = { null }; helper.onEachTick(() -> { try { result[0] = operation.tick(helper.getLevel().getServer().getTickCount()); } catch (RuntimeException failure) { operation.cancel(); fixture.close(); throw failure; } }); helper.runAfterDelay(60, () -> { try { helper.assertTrue(result[0] != null && result[0].succeeded() && helper.getLevel().getBlockState(target).isAir(), "El GameMode del jugador termina una rotura real"); int dropped = fixture.player.getInventory().countItem(Items.COBBLESTONE); for (ItemEntity item : helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(target).inflate(2))) if (item.getItem().is(Items.COBBLESTONE)) dropped += item.getItem().getCount(); helper.assertTrue(dropped == 1 && fixture.player.getInventory().getItem(0).getDamageValue() > 0, "El loot vanilla aparece una sola vez y desgasta el pico real"); helper.succeed(); } finally { operation.cancel(); fixture.close(); } }); } @GameTest(template = "empty", batch = "playerops_mining_veto", timeoutTicks = 100) public void lateBreakEventVetoLeavesBlockAndToolIntact(GameTestHelper helper) { Fixture fixture = create(helper); BlockPos target = helper.absolutePos(new BlockPos(4, 2, 3)); helper.getLevel().setBlock(target, Blocks.STONE.defaultBlockState(), 3); fixture.player.getInventory().setItem(0, new ItemStack(Items.WOODEN_PICKAXE)); PlayerOperations.Operation operation = PlayerOperations.mine(fixture.player, UUID.randomUUID(), target); operation.tick(helper.getLevel().getServer().getTickCount()); BreakVeto veto = new BreakVeto(fixture.id, target); // Se registra después del recibo; ambos listeners tienen LOWEST. MinecraftForge.EVENT_BUS.register(veto); ToolResult[] result = { null }; helper.onEachTick(() -> { try { result[0] = operation.tick(helper.getLevel().getServer().getTickCount()); } catch (RuntimeException failure) { MinecraftForge.EVENT_BUS.unregister(veto); operation.cancel(); fixture.close(); throw failure; } }); helper.runAfterDelay(60, () -> { try { helper.assertTrue(veto.events.get() > 0 && result[0] != null && !result[0].succeeded() && result[0].reason() == ToolResult.Reason.PERMISSION_DENIED && helper.getLevel().getBlockState(target).is(Blocks.STONE), "Respeta la cancelación final del evento Forge"); helper.assertTrue(fixture.player.getInventory().countItem(Items.COBBLESTONE) == 0 && fixture.player.getInventory().getItem(0).getDamageValue() == 0, "La rotura vetada no genera loot ni cobra durabilidad"); helper.succeed(); } finally { MinecraftForge.EVENT_BUS.unregister(veto); operation.cancel(); fixture.close(); } }); } // Fixtures y observación: nunca alteran recursos fuera de la arena de prueba. private static Fixture create(GameTestHelper helper) { for (int x = 0; x <= 13; x++) for (int z = 0; z <= 13; z++) helper.setBlock(new BlockPos(x, 1, z), Blocks.DIRT); UUID id = UUID.randomUUID(); String name = "Ops_" + id.toString().substring(0, 8); var server = helper.getLevel().getServer(); AgentRegistry registry = AgentRegistry.get(server); BlockPos pos = helper.absolutePos(new BlockPos(3, 2, 3)); registry.register(id, name, null, GlobalPos.of(helper.getLevel().dimension(), pos), "quieto"); registry.markState(id, AgentState.PAUSED); try { ServerPlayer player = PlayerSessions.create(server, id, name, helper.getLevel(), pos); player.setOnGround(true); return new Fixture(helper, id, player); } catch (RuntimeException failure) { registry.markState(id, AgentState.RETIRED); throw failure; } } private static int chestCount(ChestBlockEntity chest) { int total = 0; for (int slot = 0; slot < chest.getContainerSize(); slot++) total += chest.getItem(slot).getCount(); return total; } private record Fixture(GameTestHelper helper, UUID id, ServerPlayer player) { void close() { PlayerSessions.remove(helper.getLevel().getServer(), id); AgentRegistry.get(helper.getLevel().getServer()).markState(id, AgentState.RETIRED); } } public static final class PlacementVeto { private final UUID actor; private final BlockPos pos; final AtomicInteger events = new AtomicInteger(); PlacementVeto(UUID actor, BlockPos pos) { this.actor = actor; this.pos = pos; } @SubscribeEvent(priority = EventPriority.HIGHEST) public void veto(BlockEvent.EntityPlaceEvent event) { if (event.getEntity() != null && event.getEntity().getUUID().equals(actor) && event.getPos().equals(pos)) { events.incrementAndGet(); event.setCanceled(true); } } } public static final class InteractionVeto { private final UUID actor; private final BlockPos pos; final AtomicInteger events = new AtomicInteger(); InteractionVeto(UUID actor, BlockPos pos) { this.actor = actor; this.pos = pos; } @SubscribeEvent(priority = EventPriority.HIGHEST) public void veto(PlayerInteractEvent.RightClickBlock event) { if (event.getEntity().getUUID().equals(actor) && event.getPos().equals(pos)) { events.incrementAndGet(); event.setCanceled(true); } } } public static final class BreakVeto { private final UUID actor; private final BlockPos pos; final AtomicInteger events = new AtomicInteger(); BreakVeto(UUID actor, BlockPos pos) { this.actor = actor; this.pos = pos; } @SubscribeEvent(priority = EventPriority.LOWEST) public void veto(BlockEvent.BreakEvent event) { if (event.getPlayer().getUUID().equals(actor) && event.getPos().equals(pos)) { events.incrementAndGet(); event.setCanceled(true); } } } }