Volver al índice

src/main/java/ar/com/companeros/horror/tools/InfectedTools.java

package ar.com.companeros.horror.tools;

import ar.com.companeros.decision.ToolCapability;
import ar.com.companeros.horror.*;
import ar.com.companeros.tools.ToolManifest;
import com.google.gson.*;
import com.mojang.logging.LogUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.*;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.fml.loading.FMLPaths;

/** Aprendizaje corporal: JSON validado, acciones del cuerpo y evidencia real antes de compartir. */
public final class InfectedTools {
    public enum Outcome { SUCCEEDED, FAILED, CANCELLED }
    private static final Map<MinecraftServer, InfectedTools> SERVICES = new WeakHashMap<>();
    private static final int MAX_ENTRIES = 64, MAX_LEDGER_BYTES = 1_048_576, AUTO_COOLDOWN = 1200;
    private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
    private final Path directory;
    private final byte[] signingKey;
    private final Map<String, Learned> entries = new LinkedHashMap<>();
    private Execution active;
    private UUID observedBody, lastVisibleTarget;
    private Vec3 lastPosition;
    private float lastHealth;
    private int blockedTicks;
    private long nextAutomaticAt;

    // En producción la ruta es fija. El constructor con ruta es únicamente para pruebas internas.
    InfectedTools(Path folder) throws IOException {
        directory = folder.toAbsolutePath().normalize(); checkDirectory(); Files.createDirectories(directory); checkDirectory();
        signingKey = readOrCreateKey(); restore();
    }
    public static InfectedTools get(MinecraftServer server) {
        if (!server.isSameThread()) throw new IllegalStateException("Las herramientas del huésped usan el hilo del servidor");
        InfectedTools found = SERVICES.get(server);
        if (found != null) return found;
        try {
            found = new InfectedTools(FMLPaths.CONFIGDIR.get().resolve("raps/Tools/infected")); SERVICES.put(server, found); return found;
        } catch (IOException failure) { throw new IllegalStateException("No se pudo cargar Tools/infected; no se concedieron capacidades", failure); }
    }
    public static void tick(MinecraftServer server) {
        InfectedTools service = SERVICES.get(server);
        if (service == null && HorrorModule.findParasite(server).filter(ParasiteEntity::isAlive).isPresent()) service = get(server);
        if (service != null) service.advance(server);
    }
    public static void close(MinecraftServer server) {
        InfectedTools service = SERVICES.remove(server);
        if (service == null) return;
        if (service.active != null) service.finish(service.active, Outcome.CANCELLED, "SERVER_STOPPING");
    }
    public boolean busy() { return active != null; }
    public void cancelCurrent() { if (active != null) finish(active, Outcome.CANCELLED, "CANCELLED_BY_OPERATOR"); }

