Volver al índice

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

package ar.com.companeros.tools;

import ar.com.companeros.action.ToolResult;
import ar.com.companeros.body.PlayerSessions;
import ar.com.companeros.identity.AgentRoles;
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.nio.file.attribute.BasicFileAttributes;
import java.security.*;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.phys.Vec3;

/** Propuesta → validación sin efectos → prueba física → certificación compartida. */
public final class ToolLearningService {
    public static final int REQUIRED_SUCCESSES = 3;
    public static final int MAX_TRIAL_STEPS = 16;
    public static final int MAX_TRIAL_TICKS = 600;
    private static final int MAX_ENTRIES = 512;
    private static final int MAX_LEDGER_BYTES = 32 * 1024 * 1024;
    private static final UUID REVIEWER = new UUID(0, 2);
    private static final Set<String> CONTROL_PRIMITIVES = Set.of("propose_tool", "research_web");
    private static final Set<ToolPermission> PRODUCTION = Set.of(ToolPermission.MINE, ToolPermission.PLACE, ToolPermission.CRAFT);
    private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
    private final ToolRegistry registry;
    private final Path directory;
    private final byte[] signingKey;
    private final Map<String, Learned> entries = new LinkedHashMap<>();
    private final Map<UUID, Trial> active = new HashMap<>();
    private String persistenceError = "";

    public ToolLearningService(ToolRegistry registry) throws IOException {
        this.registry = Objects.requireNonNull(registry);
        directory = registry.directory();
        checkDirectory();
        signingKey = readOrCreateKey();
        restore();
        registry.loadProposals();
    }

    // El autor y las aprobaciones provienen del servidor; ningún campo JSON puede elegirlos.
    public synchronized ToolRegistry.Proposal propose(UUID actor, String source) throws IOException {
        Objects.requireNonNull(actor);
        ToolManifest candidate = ToolManifest.parse(source, actor);
        validateTrialCandidate(candidate);
        Learned previous = entries.get(candidate.sha256());
        if (previous != null) return new ToolRegistry.Proposal(previous.manifest, previous.createdAt);
        if (entries.size() >= MAX_ENTRIES || trialTools(actor).size() >= 16)
            throw ToolManifest.invalid("Límite de herramientas propuestas alcanzado");
        for (ToolManifest existing : registry.certifiedTools()) {
            if (existing.id().equals(candidate.id()) && (existing.author().equals(new UUID(0, 1))
                    || existing.version() == candidate.version()))
                throw ToolManifest.invalid("Ese identificador o versión ya pertenece a una herramienta certificada");
        }
        for (ToolRegistry.Proposal pending : registry.pendingProposals())
            if (pending.manifest().id().equals(candidate.id()) && pending.manifest().version() == candidate.version()
                    && !pending.manifest().sha256().equals(candidate.sha256()))
                throw ToolManifest.invalid("Una propuesta registrada es inmutable; elegí otra versión");
        ToolRegistry.Proposal proposal = registry.propose(actor, source);
        entries.put(candidate.sha256(), new Learned(proposal.manifest(), proposal.createdAt()));
        try { save(); }
        catch (IOException failure) { entries.remove(candidate.sha256()); throw failure; }
        return proposal;
    }

    public synchronized List<ToolManifest> trialTools(UUID actor) {
        return registry.pendingProposals().stream().map(ToolRegistry.Proposal::manifest)
            .filter(tool -> tool.author().equals(actor))
            .filter(tool -> !entries.containsKey(tool.sha256()) || !entries.get(tool.sha256()).certified)
            .filter(tool -> !entries.containsKey(tool.sha256()) || entries.get(tool.sha256()).failures < 8)
            .filter(this::isTrialCandidate)
            .sorted(Comparator.comparing(ToolManifest::id).thenComparingInt(ToolManifest::version)).toList();
    }

    public synchronized Optional<ToolManifest> pending(UUID actor, String id, int version) {
        return trialTools(actor).stream().filter(tool -> tool.id().equals(id) && tool.version() == version).findFirst();
    }

    public synchronized Statistics statistics(String hash) {
        Learned item = entries.get(hash);
        return item == null ? new Statistics(0, 0, 0, false, "")
            : new Statistics(item.successes, item.failures, item.cancellations, item.certified, item.lastResult);
    }

    public synchronized String lastPersistenceError() { return persistenceError; }

