Volver al índice

src/main/java/ar/com/companeros/flags/BannerActionsTests.java

package ar.com.companeros.flags;

import ar.com.companeros.Companeros;
import ar.com.companeros.WorkArea;
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 com.google.gson.JsonObject;
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.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BannerBlockEntity;
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;

/** Conservación de materiales, diseños acotados y protección usando un único jugador por batch. */
@GameTestHolder(Companeros.ID)
@PrefixGameTestTemplate(false)
public final class BannerActionsTests {
    @GameTest(template = "empty", batch = "flags_01_materials")
    public void realRecipeAndDyesProduceExactlyOneAuthoredBanner(GameTestHelper helper) {
        Fixture fixture = fixture(helper);
        try {
            stations(helper);
            materials(fixture.player, 1);
            UUID action = UUID.randomUUID();
            ToolResult result = BannerActions.create(fixture.player, action, design());
            helper.assertTrue(result.succeeded() && fixture.player.getInventory().countItem(Items.RED_BANNER) == 1
                && fixture.player.getInventory().countItem(Items.RED_WOOL) == 0
                && fixture.player.getInventory().countItem(Items.STICK) == 0
                && fixture.player.getInventory().countItem(Items.WHITE_DYE) == 0
                && fixture.player.getInventory().countItem(Items.BLACK_DYE) == 0,
                "La bandera cuesta seis lanas, un palo y un tinte por cada patrón");
            helper.assertTrue(result.inventoryDelta().getOrDefault("minecraft:red_wool", 0) == -6
                && result.inventoryDelta().getOrDefault("minecraft:red_banner", 0) == 1,
                "El resultado registra el cambio material observado");
            ItemStack flag = flag(fixture.player);
            var metadata = flag.getTag().getCompound(BannerActions.METADATA_TAG);
            helper.assertTrue(metadata.getInt("Schema") == BannerActions.SCHEMA_VERSION
                && metadata.getUUID("Author").equals(fixture.id) && metadata.getUUID("Action").equals(action)
                && BannerActions.designOf(flag).isPresent() && BannerBlockEntity.getPatternCount(flag) == 2,
                "El item real conserva autor, versión, diseño, círculo y símbolo");
            helper.succeed();
        } finally { fixture.close(); }
    }

    @GameTest(template = "empty", batch = "flags_02_validation")
    public void absentStationsIngredientsAndInvalidDesignNeverSpendResources(GameTestHelper helper) {
        Fixture fixture = fixture(helper);
        try {
            materials(fixture.player, 1);
            var before = PlayerCrafting.counts(fixture.player);
            ToolResult noStation = BannerActions.create(fixture.player, UUID.randomUUID(), design());
            helper.assertTrue(noStation.reason() == ToolResult.Reason.WRONG_TOOL && PlayerCrafting.counts(fixture.player).equals(before),
                "La fabricación 3×3 y el teñido necesitan mesa y telar reales");
            stations(helper);
            JsonObject arbitraryTag = design(); arbitraryTag.addProperty("nbt", "{Unrestricted:1}");
            JsonObject wrongColor = design(); wrongColor.addProperty("baseColor", "ultraviolet");
            JsonObject unknownSymbol = design(); unknownSymbol.addProperty("symbol", "arbitrary_texture");
            JsonObject noCircle = design(); noCircle.remove("circleColor");
            for (JsonObject invalid : new JsonObject[] { arbitraryTag, wrongColor, unknownSymbol, noCircle }) {
                ToolResult rejected = BannerActions.create(fixture.player, UUID.randomUUID(), invalid);
                helper.assertTrue(rejected.reason() == ToolResult.Reason.INVALID_ARGUMENT
                    && PlayerCrafting.counts(fixture.player).equals(before), "Un diseño inválido no consume materiales ni acepta NBT libre");
            }
            fixture.player.getInventory().setItem(3, ItemStack.EMPTY);
            before = PlayerCrafting.counts(fixture.player);
            ToolResult missingDye = BannerActions.create(fixture.player, UUID.randomUUID(), design());
            helper.assertTrue(missingDye.reason() == ToolResult.Reason.RESOURCE_EXHAUSTED
                && PlayerCrafting.counts(fixture.player).equals(before), "La falta de un tinte conserva también lana y palo");
            fixture.player.getInventory().setItem(3, new ItemStack(Items.BLACK_DYE));
            InteractionVeto veto = new InteractionVeto(fixture.id, helper.absolutePos(new BlockPos(4, 2, 3)));
            MinecraftForge.EVENT_BUS.register(veto);
            try {
                before = PlayerCrafting.counts(fixture.player);
                ToolResult denied = BannerActions.create(fixture.player, UUID.randomUUID(), design());
                helper.assertTrue(veto.events.get() > 0 && denied.reason() == ToolResult.Reason.PERMISSION_DENIED
                    && PlayerCrafting.counts(fixture.player).equals(before), "Un veto de uso de la mesa impide fabricar sin cobrar recursos");
            } finally { MinecraftForge.EVENT_BUS.unregister(veto); }
            helper.succeed();
        } finally { fixture.close(); }
    }