    // El autor proviene del cuerpo canónico, nunca de un UUID enviado por el modelo.
    public InfectedToolManifest propose(ParasiteEntity body, String source) {
        canonical(body);
        try { return proposeForAuthor(body.hostId().orElseThrow(), source); }
        catch (IOException failure) { throw new IllegalStateException("No se guardó la propuesta corporal", failure); }
    }
    InfectedToolManifest proposeForAuthor(UUID author, String source) throws IOException {
        InfectedToolManifest manifest = InfectedToolManifest.parse(source, author);
        validateComposition(manifest, new HashSet<>(), 0);
        String key = key(manifest.id(), manifest.version()); Learned old = entries.get(key);
        if (old != null) {
            if (!old.manifest.sha256().equals(manifest.sha256())) throw invalid("La versión ya existe con otro contenido o autor");
            return old.manifest;
        }
        if (entries.size() >= MAX_ENTRIES) throw invalid("Tools/infected alcanzó el límite de herramientas");
        Learned candidate = new Learned(manifest); entries.put(key, candidate);
        try { save(); }
        catch (IOException failure) { entries.remove(key); throw failure; }
        JsonObject readable = new JsonObject(); readable.addProperty("author", author.toString());
        readable.addProperty("sha256", manifest.sha256()); readable.add("manifest", manifest.toJson());
        try { write(directory.resolve(manifest.id() + "_v" + manifest.version() + ".json"), JSON.toJson(readable)); }
        catch (IOException failure) { LogUtils.getLogger().warn("El manifiesto corporal quedó guardado en el ledger, pero no se pudo exportar su copia {}", manifest.id(), failure); }
        log("PROPOSED", manifest, author, "Sin certificación ni ejecuciones inventadas"); return manifest;
    }
    public List<InfectedToolManifest> available(UUID actor) {
        return entries.values().stream().filter(row -> row.certified || row.manifest.author().equals(actor) && row.failures < 8)
            .map(row -> row.manifest).toList();
    }
    public Map<String, ToolCapability> catalog(UUID actor) {
        Map<String, ToolCapability> result = new LinkedHashMap<>();
        for (InfectedToolManifest manifest : available(actor)) {
            ToolCapability capability = new ToolCapability(manifest.id(), manifest.version(),
                manifest.description().substring(0, Math.min(390, manifest.description().length())) + " sha256=" + manifest.sha256()
                    + (entries.get(key(manifest.id(), manifest.version())).certified ? " CERTIFIED" : " AUTHOR_TRIAL"),
                manifest.permissions().stream().map(Enum::name).collect(java.util.stream.Collectors.toSet()));
            ToolCapability old = result.get(manifest.id());
            if (old == null || capability.version() > old.version()) result.put(manifest.id(), capability);
        }
        return Map.copyOf(result);
    }
    public Statistics statistics(String id, int version) {
        Learned row = entries.get(key(id, version));
        if (row == null) throw invalid("Herramienta corporal desconocida");
        return new Statistics(row.successes, row.failures, row.cancellations, row.reuseSuccesses, row.reuseFailures, row.certified, row.lastResult);
    }

