package ar.com.companeros.tools; import ar.com.companeros.Companeros; import ar.com.companeros.body.PlayerSessions; import ar.com.companeros.identity.*; import ar.com.companeros.runtime.BuiltinTools; import ar.com.companeros.world.WorldPrimitiveExecutor; import com.google.gson.*; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import net.minecraft.core.BlockPos; import net.minecraft.core.GlobalPos; import net.minecraft.gametest.framework.*; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.*; import net.minecraft.world.level.block.Blocks; import net.minecraftforge.gametest.*; /** Pruebas de aprendizaje: efectos físicos, certificación y archivos persistentes. */ @GameTestHolder(Companeros.ID) @PrefixGameTestTemplate(false) public final class ToolLifecycleTests { private static final UUID AUTHOR = UUID.fromString("00000000-0000-0000-0000-000000000912"); private static final Set PERMISSIONS = Set.copyOf(EnumSet.allOf(ToolPermission.class)); @GameTest(template = "empty") public void pendingSandboxRejectsExternalCapabilitiesAndVersionReplacement(GameTestHelper helper) throws Exception { Path folder = folder(); ToolRegistry registry = registry(folder); ToolLearningService learning = new ToolLearningService(registry); ToolManifest source = manifest("research_bundle", "inspect", new JsonObject(), ToolPermission.WORLD_READ); learning.propose(AUTHOR, source.toJson().toString()); JsonObject changed = source.toJson(); changed.addProperty("description", "Contenido sustituido"); rejected(helper, () -> learning.propose(AUTHOR, changed.toString()), "La misma versión pendiente no cambia de contenido"); JsonObject web = new JsonObject(); web.addProperty("query", "Minecraft"); rejected(helper, () -> learning.propose(AUTHOR, manifest("network_bundle", "research_web", web, ToolPermission.WORLD_READ).toJson().toString()), "Una composición no puede activar búsquedas ni servicios externos"); JsonObject proposal = new JsonObject(); proposal.addProperty("manifest", source.toJson().toString()); rejected(helper, () -> learning.propose(AUTHOR, manifest("recursive_bundle", "propose_tool", proposal, ToolPermission.WORLD_READ).toJson().toString()), "No permite propuestas que generen recursivamente otras herramientas"); rejected(helper, () -> learning.propose(AUTHOR, manifest("shell_bundle", "shell", new JsonObject(), ToolPermission.WORLD_READ).toJson().toString()), "No existen primitivas de sistema operativo"); rejected(helper, () -> learning.propose(AUTHOR, manifest("inspect", "inspect", new JsonObject(), ToolPermission.WORLD_READ).toJson().toString()), "No puede suplantar las herramientas instaladas por el mod"); helper.assertTrue(registry.certifiedTools().stream().noneMatch(tool -> tool.id().equals(source.id())), "La validación estructural no equivale a ejecutar o certificar"); ToolLearningService restored = new ToolLearningService(registry(folder)); helper.assertTrue(restored.pending(AUTHOR, source.id(), 1).isPresent() && restored.statistics(source.sha256()).successes() == 0, "La propuesta se recupera con autor propio y ninguna prueba inventada"); helper.succeed(); } @GameTest(template = "empty") public void alteredLedgerAndMissingSigningKeyGrantNoCertification(GameTestHelper helper) throws Exception { Path folder = folder(); ToolRegistry registry = registry(folder); ToolLearningService learning = new ToolLearningService(registry); ToolManifest tool = manifest("tamper_bundle", "inspect", new JsonObject(), ToolPermission.WORLD_READ); learning.propose(AUTHOR, tool.toJson().toString()); Path ledger = folder.resolve("learning.json"); String original = Files.readString(ledger, StandardCharsets.UTF_8); JsonObject altered = JsonParser.parseString(original).getAsJsonObject(); JsonObject row = altered.getAsJsonObject("content").getAsJsonArray("tools").get(0).getAsJsonObject(); row.addProperty("successes", 3); row.addProperty("certified", true); Files.writeString(ledger, altered.toString(), StandardCharsets.UTF_8); ToolRegistry clean = registry(folder); boolean blocked = false; try { new ToolLearningService(clean); } catch (IOException expected) { blocked = true; } helper.assertTrue(blocked && clean.certifiedTools().stream().noneMatch(value -> value.id().equals(tool.id())), "Modificar contadores o aprobación rompe la firma y no concede una capacidad"); Files.writeString(ledger, original, StandardCharsets.UTF_8); Files.move(folder.resolve("certification.key"), folder.resolve("original.key")); blocked = false; try { new ToolLearningService(registry(folder)); } catch (IOException expected) { blocked = true; } helper.assertTrue(blocked && !Files.exists(folder.resolve("certification.key")), "Un registro existente no se firma automáticamente con una clave reemplazada"); helper.succeed(); } @GameTest(batch = "learning_01_real_trials", template = "empty", timeoutTicks = 60) public void realCraftTrialsConsumeResourcesPromoteRestoreAndShare(GameTestHelper helper) throws Exception { MinecraftServer server = helper.getLevel().getServer(); UUID author = UUID.randomUUID(), other = UUID.randomUUID(); Path folder = folder(); try { ServerPlayer player = fixture(helper, author); player.getInventory().add(new ItemStack(Items.OAK_LOG, 3)); ToolRegistry registry = registry(folder); ToolLearningService learning = new ToolLearningService(registry); JsonObject args = new JsonObject(); args.addProperty("item", "minecraft:oak_planks"); args.addProperty("count", 4); ToolManifest source = authored(manifest("plank_bundle", "craft", args, ToolPermission.CRAFT), author); ToolRegistry.Proposal proposal = learning.propose(author, source.toJson().toString()); for (int trial = 0; trial < 3; trial++) { ToolExecution execution = learning.startTrial(proposal.manifest().sha256(), context(server, author), server, result -> { }); helper.assertTrue(execution.tick(server.getTickCount()) == ToolExecution.Status.SUCCEEDED, "La prueba fabrica usando la receta y el inventario del jugador registrado"); helper.assertTrue(learning.reportTrial(execution).successes() == trial + 1, "El servidor conserva un recibo distinto por prueba y no acepta autoaprobación"); execution.tick(server.getTickCount()); execution.cancel(); } helper.assertTrue(count(player, Items.OAK_LOG) == 0 && count(player, Items.OAK_PLANKS) == 12, "Tres pruebas gastan tres troncos reales y producen doce tablas sin recursos mágicos"); helper.assertTrue(learning.statistics(source.sha256()).certified() && learning.trialTools(author).isEmpty(), "Tres éxitos útiles promueven sólo la composición del hash observado"); JsonObject pipeline = source.toJson(); pipeline.addProperty("id", "plank_pipeline"); JsonObject pinned = new JsonObject(); pinned.addProperty("id", source.id()); pinned.addProperty("version", 1); pinned.addProperty("sha256", source.sha256()); JsonArray dependencies = new JsonArray(); dependencies.add(pinned); pipeline.add("dependencies", dependencies); JsonObject call = new JsonObject(); call.addProperty("tool", source.id()); call.addProperty("version", 1); call.addProperty("sha256", source.sha256()); call.addProperty("timeoutTicks", 80); call.add("args", new JsonObject()); JsonArray calls = new JsonArray(); calls.add(call); pipeline.add("steps", calls); ToolManifest nested = learning.propose(author, pipeline.toString()).manifest(); player.getInventory().add(new ItemStack(Items.OAK_LOG)); ToolExecution nestedTrial = learning.startTrial(nested.sha256(), context(server, author), server, result -> { }); helper.assertTrue(nestedTrial.tick(server.getTickCount()) == ToolExecution.Status.SUCCEEDED && count(player, Items.OAK_LOG) == 0 && count(player, Items.OAK_PLANKS) == 16, "Otra herramienta puede componer una dependencia certificada del hash exacto y consumir sus materiales reales"); ToolRegistry restoredRegistry = registry(folder); ToolLearningService restored = new ToolLearningService(restoredRegistry); helper.assertTrue(restored.statistics(source.sha256()).successes() == 3 && restored.statistics(source.sha256()).certified() && restoredRegistry.certifiedTools().stream().anyMatch(tool -> tool.sha256().equals(source.sha256())), "El reinicio verifica firma, hash y evidencia antes de recuperar la herramienta certificada"); helper.assertTrue(restored.pending(author, "plank_pipeline", 1).isPresent() && restored.statistics(nested.sha256()).successes() == 1, "La dependencia aprobada se restaura antes de cargar la composición todavía pendiente"); cleanup(server, author); ServerPlayer peer = fixture(helper, other); peer.getInventory().add(new ItemStack(Items.OAK_LOG)); ToolExecution shared = restoredRegistry.start(source.id(), 1, context(server, other), new WorldPrimitiveExecutor(server, result -> { })); helper.assertTrue(shared.tick(server.getTickCount()) == ToolExecution.Status.SUCCEEDED && count(peer, Items.OAK_LOG) == 0 && count(peer, Items.OAK_PLANKS) == 4, "Otro bot reutiliza la capacidad aprobada con sus propios materiales"); helper.assertTrue(restored.statistics(source.sha256()).successes() == 3, "La reutilización normal no falsifica las pruebas de certificación originales"); helper.succeed(); } finally { cleanup(server, author); cleanup(server, other); } } @GameTest(batch = "learning_02_noop_trials", template = "empty", timeoutTicks = 60) public void repeatedInspectDoesNotLearnAndOtherActorsCannotTrial(GameTestHelper helper) throws Exception { MinecraftServer server = helper.getLevel().getServer(); UUID actor = UUID.randomUUID(); try { fixture(helper, actor); ToolRegistry registry = registry(folder()); ToolLearningService learning = new ToolLearningService(registry); ToolManifest source = authored(manifest("noop_bundle", "inspect", new JsonObject(), ToolPermission.WORLD_READ), actor); learning.propose(actor, source.toJson().toString()); for (int index = 0; index < 3; index++) { ToolExecution execution = learning.startTrial(source.sha256(), context(server, actor), server, result -> { }); helper.assertTrue(execution.tick(server.getTickCount()) == ToolExecution.Status.SUCCEEDED, "Una observación puede terminar correctamente sin progreso útil nuevo"); } helper.assertTrue(learning.statistics(source.sha256()).successes() == 0 && learning.statistics(source.sha256()).failures() == 3 && !learning.statistics(source.sha256()).certified(), "Repetir inspect no concede una certificación sin utilidad comprobada"); rejected(helper, () -> learning.startTrial(source.sha256(), context(server, UUID.randomUUID()), server, result -> { }), "Las propuestas pendientes sólo pueden probarse por su autor"); JsonObject noPermission = new JsonObject(); rejected(helper, () -> learning.startTrial(source.sha256(), new ToolRegistry.ExecutionContext(actor, server.getTickCount(), Set.of(), noPermission), server, result -> { }), "No prueba sin permisos declarados"); ToolExecution ordinary = registry.start("inspect", 1, context(server, actor), new WorldPrimitiveExecutor(server, result -> { })); ordinary.tick(server.getTickCount()); rejected(helper, () -> learning.reportTrial(ordinary), "Una ejecución normal no se hace pasar por evidencia de aprendizaje"); helper.succeed(); } finally { cleanup(server, actor); } } @GameTest(batch = "learning_03_failed_and_cancelled", template = "empty", timeoutTicks = 60) public void resourceFailureCancellationAndRoleArePersistent(GameTestHelper helper) throws Exception { MinecraftServer server = helper.getLevel().getServer(); UUID actor = UUID.randomUUID(); Path folder = folder(); try { ServerPlayer player = fixture(helper, actor); ToolRegistry registry = registry(folder); ToolLearningService learning = new ToolLearningService(registry); JsonObject args = new JsonObject(); args.addProperty("item", "minecraft:oak_planks"); args.addProperty("count", 4); ToolManifest source = authored(manifest("failure_bundle", "craft", args, ToolPermission.CRAFT), actor); learning.propose(actor, source.toJson().toString()); ToolExecution missing = learning.startTrial(source.sha256(), context(server, actor), server, result -> { }); helper.assertTrue(missing.tick(server.getTickCount()) == ToolExecution.Status.FAILED && count(player, Items.OAK_PLANKS) == 0, "Una prueba sin troncos falla sin inventar la materia prima"); ToolExecution cancelled = learning.startTrial(source.sha256(), context(server, actor), server, result -> { }); cancelled.cancel(); cancelled.cancel(); cancelled.tick(server.getTickCount()); helper.assertTrue(learning.statistics(source.sha256()).failures() == 1 && learning.statistics(source.sha256()).cancellations() == 1, "Cada fallo y cancelación se registra una sola vez"); ToolLearningService restored = new ToolLearningService(registry(folder)); helper.assertTrue(restored.statistics(source.sha256()).failures() == 1 && restored.statistics(source.sha256()).cancellations() == 1, "Fallos y cancelaciones sobreviven al reinicio sin convertirlos en éxitos"); AgentRoles.get(server).assign(actor, AgentRoles.Role.INVESTIGADOR); rejected(helper, () -> restored.startTrial(source.sha256(), context(server, actor), server, result -> { }), "El investigador no evade su especialización mediante una herramienta compuesta"); AgentRoles.get(server).assign(actor, AgentRoles.Role.GENERALISTA); player.getInventory().add(new ItemStack(Items.OAK_LOG)); ToolExecution once = restored.startTrial(source.sha256(), context(server, actor), server, result -> { }); rejected(helper, () -> restored.startTrial(source.sha256(), context(server, actor), server, result -> { }), "Cada bot dispone como máximo de una prueba simultánea"); once.cancel(); helper.succeed(); } finally { cleanup(server, actor); } } // Cada prueba física usa un batch separado porque el servidor GameTest admite un solo jugador. private static ServerPlayer fixture(GameTestHelper helper, UUID actor) { 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); BlockPos pos = helper.absolutePos(new BlockPos(3, 2, 3)); MinecraftServer server = helper.getLevel().getServer(); AgentRegistry.get(server).register(actor, name(actor), null, GlobalPos.of(helper.getLevel().dimension(), pos), "quieto"); AgentRegistry.get(server).markState(actor, AgentState.ACTIVE); return PlayerSessions.create(server, actor, name(actor), helper.getLevel(), pos); } private static void cleanup(MinecraftServer server, UUID actor) { PlayerSessions.remove(server, actor); AgentRegistry.get(server).find(actor).ifPresent(identity -> AgentRegistry.get(server).markState(actor, AgentState.RETIRED)); } private static String name(UUID id) { return "Tl_" + id.toString().substring(0, 8); } private static Path folder() { return Path.of("tool-learning-test-output", UUID.randomUUID().toString()).toAbsolutePath(); } private static ToolRegistry registry(Path folder) throws IOException { ToolRegistry registry = new ToolRegistry(folder); BuiltinTools.install(registry); return registry; } private static ToolRegistry.ExecutionContext context(MinecraftServer server, UUID actor) { return new ToolRegistry.ExecutionContext(actor, server.getTickCount(), PERMISSIONS, new JsonObject()); } private static ToolManifest authored(ToolManifest template, UUID author) { return ToolManifest.parse(template.toJson().toString(), author); } private static ToolManifest manifest(String id, String primitive, JsonObject args, ToolPermission permission) { JsonObject source = new JsonObject(); source.addProperty("schema", 1); source.addProperty("id", id); source.addProperty("version", 1); source.addProperty("description", "Composición de prueba con efectos reales"); JsonArray permissions = new JsonArray(); permissions.add(permission.name()); source.add("permissions", permissions); source.add("dependencies", new JsonArray()); JsonObject limits = new JsonObject(); limits.addProperty("maxTicks", 100); limits.addProperty("maxSteps", 4); source.add("limits", limits); JsonObject step = new JsonObject(); step.addProperty("primitive", primitive); step.addProperty("timeoutTicks", 80); step.add("args", args); JsonArray steps = new JsonArray(); steps.add(step); source.add("steps", steps); return ToolManifest.parse(source.toString(), AUTHOR); } private static int count(ServerPlayer player, Item item) { int count = 0; for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) if (player.getInventory().getItem(slot).is(item)) count += player.getInventory().getItem(slot).getCount(); return count; } private static void rejected(GameTestHelper helper, Throwing action, String message) throws Exception { boolean rejected = false; try { action.run(); } catch (IllegalArgumentException expected) { rejected = true; } helper.assertTrue(rejected, message); } private interface Throwing { void run() throws Exception; } }