    @GameTest(template = "empty", batch = "flags_03_capacity")
    public void fullInventoryRejectsTheWholeTransaction(GameTestHelper helper) {
        Fixture fixture = fixture(helper);
        try {
            stations(helper);
            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.RED_WOOL, 64));
            fixture.player.getInventory().setItem(1, new ItemStack(Items.STICK, 64));
            fixture.player.getInventory().setItem(2, new ItemStack(Items.WHITE_DYE, 64));
            fixture.player.getInventory().setItem(3, new ItemStack(Items.BLACK_DYE, 64));
            var before = PlayerCrafting.counts(fixture.player);
            ToolResult result = BannerActions.create(fixture.player, UUID.randomUUID(), design());
            helper.assertTrue(result.reason() == ToolResult.Reason.INVENTORY_FULL
                && PlayerCrafting.counts(fixture.player).equals(before) && fixture.player.getInventory().countItem(Items.RED_BANNER) == 0,
                "El resultado que no cabe conserva todos los ingredientes y no arroja regalos al mundo");
            helper.succeed();
        } finally { fixture.close(); }
    }

    @GameTest(template = "empty", batch = "flags_04_placement")
    public void placementRetainsPatternsAndForgeVetoRestoresTheRealStack(GameTestHelper helper) {
        Fixture fixture = fixture(helper);
        BlockPos target = helper.absolutePos(new BlockPos(4, 2, 3));
        try {
            stations(helper); materials(fixture.player, 3);
            for (int flag = 0; flag < 3; flag++) helper.assertTrue(BannerActions.create(fixture.player, UUID.randomUUID(), design()).succeeded(), "Prepara cada bandera pagando sus materiales");
            helper.setBlock(new BlockPos(4, 2, 3), Blocks.AIR);
            helper.setBlock(new BlockPos(3, 2, 4), Blocks.AIR);
            ItemStack offered = flag(fixture.player).copy();
            ToolResult placed = BannerActions.place(fixture.player, UUID.randomUUID(), position(target));
            helper.assertTrue(placed.succeeded() && fixture.player.getInventory().countItem(Items.RED_BANNER) == 2
                && helper.getLevel().getBlockEntity(target) instanceof BannerBlockEntity,
                "Coloca una bandera real y cobra exactamente un item del jugador");
            BannerBlockEntity entity = (BannerBlockEntity) helper.getLevel().getBlockEntity(target);
            helper.assertTrue(BannerActions.designOf(offered).orElseThrow().patterns().equals(BannerBlockEntity.getItemPatterns(entity.getItem()))
                && entity.getPersistentData().getCompound(BannerActions.METADATA_TAG).getUUID("Author").equals(fixture.id),
                "Los patrones y el autor se conservan en la bandera física");
            helper.getLevel().setBlock(target, Blocks.AIR.defaultBlockState(), 3);
            var before = PlayerCrafting.counts(fixture.player);
            PlacementVeto placement = new PlacementVeto(fixture.id, target);
            MinecraftForge.EVENT_BUS.register(placement);
            try {
                ToolResult denied = BannerActions.place(fixture.player, UUID.randomUUID(), position(target));
                helper.assertTrue(placement.events.get() > 0 && !denied.succeeded() && helper.getLevel().getBlockState(target).isAir()
                    && PlayerCrafting.counts(fixture.player).equals(before), "El veto Forge revierte el bloque y conserva la pila original");
            } finally { MinecraftForge.EVENT_BUS.unregister(placement); }
            InteractionVeto interaction = new InteractionVeto(fixture.id, target.below());
            MinecraftForge.EVENT_BUS.register(interaction);
            try {
                ToolResult denied = BannerActions.place(fixture.player, UUID.randomUUID(), position(target));
                helper.assertTrue(interaction.events.get() > 0 && !denied.succeeded() && helper.getLevel().getBlockState(target).isAir()
                    && PlayerCrafting.counts(fixture.player).equals(before), "El veto de clic impide colocar sin gastar la bandera");
            } finally { MinecraftForge.EVENT_BUS.unregister(interaction); }
            WorkArea.get(helper.getLevel()).protect(target);
            ToolResult protectedArea = BannerActions.place(fixture.player, UUID.randomUUID(), position(target));
            helper.assertTrue(protectedArea.reason() == ToolResult.Reason.PERMISSION_DENIED
                && helper.getLevel().getBlockState(target).isAir() && PlayerCrafting.counts(fixture.player).equals(before),
                "Una posición protegida no se sobrescribe ni consume la bandera");
            helper.succeed();
        } finally { fixture.close(); }
    }

    @GameTest(template = "empty", batch = "flags_05_reach")
    public void outOfReachAndOccludedPlacementHaveNoWorldEffect(GameTestHelper helper) {
        Fixture fixture = fixture(helper);
        try {
            stations(helper); materials(fixture.player, 1);
            helper.assertTrue(BannerActions.create(fixture.player, UUID.randomUUID(), design()).succeeded(), "Fabrica el item de prueba con recursos");
            helper.setBlock(new BlockPos(4, 2, 3), Blocks.AIR);
            helper.setBlock(new BlockPos(3, 2, 4), Blocks.AIR);
            var before = PlayerCrafting.counts(fixture.player);
            BlockPos far = helper.absolutePos(new BlockPos(8, 2, 3));
            ToolResult distant = BannerActions.place(fixture.player, UUID.randomUUID(), position(far));
            helper.assertTrue(distant.reason() == ToolResult.Reason.OUT_OF_REACH && helper.getLevel().getBlockState(far).isAir(),
                "No coloca una bandera a más de cuatro bloques");
            BlockPos near = helper.absolutePos(new BlockPos(5, 2, 3));
            for (int y = 2; y <= 4; y++) helper.setBlock(new BlockPos(4, y, 3), Blocks.STONE);
            ToolResult occluded = BannerActions.place(fixture.player, UUID.randomUUID(), position(near));
            helper.assertTrue(occluded.reason() == ToolResult.Reason.NO_LINE_OF_SIGHT && helper.getLevel().getBlockState(near).isAir()
                && PlayerCrafting.counts(fixture.player).equals(before), "Una pared bloquea la colocación y deja intacto el inventario");
            JsonObject fraction = position(near); fraction.addProperty("x", near.getX() + .5);
            helper.assertTrue(BannerActions.place(fixture.player, UUID.randomUUID(), fraction).reason() == ToolResult.Reason.INVALID_ARGUMENT,
                "Las coordenadas fraccionarias no se redondean hacia otro bloque");
            helper.succeed();
        } finally { fixture.close(); }
    }

    @GameTest(template = "empty", batch = "flags_06_loot")
    public void realBlockLootRetainsOnlyValidatedAuthorshipAndCanBePlacedAgain(GameTestHelper helper) {
        Fixture fixture = fixture(helper);
        BlockPos source = helper.absolutePos(new BlockPos(4, 2, 3));
        BlockPos destination = helper.absolutePos(new BlockPos(5, 2, 3));
        try {
            stations(helper); materials(fixture.player, 1);
            helper.assertTrue(BannerActions.create(fixture.player, UUID.randomUUID(), design()).succeeded(), "Fabrica la bandera original usando materiales reales");
            var originalMetadata = flag(fixture.player).getTag().getCompound(BannerActions.METADATA_TAG).copy();
            helper.setBlock(new BlockPos(4, 2, 3), Blocks.AIR);
            helper.setBlock(new BlockPos(3, 2, 4), Blocks.AIR);
            helper.assertTrue(BannerActions.place(fixture.player, UUID.randomUUID(), position(source)).succeeded(), "La bandera sale del inventario y existe en el mundo");
            BannerBlockEntity entity = (BannerBlockEntity) helper.getLevel().getBlockEntity(source);
            entity.getPersistentData().putString("UnrelatedData", "No debe transferirse");
            var saved = entity.saveWithFullMetadata();
            helper.assertTrue(saved.getCompound("ForgeData").getCompound(BannerActions.METADATA_TAG).equals(originalMetadata),
                "La autoría se serializa como datos persistentes del bloque");
            var drops = Block.getDrops(helper.getLevel().getBlockState(source), helper.getLevel(), source, entity, fixture.player, ItemStack.EMPTY);
            helper.assertTrue(drops.stream().mapToInt(ItemStack::getCount).sum() == 1 && drops.size() == 1
                && BannerActions.designOf(drops.get(0)).isPresent()
                && drops.get(0).getTag().getCompound(BannerActions.METADATA_TAG).equals(originalMetadata)
                && !drops.get(0).getTag().contains("UnrelatedData") && !drops.get(0).getTag().contains("ForgeData"),
                "El loot real conserva exactamente una bandera con su autoría y no rescata NBT ajeno");

            // La metadata corrupta no se legitima sólo por estar almacenada en ForgeData.
            for (String invalidField : new String[] { "Schema", "Author", "Action" }) {
                var invalid = originalMetadata.copy();
                if (invalidField.equals("Schema")) invalid.putInt("Schema", 999);
                else invalid.putString(invalidField, "not-a-uuid");
                entity.getPersistentData().put(BannerActions.METADATA_TAG, invalid);
                var refused = Block.getDrops(helper.getLevel().getBlockState(source), helper.getLevel(), source, entity, fixture.player, ItemStack.EMPTY);
                helper.assertTrue(refused.stream().mapToInt(ItemStack::getCount).sum() == 1
                    && refused.stream().noneMatch(stack -> stack.hasTag() && stack.getTag().contains(BannerActions.METADATA_TAG)),
                    "El loot conserva el item vanilla pero rechaza autor, acción o versión inválidos");
            }
            var mismatched = originalMetadata.copy();
            mismatched.getCompound("Design").putString("circleColor", "blue");
            entity.getPersistentData().put(BannerActions.METADATA_TAG, mismatched);
            var refused = Block.getDrops(helper.getLevel().getBlockState(source), helper.getLevel(), source, entity, fixture.player, ItemStack.EMPTY);
            helper.assertTrue(refused.stream().noneMatch(stack -> stack.hasTag() && stack.getTag().contains(BannerActions.METADATA_TAG)),
                "Un diseño que no coincide con los patrones físicos no se copia al drop");
            entity.getPersistentData().put(BannerActions.METADATA_TAG, originalMetadata.copy());

            // El fixture recoge el único drop real después de retirar el bloque original.
            helper.getLevel().setBlock(source, Blocks.AIR.defaultBlockState(), 3);
            helper.assertTrue(fixture.player.getInventory().add(drops.get(0).copy())
                && fixture.player.getInventory().countItem(Items.RED_BANNER) == 1, "Recoge el resultado del loot sin generar otro item");
            ToolResult reused = BannerActions.place(fixture.player, UUID.randomUUID(), position(destination));
            helper.assertTrue(reused.succeeded() && fixture.player.getInventory().countItem(Items.RED_BANNER) == 0
                && helper.getLevel().getBlockEntity(source) == null
                && helper.getLevel().getBlockEntity(destination) instanceof BannerBlockEntity,
                "La misma bandera puede volver a colocarse, con un solo ejemplar entre mundo e inventario");
            BannerBlockEntity replaced = (BannerBlockEntity) helper.getLevel().getBlockEntity(destination);
            helper.assertTrue(replaced.getPersistentData().getCompound(BannerActions.METADATA_TAG).equals(originalMetadata),
                "La segunda colocación conserva la autoría de la fabricación original");
            helper.succeed();
        } finally { fixture.close(); }
    }

    // Los recursos pertenecen exclusivamente al fixture; nunca se crean para agentes del experimento.
    private static Fixture fixture(GameTestHelper helper) {
        PlayerSessions.install();
        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 = "Flag_" + id.toString().substring(0, 8);
        var server = helper.getLevel().getServer();
        BlockPos pos = helper.absolutePos(new BlockPos(3, 2, 3));
        AgentRegistry registry = AgentRegistry.get(server);
        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 void stations(GameTestHelper helper) {
        helper.setBlock(new BlockPos(4, 2, 3), Blocks.CRAFTING_TABLE);
        helper.setBlock(new BlockPos(3, 2, 4), Blocks.LOOM);
    }
    private static void materials(ServerPlayer player, int flags) {
        player.getInventory().setItem(0, new ItemStack(Items.RED_WOOL, 6 * flags));
        player.getInventory().setItem(1, new ItemStack(Items.STICK, flags));
        player.getInventory().setItem(2, new ItemStack(Items.WHITE_DYE, flags));
        player.getInventory().setItem(3, new ItemStack(Items.BLACK_DYE, flags));
    }
    private static JsonObject design() {
        JsonObject design = new JsonObject();
        design.addProperty("baseColor", "red"); design.addProperty("circleColor", "white");
        design.addProperty("symbol", "stripe_center"); design.addProperty("symbolColor", "black");
        return design;
    }
    private static JsonObject position(BlockPos pos) {
        JsonObject result = new JsonObject(); result.addProperty("x", pos.getX()); result.addProperty("y", pos.getY()); result.addProperty("z", pos.getZ()); return result;
    }
    private static ItemStack flag(ServerPlayer player) {
        for (ItemStack stack : player.getInventory().items) if (BannerActions.designOf(stack).isPresent()) return stack;
        throw new IllegalStateException("No hay bandera diseñada en el fixture");
    }
    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 {
        final UUID actor; 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 deny(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 {
        final UUID actor; 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 deny(PlayerInteractEvent.RightClickBlock event) {
            if (event.getEntity().getUUID().equals(actor) && event.getPos().equals(pos)) {
                events.incrementAndGet(); event.setCanceled(true);
            }
        }
    }
}