    // Sólo una ejecución corporal. La composición se compila por completo antes de tocar el mundo.
    public CompletableFuture<Outcome> start(ParasiteEntity body, String id, int version, Map<String, String> inputs) {
        MinecraftServer server = canonical(body); UUID actor = body.hostId().orElseThrow();
        if (active != null) throw invalid("El cuerpo ya está ejecutando una herramienta");
        Learned row = entries.get(key(id, version));
        if (row == null || !row.certified && !row.manifest.author().equals(actor)) throw invalid("La herramienta no está disponible para este huésped");
        if (!row.certified && row.failures >= 8) throw invalid("La herramienta requiere una versión corregida tras ocho fallos");
        if (inputs.size() > 16) throw invalid("Demasiadas entradas corporales");
        JsonObject values = new JsonObject();
        for (var entry : inputs.entrySet()) {
            if (!entry.getKey().matches("[a-z][a-zA-Z0-9_]{0,47}") || entry.getValue().length() > 512) throw invalid("Entrada corporal inválida");
            values.addProperty(entry.getKey(), entry.getValue());
        }
        List<Compiled> plan = new ArrayList<>(); compile(row.manifest, values, plan, new HashSet<>(), 0);
        active = new Execution(row, body, actor, server.overworld().getGameTime(), List.copyOf(plan), !row.certified);
        log("STARTED", row.manifest, actor, active.id.toString());
        return active.result;
    }
    public CompletableFuture<Outcome> startLatest(ParasiteEntity body, String id) {
        InfectedToolManifest manifest = available(body.hostId().orElseThrow()).stream().filter(value -> value.id().equals(id))
            .max(Comparator.comparingInt(InfectedToolManifest::version)).orElseThrow(() -> invalid("Herramienta corporal desconocida"));
        return start(body, id, manifest.version(), Map.of());
    }
    private static MinecraftServer canonical(ParasiteEntity body) {
        if (!(body.level() instanceof ServerLevel level) || !level.getServer().isSameThread() || !body.isAlive()) throw invalid("Falta un huésped vivo del servidor");
        MinecraftServer server = level.getServer(); HorrorState state = HorrorState.get(server);
        if (!HorrorModule.config(server).enabled() || body.hostId().isEmpty() || !state.host().filter(body.hostId().get()::equals).isPresent()
                || !state.entityId().filter(body.getUUID()::equals).isPresent() || HorrorModule.findParasite(server).filter(entity -> entity == body).isEmpty())
            throw invalid("Sólo el cuerpo canónico del bot infectado puede usar Tools/infected");
        return server;
    }
    private Counts validateComposition(InfectedToolManifest manifest, Set<String> visiting, int depth) {
        if (depth > 4 || !visiting.add(manifest.sha256())) throw invalid("Dependencia circular o demasiado profunda");
        int steps = 0, ticks = 0;
        try {
            for (ToolManifest.Dependency dependency : manifest.dependencies()) certified(dependency);
            for (ToolManifest.Step step : manifest.steps()) {
                if (step.primitive() != null) {
                    InfectedToolManifest.Permission needed = step.primitive().equals("speak") ? InfectedToolManifest.Permission.BODY_VOICE : InfectedToolManifest.Permission.BODY_MOVE;
                    if (!manifest.permissions().contains(needed)) throw invalid("La primitiva amplía permisos corporales");
                    steps++; ticks += step.timeoutTicks();
                } else {
                    InfectedToolManifest child = certified(step.tool());
                    if (!manifest.permissions().containsAll(child.permissions())) throw invalid("La dependencia amplía permisos corporales");
                    Counts nested = validateComposition(child, visiting, depth + 1);
                    if (nested.ticks > step.timeoutTicks()) throw invalid("El límite del paso no alcanza para la dependencia");
                    steps += nested.steps; ticks += nested.ticks;
                }
            }
            if (steps > manifest.maxSteps() || ticks > manifest.maxTicks()) throw invalid("La composición expandida excede sus límites");
            return new Counts(steps, ticks);
        } finally { visiting.remove(manifest.sha256()); }
    }
    private InfectedToolManifest certified(ToolManifest.Dependency dependency) {
        Learned child = entries.get(key(dependency.id(), dependency.version()));
        if (child == null || !child.certified || !child.manifest.sha256().equals(dependency.sha256())) throw invalid("Dependencia corporal no certificada o hash distinto");
        return child.manifest;
    }
    private void compile(InfectedToolManifest manifest, JsonObject inputs, List<Compiled> output, Set<String> visiting, int depth) {
        validateComposition(manifest, new HashSet<>(), 0);
        if (depth > 4 || !visiting.add(manifest.sha256())) throw invalid("Dependencia circular");
        try {
            for (ToolManifest.Step step : manifest.steps()) {
                JsonObject args = InfectedToolManifest.resolve(step.arguments(), inputs);
                if (step.primitive() != null) {
                    InfectedToolManifest.validatePrimitive(step.primitive(), args, step.timeoutTicks(), false);
                    output.add(new Compiled(step.primitive(), args, step.timeoutTicks()));
                } else compile(certified(step.tool()), args, output, visiting, depth + 1);
            }
        } finally { visiting.remove(manifest.sha256()); }
        if (output.size() > InfectedToolManifest.MAX_STEPS) throw invalid("Demasiados pasos corporales expandidos");
    }