    // Una prueba pendiente usa el mismo jugador, inventario, alcance y protecciones que una acción normal.
    public synchronized ToolExecution startTrial(String hash, ToolRegistry.ExecutionContext context,
            MinecraftServer server, Consumer<ToolResult> observed) {
        if (server == null || !server.isSameThread()) throw ToolManifest.invalid("La prueba exige el hilo del servidor");
        if (context == null || hash == null || !hash.matches("[0-9a-f]{64}")) throw ToolManifest.invalid("Prueba inválida");
        if (context.startTick() != server.getTickCount()) throw ToolManifest.invalid("La prueba necesita una observación actual");
        ToolManifest manifest = trialTools(context.actorId()).stream().filter(tool -> tool.sha256().equals(hash))
            .findFirst().orElseThrow(() -> ToolManifest.invalid("Propuesta pendiente ajena o desconocida"));
        validateTrialCandidate(manifest);
        if (!context.permissions().containsAll(manifest.permissions())) throw ToolManifest.invalid("Permisos insuficientes para la prueba");
        if (AgentRoles.get(server).role(context.actorId()) == AgentRoles.Role.INVESTIGADOR
                && manifest.permissions().stream().anyMatch(PRODUCTION::contains))
            throw ToolManifest.invalid("El rol investigador delega esa producción");
        if (PlayerSessions.find(server, context.actorId()) == null) throw ToolManifest.invalid("El autor debe estar conectado");
        if (active.size() >= 4 || active.values().stream().anyMatch(trial -> trial.actor.equals(context.actorId())))
            throw ToolManifest.invalid("Ya hay una prueba activa o se alcanzó el cupo del servidor");
        registry.validateInputs(manifest, context); // Compilación y resolución: todavía no toca el mundo.
        Learned state = entries.computeIfAbsent(hash, ignored -> new Learned(manifest, Instant.now()));
        if (state.failures >= 8) throw ToolManifest.invalid("Demasiados fallos: corregí la herramienta en una versión nueva");
        Trial trial = new Trial(hash, context.actorId(), server.overworld().getGameTime());
        WorldPrimitiveExecutor physical = new WorldPrimitiveExecutor(server, result -> {
            recordEvidence(trial, server, result);
            if (observed != null) observed.accept(result);
        });
        PrimitiveExecutor witnessed = (actor, invocation, primitive, args) -> {
            ServerPlayer body = PlayerSessions.find(server, actor);
            trial.invocations.put(invocation, new Invocation(primitive, body == null ? null : body.position()));
            return physical.begin(actor, invocation, primitive, args);
        };
        ToolExecution execution = registry.startTrial(manifest, context, witnessed, status -> finish(trial, status));
        trial.execution = execution;
        active.put(execution.id(), trial);
        return execution;
    }

    /** Relectura de evidencia del servidor; no acepta un hash y un booleano enviados por el modelo. */
    public synchronized Statistics reportTrial(ToolExecution execution) {
        if (execution == null) throw ToolManifest.invalid("Falta la ejecución observada");
        Trial running = active.get(execution.id());
        if (running != null && running.execution != execution) throw ToolManifest.invalid("Ejecución ajena");
        Learned state = entries.values().stream().filter(item -> item.receipts.stream()
            .anyMatch(receipt -> receipt.execution.equals(execution.id()))).findFirst().orElse(null);
        if (state == null && running == null) throw ToolManifest.invalid("La ejecución no tiene evidencia de prueba");
        return statistics(running != null ? running.hash : state.manifest.sha256());
    }

    private synchronized void recordEvidence(Trial trial, MinecraftServer server, ToolResult result) {
        Invocation invocation = trial.invocations.get(result.actionId());
        if (invocation == null || invocation.result != null) return;
        invocation.result = result;
        if (!result.succeeded() || result.location() == null) return;
        boolean meaningful = result.inventoryDelta().values().stream().anyMatch(value -> value != 0);
        meaningful |= result.actualEffects().stream().anyMatch(effect -> effect.startsWith("mined:")
            || effect.startsWith("placed:") || effect.startsWith("attack:") || effect.startsWith("recipe=")
            || effect.matches("internal_message:[1-9][0-9]*") || effect.equals("global_published"));
        if (invocation.primitive.equals("move_to")) {
            ServerPlayer body = PlayerSessions.find(server, trial.actor);
            meaningful |= body != null && invocation.before != null && body.position().distanceToSqr(invocation.before) > .04;
        }
        if (meaningful) trial.meaningful++;
    }

