Volver al índice

src/main/java/ar/com/companeros/tools/ToolLifecycleTests.java

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