    // Tick local independiente de Luna. Cada paso conserva su plazo y un testigo del efecto real.
    void advance(MinecraftServer server) {
        if (!server.isSameThread()) throw new IllegalStateException("Ejecución corporal fuera del hilo del servidor");
        if (active != null) advanceExecution(server);
        if (active == null) automatic(server);
    }
    private void advanceExecution(MinecraftServer server) {
        Execution run = active; long time = server.overworld().getGameTime();
        try { if (canonical(run.body) != server || !run.actor.equals(run.body.hostId().orElse(null))) throw invalid("El cuerpo cambió"); }
        catch (RuntimeException missing) { finish(run, Outcome.CANCELLED, "BODY_UNAVAILABLE"); return; }
        if (time - run.started > run.row.manifest.maxTicks()) { finish(run, Outcome.FAILED, "TOOL_TIMEOUT"); return; }
        if (run.step != null) {
            long elapsed = time - run.stepStarted;
            if (elapsed > run.step.timeoutTicks) { finish(run, Outcome.FAILED, "STEP_TIMEOUT"); return; }
            boolean done;
            if (run.voice != null) {
                if (!run.voice.isDone()) return;
                boolean delivered;
                try { delivered = Boolean.TRUE.equals(run.voice.join()); }
                catch (RuntimeException failure) { delivered = false; }
                if (!delivered) { finish(run, Outcome.FAILED, "VOICE_NOT_DELIVERED"); return; }
                run.useful++; done = true;
            } else done = elapsed >= run.duration;
            if (!done) return;
            if (InfectedToolManifest.isIntent(run.step.primitive) && usefulIntent(run)) run.useful++;
            run.completed++; run.step = null; run.voice = null;
        }
        if (run.completed == run.plan.size()) {
            finish(run, run.useful > 0 ? Outcome.SUCCEEDED : Outcome.FAILED, run.useful > 0 ? "SUCCEEDED" : "NO_USEFUL_EFFECT"); return;
        }
        if (time - run.started >= run.row.manifest.maxTicks()) { finish(run, Outcome.FAILED, "TOOL_TIMEOUT"); return; }
        run.step = run.plan.get(run.completed); run.stepStarted = time; run.before = run.body.position();
        run.targetBefore = run.body.getTarget() == null ? null : run.body.getTarget().position();
        run.attacksBefore = run.body.successfulAttackCount(); run.scaresBefore = run.body.deliveredScareCount();
        if (InfectedToolManifest.isIntent(run.step.primitive)) {
            run.duration = InfectedToolManifest.integer(run.step.args.get("durationTicks"), 20, 1200);
            run.body.setIntent(ParasiteEntity.Intent.valueOf(run.step.primitive.toUpperCase(Locale.ROOT)), run.duration);
        } else if (run.step.primitive.equals("wait")) {
            run.duration = InfectedToolManifest.integer(run.step.args.get("durationTicks"), 1, 1200); run.body.pauseForTool(run.duration);
        } else if (run.step.primitive.equals("posture")) {
            int duration = run.step.args.has("durationTicks")
                ? InfectedToolManifest.integer(run.step.args.get("durationTicks"), 20, ParasiteEntity.MAX_POSTURE_TICKS)
                : ParasiteEntity.DEFAULT_POSTURE_TICKS;
            if (!run.body.requestBodyPosture(run.step.args.get("mode").getAsString(), duration)) {
                finish(run, Outcome.FAILED, "POSTURE_NO_REAL_SUPPORT"); return;
            }
            run.duration = 1;
        } else {
            run.duration = 0; run.voice = HorrorSpeech.speak(run.body, InfectedToolManifest.text(run.step.args.get("text")));
        }
    }
    private static boolean usefulIntent(Execution run) {
        if (run.body.successfulAttackCount() > run.attacksBefore || run.body.deliveredScareCount() > run.scaresBefore) return true;
        Vec3 after = run.body.position();
        if (after.distanceToSqr(run.before) <= .04) return false;
        if (run.step.primitive.equals("climb")) return after.y - run.before.y > .2;
        if (run.step.primitive.equals("retreat") || run.step.primitive.equals("ambush")) {
            if (run.targetBefore == null) return false;
            double beforeDistance = run.before.distanceTo(run.targetBefore), afterDistance = after.distanceTo(run.targetBefore);
            return run.step.primitive.equals("retreat") ? afterDistance - beforeDistance > .2 : beforeDistance - afterDistance > .2;
        }
        return true;
    }
    private void finish(Execution run, Outcome outcome, String reason) {
        if (active != run) return;
        active = null;
        if (run.voice != null && !run.voice.isDone()) run.voice.cancel(false);
        if (run.body.isAlive()) {
            run.body.pauseForTool(0);
            if (outcome != Outcome.SUCCEEDED) { run.body.getNavigation().stop(); run.body.setIntent(ParasiteEntity.Intent.STALK, 20); }
        }
        Learned row = run.row;
        if (run.trial) {
            if (outcome == Outcome.SUCCEEDED) row.successes++;
            else if (outcome == Outcome.CANCELLED) row.cancellations++;
            else row.failures++;
            row.receipts.add(new Receipt(run.id, run.actor, run.started, outcome.name(), run.completed, run.useful));
            if (row.receipts.size() > 32) {
                Receipt removable = row.receipts.stream().filter(receipt -> !receipt.result.equals("SUCCEEDED")).findFirst().orElse(row.receipts.get(0));
                row.receipts.remove(removable);
            }
            row.certified = row.successes >= 3 && (double) row.successes / (row.successes + row.failures) >= .8;
        } else if (outcome == Outcome.SUCCEEDED) row.reuseSuccesses++;
        else row.reuseFailures++;
        row.lastResult = reason;
        try { save(); log(outcome.name(), row.manifest, run.actor, run.id + " steps=" + run.completed + " useful=" + run.useful + " " + reason); }
        catch (IOException failure) {
            // Una persistencia fallida nunca entrega una nueva certificación.
            row.certified = false; outcome = Outcome.FAILED; row.lastResult = "PERSISTENCE_FAILED";
            LogUtils.getLogger().error("No se pudo guardar la ejecución corporal {}", row.manifest.id(), failure);
        }
        run.result.complete(outcome);
    }

