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