Volver al índice

src/main/java/ar/com/companeros/PlayerOperationTests.java

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);
            }
        }
    }
}