    // Políticas retenidas y certificadas: sólo eventos reales, sin nuevas llamadas al modelo.
    private void automatic(MinecraftServer server) {
        ParasiteEntity body = HorrorModule.findParasite(server).orElse(null); if (body == null || !body.isAlive()) return;
        try { canonical(body); } catch (RuntimeException unavailable) { return; }
        ServerPlayer target = body.getTarget() instanceof ServerPlayer human && HorrorModule.mayTarget(server, human) && body.hasLineOfSight(human) ? human : null;
        UUID visible = target == null ? null : target.getUUID(); EnumSet<InfectedToolManifest.Trigger> events = EnumSet.noneOf(InfectedToolManifest.Trigger.class);
        if (!body.getUUID().equals(observedBody)) {
            observedBody = body.getUUID(); lastVisibleTarget = null; lastPosition = body.position(); lastHealth = body.getHealth(); blockedTicks = 0;
        }
        if (visible != null && !visible.equals(lastVisibleTarget)) events.add(InfectedToolManifest.Trigger.ON_TARGET_VISIBLE);
        if (body.getHealth() + .1 < lastHealth) events.add(InfectedToolManifest.Trigger.ON_DAMAGE);
        boolean blocked = target != null && (body.horizontalCollision || !body.getNavigation().isDone() && body.position().distanceToSqr(lastPosition) < .0025);
        blockedTicks = blocked ? blockedTicks + 1 : 0;
        if (blockedTicks == 40) events.add(InfectedToolManifest.Trigger.ON_BLOCKED);
        lastVisibleTarget = visible; lastPosition = body.position(); lastHealth = body.getHealth();
        long time = server.overworld().getGameTime(); if (time < nextAutomaticAt || events.isEmpty()) return;
        for (Learned row : entries.values()) if (row.certified && row.manifest.triggers().stream().anyMatch(events::contains)) {
            nextAutomaticAt = time + AUTO_COOLDOWN;
            try { start(body, row.manifest.id(), row.manifest.version(), Map.of()); log("AUTOMATIC", row.manifest, body.hostId().orElseThrow(), events.toString()); }
            catch (RuntimeException missingInputs) { LogUtils.getLogger().debug("No se inició la herramienta corporal automática {}: {}", row.manifest.id(), missingInputs.getMessage()); }
            return;
        }
    }

