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