    // Tres resultados útiles reales y una tasa mínima permiten compartir el hash exacto.
    private synchronized void finish(Trial trial, ToolExecution.Status status) {
        if (active.remove(trial.execution.id()) != trial) return;
        Learned state = entries.get(trial.hash);
        if (state == null) return;
        long oldSuccess = state.successes, oldFailure = state.failures, oldCancelled = state.cancellations;
        boolean oldCertified = state.certified;
        String oldResult = state.lastResult;
        boolean complete = status == ToolExecution.Status.SUCCEEDED && trial.meaningful > 0
            && trial.invocations.size() == trial.execution.completedSteps()
            && trial.invocations.values().stream().allMatch(call -> call.result != null && call.result.succeeded());
        if (complete) state.successes++;
        else if (status == ToolExecution.Status.CANCELLED) state.cancellations++;
        else state.failures++;
        state.lastResult = complete ? "SUCCEEDED" : status == ToolExecution.Status.SUCCEEDED ? "NO_USEFUL_CHANGE" : status.name();
        Receipt receipt = new Receipt(trial.execution.id(), trial.actor, trial.startedWorldTick,
            Instant.now(), state.lastResult, trial.invocations.size(), trial.meaningful);
        state.receipts.add(receipt);
        if (state.receipts.size() > 32) state.receipts.remove(0);
        state.certified = state.successes >= REQUIRED_SUCCESSES
            && (double)state.successes / (state.successes + state.failures) >= .8;
        try {
            save(); // El certificado se escribe antes de concederlo en memoria.
            if (state.certified) registry.certify(trial.hash, REVIEWER,
                new ToolRegistry.TestEvidence(trial.hash, "physical_trials_3_successes", true, Instant.now()));
        } catch (IOException | RuntimeException failure) {
            state.successes = oldSuccess; state.failures = oldFailure; state.cancellations = oldCancelled;
            state.certified = oldCertified; state.lastResult = oldResult; state.receipts.remove(receipt);
            persistenceError = "No se pudo guardar el resultado de una prueba";
            com.mojang.logging.LogUtils.getLogger().error("No se pudo persistir la prueba de {}", state.manifest.id(), failure);
        }
    }

    private void validateLearningManifest(ToolManifest manifest, Set<String> visiting) {
        if (!visiting.add(manifest.sha256())) throw ToolManifest.invalid("Dependencia circular");
        try {
            for (ToolManifest.Step step : manifest.steps()) {
                if (step.primitive() != null && CONTROL_PRIMITIVES.contains(step.primitive()))
                    throw ToolManifest.invalid("Una herramienta propuesta no puede crear herramientas ni activar consultas web");
                if (step.tool() != null) {
                    ToolManifest.Dependency dependency = step.tool();
                    ToolManifest child = registry.certifiedTools().stream().filter(tool -> tool.id().equals(dependency.id())
                        && tool.version() == dependency.version() && tool.sha256().equals(dependency.sha256())).findFirst()
                        .orElseThrow(() -> ToolManifest.invalid("Dependencia no certificada"));
                    validateLearningManifest(child, visiting);
                }
            }
        } finally { visiting.remove(manifest.sha256()); }
    }

    private static void validateTrialLimits(ToolManifest manifest) {
        if (manifest.maxTicks() > MAX_TRIAL_TICKS || manifest.maxSteps() > MAX_TRIAL_STEPS)
            throw ToolManifest.invalid("Las herramientas aprendidas admiten hasta 16 pasos y 600 ticks");
    }

    private void validateTrialCandidate(ToolManifest manifest) {
        validateTrialLimits(manifest);
        if (registry.certifiedTools().stream().anyMatch(existing -> existing.id().equals(manifest.id())
                && existing.author().equals(new UUID(0, 1))))
            throw ToolManifest.invalid("No se puede sustituir una capacidad instalada por el mod");
        validateLearningManifest(manifest, new HashSet<>());
    }

    private boolean isTrialCandidate(ToolManifest manifest) {
        try { validateTrialCandidate(manifest); return true; }
        catch (IllegalArgumentException rejected) { return false; }
    }