    // Registro autenticado. Editar contadores o manifiestos no puede aprobar herramientas.
    private void save() throws IOException {
        checkDirectory(); JsonObject content = new JsonObject(); content.addProperty("schema", 1); JsonArray tools = new JsonArray();
        for (Learned row : entries.values()) {
            JsonObject tool = new JsonObject(); tool.addProperty("author", row.manifest.author().toString()); tool.addProperty("sha256", row.manifest.sha256());
            tool.add("manifest", row.manifest.toJson()); tool.addProperty("createdAt", row.createdAt);
            tool.addProperty("successes", row.successes); tool.addProperty("failures", row.failures); tool.addProperty("cancellations", row.cancellations);
            tool.addProperty("reuseSuccesses", row.reuseSuccesses); tool.addProperty("reuseFailures", row.reuseFailures);
            tool.addProperty("certified", row.certified); tool.addProperty("lastResult", row.lastResult);
            tool.add("trials", JSON.toJsonTree(row.receipts)); tools.add(tool);
        }
        content.add("tools", tools); JsonObject envelope = new JsonObject(); envelope.add("content", content); envelope.addProperty("hmac", signature(content));
        write(directory.resolve("learning.json"), JSON.toJson(envelope));
    }
    private void restore() throws IOException {
        Path file = directory.resolve("learning.json"); if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return;
        checkFile(file, MAX_LEDGER_BYTES);
        try {
            JsonObject envelope = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
            if (!envelope.keySet().equals(Set.of("content", "hmac"))) throw invalid("Registro corporal incompatible");
            JsonObject content = envelope.getAsJsonObject("content");
            if (!MessageDigest.isEqual(signature(content).getBytes(StandardCharsets.US_ASCII), envelope.get("hmac").getAsString().getBytes(StandardCharsets.US_ASCII)))
                throw invalid("El registro corporal fue adulterado");
            if (!content.keySet().equals(Set.of("schema", "tools")) || content.get("schema").getAsInt() != 1 || content.getAsJsonArray("tools").size() > MAX_ENTRIES)
                throw invalid("Registro corporal incompatible");
            Set<UUID> receipts = new HashSet<>();
            for (JsonElement item : content.getAsJsonArray("tools")) {
                JsonObject raw = item.getAsJsonObject();
                if (!raw.keySet().equals(Set.of("author", "sha256", "manifest", "createdAt", "successes", "failures", "cancellations", "reuseSuccesses", "reuseFailures", "certified", "lastResult", "trials")))
                    throw invalid("Campos del registro corporal incompatibles");
                InfectedToolManifest manifest = InfectedToolManifest.parse(raw.get("manifest").toString(), UUID.fromString(raw.get("author").getAsString()));
                if (!manifest.sha256().equals(raw.get("sha256").getAsString()) || entries.containsKey(key(manifest.id(), manifest.version()))) throw invalid("Versión o hash corporal alterados");
                Learned row = new Learned(manifest); row.createdAt = raw.get("createdAt").getAsString();
                row.successes = counter(raw, "successes"); row.failures = counter(raw, "failures"); row.cancellations = counter(raw, "cancellations");
                row.reuseSuccesses = counter(raw, "reuseSuccesses"); row.reuseFailures = counter(raw, "reuseFailures"); row.certified = raw.get("certified").getAsBoolean(); row.lastResult = raw.get("lastResult").getAsString();
                if (raw.getAsJsonArray("trials").size() > 32) throw invalid("Demasiadas evidencias corporales");
                for (JsonElement record : raw.getAsJsonArray("trials")) {
                    Receipt receipt = JSON.fromJson(record, Receipt.class);
                    if (receipt.execution == null || !receipts.add(receipt.execution) || !manifest.author().equals(receipt.actor) || receipt.started < 0
                        || receipt.steps < 0 || receipt.steps > 16 || receipt.useful < 0 || receipt.useful > 16 || !Set.of("SUCCEEDED", "FAILED", "CANCELLED").contains(receipt.result))
                        throw invalid("Evidencia corporal inválida");
                    row.receipts.add(receipt);
                }
                if (row.certified && (row.successes < 3 || (double)row.successes / (row.successes + row.failures) < .8
                    || row.receipts.stream().filter(receipt -> receipt.result.equals("SUCCEEDED") && receipt.useful > 0).count() < 3)) throw invalid("Certificación corporal sin evidencia");
                entries.put(key(manifest.id(), manifest.version()), row);
            }
            for (Learned row : entries.values()) validateComposition(row.manifest, new HashSet<>(), 0);
        } catch (RuntimeException | StackOverflowError malformed) { entries.clear(); throw new IOException("Registro corporal inválido; no se otorgaron herramientas", malformed); }
    }
    private static long counter(JsonObject object, String key) {
        long number = object.get(key).getAsBigDecimal().longValueExact();
        if (number < 0 || number > 10_000_000) throw invalid("Contador corporal inválido"); return number;
    }
    private byte[] readOrCreateKey() throws IOException {
        Path file = directory.resolve("certification.key");
        if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
            checkFile(file, 32); byte[] key = Files.readAllBytes(file); if (key.length != 32) throw new IOException("Clave corporal inválida"); return key;
        }
        if (Files.exists(directory.resolve("learning.json"), LinkOption.NOFOLLOW_LINKS)) throw new IOException("Falta la clave del registro corporal");
        byte[] key = new byte[32]; new SecureRandom().nextBytes(key); Files.write(file, key, StandardOpenOption.CREATE_NEW); return key;
    }
    private String signature(JsonObject content) {
        try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(signingKey, "HmacSHA256"));
            return HexFormat.of().formatHex(mac.doFinal(InfectedToolManifest.canonical(content).getBytes(StandardCharsets.UTF_8)));
        } catch (GeneralSecurityException failure) { throw new IllegalStateException(failure); }
    }
    private void checkDirectory() throws IOException {
        for (Path path = directory; path != null; path = path.getParent()) if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
            BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
            if (attrs.isSymbolicLink() || attrs.isOther() || !path.toRealPath().equals(path)) throw new IOException("Tools/infected no atraviesa enlaces o redirecciones");
        }
    }
    private static void checkFile(Path file, long max) throws IOException {
        if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file) || Files.size(file) > max) throw new IOException("Archivo corporal inválido");
    }
    private void write(Path file, String source) throws IOException {
        if (!file.getParent().equals(directory) || Files.isSymbolicLink(file) || source.getBytes(StandardCharsets.UTF_8).length > MAX_LEDGER_BYTES) throw new IOException("Escritura corporal fuera de límites");
        Path temp = directory.resolve("writing_" + UUID.randomUUID() + ".tmp");
        try { Files.writeString(temp, source, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW);
            try { Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
            catch (AtomicMoveNotSupportedException unavailable) { Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); }
        } finally { Files.deleteIfExists(temp); }
    }
    private void log(String event, InfectedToolManifest manifest, UUID actor, String detail) {
        LogUtils.getLogger().info("Tools/infected {} {} v{} actor={} {}", event, manifest.id(), manifest.version(), actor, detail);
    }
    private static String key(String id, int version) { return id + ":" + version; }
    private static IllegalArgumentException invalid(String text) { return InfectedToolManifest.invalid(text); }
    public record Statistics(long successes, long failures, long cancellations, long reuseSuccesses, long reuseFailures, boolean certified, String lastResult) { }
    private record Counts(int steps, int ticks) { }
    private record Compiled(String primitive, JsonObject args, int timeoutTicks) { }
    private record Receipt(UUID execution, UUID actor, long started, String result, int steps, int useful) { }
    private static final class Learned {
        final InfectedToolManifest manifest; String createdAt = Instant.now().toString(), lastResult = "";
        long successes, failures, cancellations, reuseSuccesses, reuseFailures; boolean certified;
        final List<Receipt> receipts = new ArrayList<>();
        Learned(InfectedToolManifest manifest) { this.manifest = manifest; }
    }
    private static final class Execution {
        final UUID id = UUID.randomUUID(); final Learned row; final ParasiteEntity body; final UUID actor; final long started;
        final List<Compiled> plan; final boolean trial; final CompletableFuture<Outcome> result = new CompletableFuture<>();
        int completed, useful, duration; long stepStarted, attacksBefore, scaresBefore; Vec3 before, targetBefore; Compiled step; CompletableFuture<Boolean> voice;
        Execution(Learned row, ParasiteEntity body, UUID actor, long started, List<Compiled> plan, boolean trial) {
            this.row = row; this.body = body; this.actor = actor; this.started = started; this.plan = plan; this.trial = trial;
        }
    }
}