    // Ledger autenticado: estadísticas editadas o manifiestos sustituidos nunca conceden certificación.
    public synchronized void save() throws IOException {
        checkDirectory();
        JsonObject content = new JsonObject(); content.addProperty("schema", 1);
        JsonArray tools = new JsonArray();
        for (Learned state : entries.values()) {
            JsonObject row = new JsonObject();
            row.addProperty("author", state.manifest.author().toString());
            row.addProperty("sha256", state.manifest.sha256());
            row.add("manifest", state.manifest.toJson()); row.addProperty("createdAt", state.createdAt.toString());
            row.addProperty("successes", state.successes); row.addProperty("failures", state.failures);
            row.addProperty("cancellations", state.cancellations); row.addProperty("certified", state.certified);
            row.addProperty("lastResult", state.lastResult);
            JsonArray receipts = new JsonArray();
            for (Receipt receipt : state.receipts) {
                JsonObject result = new JsonObject(); result.addProperty("execution", receipt.execution.toString());
                result.addProperty("actor", receipt.actor.toString()); result.addProperty("startedWorldTick", receipt.startedWorldTick);
                result.addProperty("completedAt", receipt.completedAt.toString()); result.addProperty("result", receipt.result);
                result.addProperty("steps", receipt.steps); result.addProperty("usefulEffects", receipt.usefulEffects);
                receipts.add(result);
            }
            row.add("trials", receipts); tools.add(row);
        }
        content.add("tools", tools);
        JsonObject envelope = new JsonObject(); envelope.add("content", content); envelope.addProperty("hmac", signature(content));
        writeAtomically(directory.resolve("learning.json"), JSON.toJson(envelope));
        persistenceError = "";
    }

    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();
            ToolManifest.rejectUnknown(envelope, Set.of("content", "hmac"));
            JsonObject content = envelope.getAsJsonObject("content");
            if (!MessageDigest.isEqual(signature(content).getBytes(StandardCharsets.US_ASCII),
                    envelope.get("hmac").getAsString().getBytes(StandardCharsets.US_ASCII)))
                throw ToolManifest.invalid("El registro de certificación fue adulterado");
            ToolManifest.rejectUnknown(content, Set.of("schema", "tools"));
            if (content.get("schema").getAsInt() != 1 || content.getAsJsonArray("tools").size() > MAX_ENTRIES)
                throw ToolManifest.invalid("Registro de aprendizaje incompatible");
            Set<String> versions = new HashSet<>(), executionIds = new HashSet<>();
            for (JsonElement item : content.getAsJsonArray("tools")) {
                JsonObject row = item.getAsJsonObject();
                ToolManifest.rejectUnknown(row, Set.of("author", "sha256", "manifest", "createdAt", "successes",
                    "failures", "cancellations", "certified", "lastResult", "trials"));
                ToolManifest manifest = ToolManifest.parse(row.get("manifest").toString(), UUID.fromString(row.get("author").getAsString()));
                if (!manifest.sha256().equals(row.get("sha256").getAsString()) || !versions.add(manifest.id() + ":" + manifest.version()))
                    throw ToolManifest.invalid("Hash o versión alterados");
                Learned state = new Learned(manifest, Instant.parse(row.get("createdAt").getAsString()));
                validateTrialLimits(manifest);
                state.successes = nonnegative(row, "successes"); state.failures = nonnegative(row, "failures");
                state.cancellations = nonnegative(row, "cancellations"); state.lastResult = row.get("lastResult").getAsString();
                state.certified = row.get("certified").getAsBoolean();
                JsonArray receipts = row.getAsJsonArray("trials");
                if (receipts.size() > 32) throw ToolManifest.invalid("Demasiadas evidencias");
                for (JsonElement evidence : receipts) {
                    JsonObject result = evidence.getAsJsonObject();
                    UUID execution = UUID.fromString(result.get("execution").getAsString());
                    if (!executionIds.add(execution.toString())) throw ToolManifest.invalid("Prueba duplicada");
                    UUID actor = UUID.fromString(result.get("actor").getAsString());
                    if (!actor.equals(manifest.author())) throw ToolManifest.invalid("La prueba no pertenece al autor");
                    long worldTick = result.get("startedWorldTick").getAsBigDecimal().longValueExact();
                    if (worldTick < 0) throw ToolManifest.invalid("Fecha de mundo inválida");
                    state.receipts.add(new Receipt(execution, actor, worldTick,
                        Instant.parse(result.get("completedAt").getAsString()), result.get("result").getAsString(),
                        Math.toIntExact(nonnegative(result, "steps")), Math.toIntExact(nonnegative(result, "usefulEffects"))));
                }
                if (state.certified && (state.successes < REQUIRED_SUCCESSES
                        || (double)state.successes / (state.successes + state.failures) < .8
                        || state.receipts.stream().filter(receipt -> receipt.result.equals("SUCCEEDED") && receipt.usefulEffects > 0).count() < REQUIRED_SUCCESSES))
                    throw ToolManifest.invalid("Certificación sin pruebas suficientes");
                entries.put(manifest.sha256(), state);
            }
            List<Learned> unresolved = new ArrayList<>(entries.values().stream().filter(state -> state.certified).toList());
            for (int depth = 0; depth < 5 && !unresolved.isEmpty(); depth++) {
                Iterator<Learned> iterator = unresolved.iterator();
                while (iterator.hasNext()) {
                    Learned state = iterator.next();
                    try { validateLearningManifest(state.manifest, new HashSet<>()); registry.registerCertified(state.manifest); iterator.remove(); }
                    catch (IllegalArgumentException waiting) { }
                }
            }
            if (!unresolved.isEmpty()) throw ToolManifest.invalid("Dependencias certificadas no disponibles");
            for (Learned state : entries.values()) if (!state.certified) {
                validateLearningManifest(state.manifest, new HashSet<>());
                registry.propose(state.manifest.author(), state.manifest.toJson().toString());
            }
        } catch (RuntimeException | StackOverflowError failure) { throw new IOException("Registro de aprendizaje inválido; no se concedieron nuevas certificaciones", failure); }
    }

    private byte[] readOrCreateKey() throws IOException {
        Path file = directory.resolve("certification.key");
        if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
            checkFile(file, 32);
            byte[] bytes = Files.readAllBytes(file);
            if (bytes.length != 32) throw new IOException("Clave de certificación inválida");
            return bytes;
        }
        if (Files.exists(directory.resolve("learning.json"), LinkOption.NOFOLLOW_LINKS))
            throw new IOException("Falta la clave del registro de certificación existente");
        byte[] bytes = new byte[32]; new SecureRandom().nextBytes(bytes);
        Files.write(file, bytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
        return bytes;
    }

    private String signature(JsonObject content) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(signingKey, "HmacSHA256"));
            return HexFormat.of().formatHex(mac.doFinal(ToolManifest.canonical(content).getBytes(StandardCharsets.UTF_8)));
        } catch (GeneralSecurityException exception) { throw new IllegalStateException(exception); }
    }

    private void checkDirectory() throws IOException {
        for (Path current = directory; current != null; current = current.getParent()) {
            if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) continue;
            BasicFileAttributes attributes = Files.readAttributes(current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
            if (attributes.isSymbolicLink() || attributes.isOther() || !current.toRealPath().equals(current))
                throw new IOException("La carpeta Tools no puede atravesar enlaces o redirecciones");
        }
    }

    private static void checkFile(Path file, long maximum) throws IOException {
        if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file) || Files.size(file) > maximum)
            throw new IOException("Archivo de aprendizaje inválido o demasiado grande");
    }

    private void writeAtomically(Path file, String source) throws IOException {
        if (!file.normalize().getParent().equals(directory) || Files.isSymbolicLink(file)) throw new IOException("Ruta fuera de Tools");
        if (source.getBytes(StandardCharsets.UTF_8).length > MAX_LEDGER_BYTES) throw new IOException("Registro de aprendizaje lleno");
        Path temporary = directory.resolve("learning_write_" + UUID.randomUUID() + ".tmp");
        try {
            Files.writeString(temporary, source, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
            try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
            catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); }
        } finally { Files.deleteIfExists(temporary); }
    }

    private static long nonnegative(JsonObject row, String key) {
        long number = row.get(key).getAsBigDecimal().longValueExact();
        if (number < 0 || number > 1_000_000) throw ToolManifest.invalid("Contador fuera del límite");
        return number;
    }

    public record Statistics(long successes, long failures, long cancellations, boolean certified, String lastResult) { }
    private static final class Learned {
        final ToolManifest manifest; final Instant createdAt; final List<Receipt> receipts = new ArrayList<>();
        long successes, failures, cancellations; boolean certified; String lastResult = "";
        Learned(ToolManifest manifest, Instant createdAt) { this.manifest = manifest; this.createdAt = createdAt; }
    }
    private record Receipt(UUID execution, UUID actor, long startedWorldTick, Instant completedAt, String result, int steps, int usefulEffects) { }
    private static final class Invocation {
        final String primitive; final Vec3 before; ToolResult result;
        Invocation(String primitive, Vec3 before) { this.primitive = primitive; this.before = before; }
    }
    private static final class Trial {
        final String hash; final UUID actor; final long startedWorldTick; final Map<UUID, Invocation> invocations = new LinkedHashMap<>();
        ToolExecution execution; int meaningful;
        Trial(String hash, UUID actor, long startedWorldTick) { this.hash = hash; this.actor = actor; this.startedWorldTick = startedWorldTick; }
    }
}