Volver al índice

src/main/java/ar/com/companeros/runtime/AgentRuntime.java

package ar.com.companeros.runtime;

import ar.com.companeros.action.ToolResult;
import ar.com.companeros.body.PlayerSessions;
import ar.com.companeros.decision.*;
import ar.com.companeros.identity.*;
import ar.com.companeros.memory.*;
import ar.com.companeros.social.AgentInbox;
import ar.com.companeros.tools.*;
import ar.com.companeros.world.*;
import com.google.gson.*;
import com.mojang.logging.LogUtils;
import java.util.*;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.*;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.living.LivingDeathEvent;
import net.minecraftforge.event.server.*;
import net.minecraftforge.fml.loading.FMLPaths;

/** Percepción → decisión → plan → herramientas → resultados. No contiene un guion de vida. */
public final class AgentRuntime implements AutoCloseable {
    private static final Map<MinecraftServer, AgentRuntime> SERVERS = new WeakHashMap<>();
    private static final Set<ToolPermission> PERMISSIONS = Set.copyOf(EnumSet.allOf(ToolPermission.class));
    private static boolean installed;
    private final MinecraftServer server;
    private final AgentConfig config;
    private final ToolRegistry tools;
    private final ToolLearningService learning;
    private final DecisionGateway gateway;
    private final ParasiteReasoning parasiteReasoning;
    private final String operatorInstructions;
    private final PersistentMemoryArchive archive;
    private final java.util.concurrent.ThreadPoolExecutor archiveWriter = new java.util.concurrent.ThreadPoolExecutor(
        1, 1, 0, java.util.concurrent.TimeUnit.MILLISECONDS, new java.util.concurrent.ArrayBlockingQueue<>(1), runnable -> {
            Thread thread = new Thread(runnable, "raps-memory"); thread.setDaemon(true); return thread;
        });
    private final Map<UUID, Mind> minds = new HashMap<>();
    private boolean closed;
    private static final class Mind {
        long epoch = 1, seq, lastDecisionTick = -1200, lastProgressTick;
        ServerPlayer body;
        String dimension = "";
        ToolExecution execution;
        final ArrayDeque<ToolDecision.Step> plan = new ArrayDeque<>();
        final ArrayDeque<String> recent = new ArrayDeque<>();
        String goal, trigger = "INITIAL_OBSERVATION", activity = "Evaluando entorno", failedFingerprint = "";
        UUID lastAdminMessage;
        long lastAdminOrder = -1;
        String currentFingerprint = "";
        String webQuery = "";
        int repeatedFailure;
        double progress;
        boolean pending;
        Mind(String goal) { this.goal = goal; }
    }
    private AgentRuntime(MinecraftServer server) throws java.io.IOException {
        this.server = server;
        config = AgentConfig.load(FMLPaths.CONFIGDIR.get());
        operatorInstructions = AgentInstructions.load(FMLPaths.CONFIGDIR.get().resolve("raps"));
        archive = new PersistentMemoryArchive(FMLPaths.CONFIGDIR.get().resolve("raps").resolve("memory"));
        tools = new ToolRegistry(FMLPaths.CONFIGDIR.get().resolve("raps").resolve("Tools"));
        new ar.com.companeros.suggestions.SuggestionStore(FMLPaths.CONFIGDIR.get().resolve("raps/Suggestions"));
        BuiltinTools.install(tools); learning = new ToolLearningService(tools);
        var providerConfig = config.decisionConfig();
        java.util.function.Supplier<String> credential = providerConfig.enabled()
            ? new ApiCredential(FMLPaths.CONFIGDIR.get().resolve("raps")) : () -> "";
        gateway = new DecisionGateway(providerConfig, credential, new StrategicGate(providerConfig.solEnabled()),
            BudgetLedger.file(FMLPaths.CONFIGDIR.get().resolve("raps/budget.json")));
        parasiteReasoning = new ParasiteReasoning(server, gateway, providerConfig, archive);
    }

    // Vida del servicio: el banco de GameTests controla sus propios fixtures.
    public static synchronized void install() {
        if (installed) return; installed = true;
        MinecraftForge.EVENT_BUS.addListener(AgentRuntime::started);
        MinecraftForge.EVENT_BUS.addListener(AgentRuntime::tick);
        MinecraftForge.EVENT_BUS.addListener(AgentRuntime::stopping);
        MinecraftForge.EVENT_BUS.addListener(AgentRuntime::death);
    }
    private static void started(ServerStartedEvent event) {
        if (event.getServer() instanceof net.minecraft.gametest.framework.GameTestServer) return;
        AgentRuntime runtime;
        try { runtime = get(event.getServer()); }
        catch (RuntimeException failure) {
            LogUtils.getLogger().error("No se pudo iniciar el controlador de agentes; revisá config/raps", failure);
            return;
        }
        if (runtime.config.autoConnect()) for (AgentIdentity identity : AgentRegistry.get(runtime.server).entries()) {
            if (identity.autoLogin()) try { runtime.open(identity); }
            catch (RuntimeException failure) { LogUtils.getLogger().warn("No se pudo conectar al agente {}: {}", identity.name(), failure.getMessage()); }
        }
    }
    public static java.util.concurrent.CompletionStage<DecisionResponse> appraiseUpgrade(MinecraftServer server, DecisionRequest request) {
        if (!server.isSameThread() || request.kind() != DecisionRequest.Kind.UPGRADE_APPRAISAL)
            throw new IllegalArgumentException("La tasación pertenece al servicio del servidor");
        DecisionGateway gateway = get(server).gateway;
        gateway.updateSession(request.actorUUID(), request.sessionEpoch(), request.observationSeq());
        return gateway.decide(request);
    }
    private static AgentRuntime get(MinecraftServer server) {
        if (!server.isSameThread()) throw new IllegalStateException("El runtime exige el hilo del servidor");
        AgentRuntime current = SERVERS.get(server);
        if (current == null) try { current = new AgentRuntime(server); SERVERS.put(server, current); }
        catch (java.io.IOException failure) { throw new IllegalStateException("No se pudo cargar la biblioteca de herramientas", failure); }
        return current;
    }
    private static void stopping(ServerStoppingEvent event) {
        AgentRuntime runtime = SERVERS.remove(event.getServer()); if (runtime != null) runtime.close();
    }
    private static void tick(TickEvent.ServerTickEvent event) {
        if (event.phase != TickEvent.Phase.END) return;
        AgentRuntime runtime = SERVERS.get(event.getServer()); if (runtime == null || runtime.closed) return;
        for (AgentIdentity identity : AgentRegistry.get(runtime.server).entries()) {
            if (identity.state() != AgentState.ACTIVE) continue;
            ServerPlayer body = PlayerSessions.find(runtime.server, identity.id()); if (body == null) continue;
            try { runtime.advance(identity, body); }
            catch (RuntimeException failure) {
                LogUtils.getLogger().error("Se pausó el controlador de {}", identity.name(), failure);
                Mind mind = runtime.minds.get(identity.id()); if (mind != null) runtime.invalidate(identity.id(), mind, "CONTROLLER_ERROR");
                AgentRegistry.get(runtime.server).markState(identity.id(), AgentState.PAUSED);
            }
        }
        runtime.advanceParasite();
        if (runtime.server.getTickCount() % 1200 == 0) try { runtime.tools.saveStatistics(); runtime.learning.save(); runtime.queueMemories(); }
        catch (java.io.IOException failure) { LogUtils.getLogger().warn("No se pudo guardar estadísticas de herramientas", failure); }
    }

    // Un tick local sigue el plan. La observación para el modelo sólo se prepara por eventos y cupo.
    private void advanceParasite() {
        try { parasiteReasoning.tick(); }
        catch (RuntimeException failure) { LogUtils.getLogger().error("No se pudo evaluar la intención del parásito", failure); }
    }
    private void advance(AgentIdentity identity, ServerPlayer body) {
        Mind mind = minds.computeIfAbsent(identity.id(), id -> new Mind(AgentJournal.get(server).entry(id).goal()));
        if (mind.body != body) { invalidate(identity.id(), mind, "BODY_CHANGED"); mind.body = body; }
        String dimension = body.level().dimension().location().toString();
        if (!mind.dimension.equals(dimension)) { invalidate(identity.id(), mind, "DIMENSION_CHANGED"); mind.dimension = dimension; }
        if (!body.isAlive()) return;
        long tick = server.getTickCount();
        if (tick % 20 == 0) {
            AgentInbox.Message admin = AgentInbox.get(server).pending(identity.id(), 64).stream()
                .filter(message -> message.kind() == AgentInbox.Kind.ADMIN).findFirst().orElse(null);
            long adminOrder = admin == null ? -1 : AgentInbox.get(server).deliveryOrder(identity.id(), admin.id());
            if (admin != null && adminOrder > mind.lastAdminOrder) {
                mind.lastAdminMessage = admin.id(); mind.lastAdminOrder = adminOrder; invalidate(identity.id(), mind, "ADMIN_MESSAGE_RECEIVED");
            }
            if (mind.execution != null && body.getHealth() <= 8 && !body.serverLevel().getEntitiesOfClass(Monster.class,
                    body.getBoundingBox().inflate(6), monster -> monster.isAlive() && !(monster instanceof ar.com.companeros.horror.ParasiteEntity) && body.hasLineOfSight(monster)).isEmpty())
                invalidate(identity.id(), mind, "DANGER_LOW_HEALTH");
        }
        if (mind.execution != null) {
            ToolExecution.Status status = mind.execution.tick(tick);
            if (status == ToolExecution.Status.RUNNING) {
                if (mind.execution.progress() > mind.progress) { mind.progress = mind.execution.progress(); mind.lastProgressTick = tick; }
                if (tick - mind.lastProgressTick >= 2400) invalidate(identity.id(), mind, "NO_PROGRESS");
                return;
            }
            String outcome = mind.execution.toolId() + ":" + status + ":" + mind.execution.failureReason();
            remember(mind, outcome);
            if (status == ToolExecution.Status.SUCCEEDED) { mind.repeatedFailure = 0; mind.failedFingerprint = ""; }
            else {
                mind.repeatedFailure = mind.failedFingerprint.equals(mind.currentFingerprint) ? mind.repeatedFailure + 1 : 1;
                mind.failedFingerprint = mind.currentFingerprint; mind.plan.clear(); mind.trigger = "ACTION_FAILED_RECONSIDER";
            }
            mind.execution = null;
            AgentJournal.get(server).checkpoint(identity.id(), mind.goal, outcome, mind.repeatedFailure, false);
        }
        // Reflejos breves no eligen proyectos: comer un alimento que ya se posee.
        if (body.getFoodData().getFoodLevel() <= 12 && PlayerOperations.find(body,
                stack -> stack.isEdible() && !stack.is(net.minecraft.world.item.Items.ROTTEN_FLESH)
                    && !stack.is(net.minecraft.world.item.Items.PUFFERFISH) && !stack.is(net.minecraft.world.item.Items.SPIDER_EYE)
                    && !stack.is(net.minecraft.world.item.Items.POISONOUS_POTATO)) >= 0) {
            start(identity.id(), mind, new ToolDecision.Step("eat", 1, Map.of())); return;
        }
        // Una amenaza visible cercana interrumpe el trabajo antes de replantearlo.
        if (tick % 20 == 0 && body.getHealth() <= 8 && !body.serverLevel().getEntitiesOfClass(Monster.class,
                body.getBoundingBox().inflate(6), monster -> monster.isAlive() && !(monster instanceof ar.com.companeros.horror.ParasiteEntity) && body.hasLineOfSight(monster)).isEmpty()) {
            mind.plan.clear(); mind.trigger = "DANGER_LOW_HEALTH";
        }
        if (!identity.mode().equals("autonomo")) { mind.activity = "En pausa"; return; }
        if (!mind.plan.isEmpty()) { start(identity.id(), mind, mind.plan.removeFirst()); return; }
        if (gateway.disabled()) { mind.activity = "Esperando decisión"; return; }
        if (mind.pending || tick - mind.lastDecisionTick < config.decisionConfig().minAgentIntervalSeconds() * 20L) return;
        request(identity, body, mind);
    }
    private void start(UUID id, Mind mind, ToolDecision.Step step) {
        String fingerprint = step.toolId() + ":" + step.version() + ":" + new TreeMap<>(step.arguments());
        if (mind.repeatedFailure >= 3 && fingerprint.equals(mind.failedFingerprint)) {
            mind.plan.clear(); mind.trigger = "REPEATED_FAILURE_REQUIRE_NEW_STRATEGY";
            remember(mind, "Tres fallos de la misma acción: elegir otra estrategia o volver a verificar condiciones."); return;
        }
        JsonObject inputs = inputs(step.arguments()); defaults(step.toolId(), inputs);
        try {
            if (!AgentRoles.get(server).allows(id, step.toolId())) {
                mind.plan.clear(); remember(mind, "El rol actual delega esa tarea a otro compañero."); return;
            }
            mind.execution = startExecution(id, step.toolId(), step.version(), inputs, result -> {
                    remember(mind, result.reason().name() + " " + String.join(" ", result.actualEffects()));
                    for (String effect : result.actualEffects()) if (effect.startsWith("web_query:")) {
                        mind.webQuery = effect.substring("web_query:".length()); mind.plan.clear();
                    }
                });
        } catch (IllegalArgumentException rejected) {
            mind.plan.clear(); mind.trigger = "INVALID_TOOL_ARGUMENT_RECONSIDER";
            mind.repeatedFailure = mind.failedFingerprint.equals(fingerprint) ? mind.repeatedFailure + 1 : 1;
            mind.failedFingerprint = fingerprint; remember(mind, "La herramienta rechazó los argumentos de " + step.toolId()); return;
        }
        mind.currentFingerprint = fingerprint;
        mind.activity = step.toolId(); mind.progress = 0; mind.lastProgressTick = server.getTickCount();
        AgentJournal.get(server).checkpoint(id, mind.goal, "RUNNING:" + step.toolId(), mind.repeatedFailure, true);
    }
    private void request(AgentIdentity identity, ServerPlayer body, Mind mind) {
        mind.pending = true; mind.lastDecisionTick = server.getTickCount(); mind.seq++;
        gateway.updateSession(identity.id(), mind.epoch, mind.seq);
        List<AgentInbox.Message> messages = AgentInbox.get(server).pending(identity.id(), 8);
        UUID observedAdmin = latestAdminId(server, identity.id());
        boolean research = !mind.webQuery.isBlank();
        String query = mind.webQuery; mind.webQuery = "";
        ObservationSnapshot observation = Perception.snapshot(server, body, research ? query : mind.goal, List.copyOf(mind.recent), archive);
        Map<String, ToolCapability> capabilities = new LinkedHashMap<>();
        List<ToolManifest> available = new ArrayList<>();
        tools.certifiedTools().stream().filter(tool -> tool.author().equals(new UUID(0, 1))).forEach(available::add);
        available.addAll(learning.trialTools(identity.id()));
        tools.certifiedTools().stream().filter(tool -> !tool.author().equals(new UUID(0, 1))).forEach(available::add);
        for (ToolManifest tool : available) {
            if (!AgentRoles.get(server).allows(identity.id(), tool.id()) || tool.id().equals("research_web") && (!config.webEnabled() || research)) continue;
            if (research && !Set.of("inspect", "research_recipe", "message_internal", "message_global").contains(tool.id())) continue;
            if (AgentRoles.get(server).role(identity.id()) == AgentRoles.Role.INVESTIGADOR
                    && tool.permissions().stream().anyMatch(p -> Set.of(ToolPermission.MINE, ToolPermission.PLACE, ToolPermission.CRAFT).contains(p))) continue;
            if (capabilities.size() >= 32 && !capabilities.containsKey(tool.id())) continue;
            ToolCapability previous = capabilities.get(tool.id());
            if (previous != null && previous.version() > tool.version()) continue;
            capabilities.put(tool.id(), new ToolCapability(tool.id(), tool.version(),
            capabilityDescription(tool), tool.permissions().stream().map(Enum::name).collect(java.util.stream.Collectors.toSet())));
        }
        Set<String> allowed = PERMISSIONS.stream().map(Enum::name).collect(java.util.stream.Collectors.toSet());
        String trigger = messages.isEmpty() ? mind.trigger : "MESSAGE_RECEIVED_RECONSIDER";
        DecisionRequest request = new DecisionRequest(UUID.randomUUID(), identity.id(), mind.epoch, mind.seq, trigger,
            observation, capabilities, allowed, research ? DecisionRequest.Kind.WEB_RESEARCH : DecisionRequest.Kind.ORDINARY,
            null, operatorInstructions);
        if (research) request = new DecisionRequest(request.requestId(), identity.id(), mind.epoch, mind.seq,
            "Investigar Minecraft 1.20.1: " + query, observation, capabilities, allowed, DecisionRequest.Kind.WEB_RESEARCH, null, operatorInstructions);
        final DecisionRequest sentRequest = request;
        gateway.decide(sentRequest).whenComplete((answer, failure) -> server.execute(() -> {
            if (closed || minds.get(identity.id()) != mind || sentRequest.sessionEpoch() != mind.epoch || sentRequest.observationSeq() != mind.seq) return;
            mind.pending = false;
            if (!Objects.equals(observedAdmin, latestAdminId(server, identity.id()))) {
                invalidate(identity.id(), mind, "ADMIN_MESSAGE_CHANGED_REOBSERVE"); return;
            }
            if (failure != null || answer == null || !answer.validFor(identity.id(), mind.epoch, mind.seq)) {
                remember(mind, "DECISION:" + (answer == null ? "ERROR" : answer.status())); return;
            }
            ServerPlayer current = PlayerSessions.find(server, identity.id());
            if (current != body || !current.isAlive() || !current.level().dimension().location().toString().equals(observation.dimension())
                    || AgentRegistry.get(server).find(identity.id()).orElseThrow().state() != AgentState.ACTIVE) return;
            mind.goal = answer.decision().goal(); mind.plan.addAll(answer.decision().steps()); mind.trigger = "PLAN_FINISHED";
            if (research) {
                String report = answer.decision().briefRationale() + "\nFuentes: " + String.join(" ", answer.webSources());
                if (report.length() > 2048) report = report.substring(0, 2048);
                long now = System.currentTimeMillis();
                MemoryStore.get(server).record(new MemoryRecord(sentRequest.requestId(), identity.id(), AgentRegistry.get(server).worldId(),
                    GlobalPos.of(body.level().dimension(), body.blockPosition()), now, body.serverLevel().getGameTime(), MemoryType.STRATEGY,
                    .6, .5, 0, 0, MemoryOrigin.REPORTED, "web:" + sentRequest.requestId(), List.of(), report, Set.of("investigacion", "web"), Set.of(), null, false));
                remember(mind, "Hallazgo web por verificar: " + report.substring(0, Math.min(480, report.length())));
            }
            for (AgentInbox.Message message : messages) {
                if (storeMessage(identity.id(), body, message)) AgentInbox.get(server).acknowledge(identity.id(), message.id());
            }
            AgentJournal.get(server).checkpoint(identity.id(), mind.goal, "PLAN_ACCEPTED", mind.repeatedFailure, !mind.plan.isEmpty());
        }));
    }
    private void invalidate(UUID id, Mind mind, String reason) {
        if (mind.execution != null) { mind.execution.cancel(); mind.execution = null; }
        mind.plan.clear(); mind.pending = false; mind.epoch++; mind.seq = 0;
        gateway.updateSession(id, mind.epoch, mind.seq); mind.trigger = reason; remember(mind, reason);
        AgentJournal.get(server).checkpoint(id, mind.goal, reason, mind.repeatedFailure, true);
        if (PlayerSessions.find(server, id) != null) PlayerSessions.setMovementIntent(server, id, 0, 0, false);
    }
    private static UUID latestAdminId(MinecraftServer server, UUID agent) {
        return AgentInbox.get(server).pending(agent, 64).stream().filter(m -> m.kind() == AgentInbox.Kind.ADMIN).map(AgentInbox.Message::id).findFirst().orElse(null);
    }
    private boolean storeMessage(UUID agent, ServerPlayer body, AgentInbox.Message message) {
        MemoryStore memory = MemoryStore.get(server); MemoryRecord existing = memory.find(message.id()).orElse(null);
        if (existing != null) return existing.agentId().equals(agent) && existing.content().equals(message.body())
            && existing.sourceId().equals(message.kind() + ":" + message.senderId());
        return memory.record(new MemoryRecord(message.id(), agent, memory.worldId(), null,
            message.createdAtUtc(), server.overworld().getGameTime(), MemoryType.EXPERIENCE, .5, .5, 0, 0,
            MemoryOrigin.REPORTED, message.kind() + ":" + message.senderId(), List.of(), message.body(), Set.of("message"),
            Set.of(message.senderId()), null, false));
    }
    private static void remember(Mind mind, String text) {
        if (text.length() > 512) text = text.substring(0, 512);
        mind.recent.addLast(text); while (mind.recent.size() > 8) mind.recent.removeFirst();
    }
    private static void death(LivingDeathEvent event) {
        if (!(event.getEntity() instanceof ServerPlayer body) || body.level().isClientSide) return;
        AgentRuntime runtime = SERVERS.get(body.server); if (runtime == null || !PlayerSessions.isAgent(body.server, body.getUUID())) return;
        Mind mind = runtime.minds.get(body.getUUID()); if (mind != null) runtime.invalidate(body.getUUID(), mind, "DEATH_RECOVERY");
        MemoryStore memory = MemoryStore.get(body.server); long now = System.currentTimeMillis();
        var attacker = event.getSource().getEntity();
        String who = attacker == null ? "" : " · atacante " + attacker.getName().getString() + " " + attacker.getUUID();
        memory.record(new MemoryRecord(UUID.randomUUID(), body.getUUID(), memory.worldId(), GlobalPos.of(body.level().dimension(), body.blockPosition()),
            now, body.server.overworld().getGameTime(), MemoryType.DEATH, .9, 1, now, body.server.overworld().getGameTime(), MemoryOrigin.OBSERVED,
            event.getSource().getMsgId(), List.of(), "Muerte: " + event.getSource().getMsgId() + " en " + body.blockPosition().toShortString() + who,
            Set.of("death", "danger"), attacker == null ? Set.of() : Set.of(attacker.getUUID()), null, true));
    }
    private void open(AgentIdentity identity) {
        if (ar.com.companeros.horror.HorrorModule.isInfected(server, identity.id()))
            throw new IllegalStateException("Ese jugador es el huésped del parásito.");
        if (PlayerSessions.find(server, identity.id()) != null) {
            AgentRegistry.get(server).markState(identity.id(), AgentState.ACTIVE); return;
        }
        if (PlayerSessions.players(server).size() >= config.populationLimit()) throw new IllegalStateException("Se alcanzó la población habilitada");
        GlobalPos camp = identity.camp(); var level = camp == null ? server.overworld() : server.getLevel(camp.dimension());
        if (level == null) throw new IllegalStateException("La dimensión del campamento no está disponible");
        BlockPos pos = camp == null ? level.getSharedSpawnPos() : camp.pos();
        PlayerSessions.create(server, identity.id(), identity.name(), level, pos);
        AgentRegistry.get(server).markState(identity.id(), AgentState.ACTIVE);
        Mind mind = minds.computeIfAbsent(identity.id(), id -> new Mind(AgentJournal.get(server).entry(id).goal()));
        mind.trigger = AgentJournal.get(server).entry(identity.id()).interrupted() ? "RESTART_REOBSERVE" : "INITIAL_OBSERVATION";
    }
    @Override public void close() {
        if (closed) return;
        for (var item : minds.entrySet()) invalidate(item.getKey(), item.getValue(), "SERVER_STOPPED");
        closed = true; gateway.close();
        archiveWriter.shutdown();
        try {
            if (archiveWriter.awaitTermination(30, java.util.concurrent.TimeUnit.SECONDS)) writeMemories(memorySnapshot());
            else LogUtils.getLogger().warn("El archivado sigue ocupado; SavedData conserva el estado del mundo");
            tools.saveStatistics(); learning.save();
        } catch (InterruptedException failure) { Thread.currentThread().interrupt(); }
        catch (java.io.IOException failure) { LogUtils.getLogger().warn("No se guardaron estadísticas o memoria persistente", failure); }
    }
    private Map<UUID, List<MemoryRecord>> memorySnapshot() {
        MemoryStore memory = MemoryStore.get(server);
        Map<UUID, List<MemoryRecord>> snapshot = new HashMap<>();
        for (AgentIdentity identity : AgentRegistry.get(server).entries())
            if (identity.state() != AgentState.LEGACY) snapshot.put(identity.id(), memory.recordsFor(identity.id()));
        return Map.copyOf(snapshot);
    }
    private void writeMemories(Map<UUID, List<MemoryRecord>> snapshot) throws java.io.IOException {
        for (var entry : snapshot.entrySet()) archive.sync(entry.getKey(), entry.getValue());
    }
    private void queueMemories() {
        Map<UUID, List<MemoryRecord>> snapshot = memorySnapshot();
        try { archiveWriter.execute(() -> {
            try { writeMemories(snapshot); }
            catch (java.io.IOException failure) { LogUtils.getLogger().error("No se pudo archivar memoria fuera del mundo", failure); }
        }); } catch (java.util.concurrent.RejectedExecutionException busy) {
            LogUtils.getLogger().warn("Archivador ocupado; se conserva SavedData para la siguiente sincronización");
        }
    }

    // Controles humanos. Un agente nunca hereda los permisos de su administrador.
    public static boolean isRegisteredPlayer(MinecraftServer server, String name) { return find(server, name) != null; }
    private static AgentIdentity find(MinecraftServer server, String name) {
        return AgentRegistry.get(server).entries().stream().filter(a -> a.state() != AgentState.LEGACY && a.state() != AgentState.RETIRED && a.name().equalsIgnoreCase(name)).findFirst().orElse(null);
    }
    private static boolean authorized(CommandSourceStack source, AgentIdentity identity, boolean admin) {
        if (source.getEntity() != null && (!(source.getEntity() instanceof ServerPlayer player)
                || !AgentRegistry.get(source.getServer()).isHuman(player) || PlayerSessions.isAgent(source.getServer(), player.getUUID()))) return false;
        return source.hasPermission(2) || !admin && identity != null && source.getEntity() != null && source.getEntity().getUUID().equals(identity.legacyOwner());
    }
    private static int reject(CommandSourceStack source, String text) { source.sendFailure(Component.literal(text)); return 0; }
    private static int reply(CommandSourceStack source, String text) { source.sendSuccess(() -> Component.literal(text), false); return 1; }
    public static int create(CommandSourceStack source, String name) {
        if (!authorized(source, null, true)) return reject(source, "Necesitás permiso de administrador.");
        if (!name.matches("[A-Za-z0-9_]{1,16}") || name.equalsIgnoreCase("all")) return reject(source, "Usá un nombre de hasta 16 letras, números o guiones bajos.");
        AgentRuntime runtime = get(source.getServer()); AgentRegistry registry = AgentRegistry.get(source.getServer());
        AgentIdentity previous = registry.entries().stream().filter(a -> a.name().equalsIgnoreCase(name)).findFirst().orElse(null);
        if (previous != null) return reject(source, previous.state() == AgentState.RETIRED || previous.state() == AgentState.LEGACY
            ? "Ese nombre tiene datos de un bot retirado o del prototipo. Elegí otro nombre."
            : "Ese nombre ya tiene datos guardados. Usá /Raps join bot " + previous.name() + ".");
        if (registry.entries().stream().filter(a -> a.state() != AgentState.LEGACY && a.state() != AgentState.RETIRED).count() >= runtime.config.populationLimit()) return reject(source, "Se alcanzó la población habilitada.");
        UUID id = PlayerProfileIds.offline(name); BlockPos pos;
        if (source.getServer().getPlayerList().getPlayerByName(name) != null
                || java.nio.file.Files.exists(source.getServer().getWorldPath(net.minecraft.world.level.storage.LevelResource.PLAYER_DATA_DIR).resolve(id + ".dat")))
            return reject(source, "Ese nombre ya pertenece a un jugador del mundo. Elegí otro para el bot.");
        if (source.getEntity() == null) {
            BlockPos spawn = source.getLevel().getSharedSpawnPos();
            int x = spawn.getX() + 2, z = spawn.getZ();
            // getHeight devuelve la altura mínima cuando el chunk todavía no está cargado.
            source.getLevel().getChunk(x >> 4, z >> 4);
            pos = new BlockPos(x, source.getLevel().getHeight(net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z), z);
        } else pos = BlockPos.containing(source.getPosition().add(1.5, 0, 0));
        AgentIdentity identity = registry.register(id, name, source.getEntity() == null ? null : source.getEntity().getUUID(),
            GlobalPos.of(source.getLevel().dimension(), pos), "autonomo");
        try { runtime.open(identity); return reply(source, name + " se sumó a la aventura."); }
        catch (RuntimeException failure) { registry.markState(id, AgentState.RETIRED); return reject(source, "No se pudo conectar a " + name + ": " + failure.getMessage()); }
    }
    public static int connect(CommandSourceStack source, String name) {
        if (!authorized(source, null, true)) return reject(source, "Necesitás permiso de administrador.");
        AgentIdentity identity = find(source.getServer(), name); if (identity == null) return reject(source, "No hay un bot disponible con ese nombre. Usá /Raps estado o /Raps crear <nombre>.");
        try { get(source.getServer()).open(identity); return reply(source, name + " conectado."); }
        catch (RuntimeException failure) { return reject(source, "No se pudo conectar: " + failure.getMessage()); }
    }
    public static List<String> joinableNames(MinecraftServer server) {
        return AgentRegistry.get(server).entries().stream()
            .filter(a -> a.state() != AgentState.LEGACY && a.state() != AgentState.RETIRED)
            .filter(a -> !ar.com.companeros.horror.HorrorModule.isInfected(server, a.id()))
            .map(AgentIdentity::name).toList();
    }
    public static int connectAll(CommandSourceStack source) {
        if (!authorized(source, null, true)) return reject(source, "Necesitás permiso de administrador.");
        AgentRuntime runtime = get(source.getServer());
        List<String> names = joinableNames(source.getServer());
        if (names.isEmpty()) return reject(source, "No hay bots guardados. Creá uno con /Raps crear <nombre>.");
        int joined = 0;
        for (String name : names) {
            AgentIdentity identity = find(source.getServer(), name);
            if (PlayerSessions.find(source.getServer(), identity.id()) != null) continue;
            try { runtime.open(identity); joined++; }
            catch (RuntimeException failure) { reject(source, name + ": " + failure.getMessage()); }
        }
        reply(source, "Se conectaron " + joined + " bot(s). Conectados: " + PlayerSessions.players(source.getServer()).size() + ".");
        return joined;
    }
    public static int pause(CommandSourceStack source, String name) {
        AgentIdentity identity = find(source.getServer(), name); if (identity == null || !authorized(source, identity, false)) return reject(source, "No encontré un bot que puedas pausar.");
        if (ar.com.companeros.horror.HorrorModule.isInfected(source.getServer(), identity.id())) return reject(source, "El huésped se controla mediante /Raps mimicer.");
        AgentRuntime runtime = get(source.getServer()); Mind mind = runtime.minds.get(identity.id()); if (mind != null) runtime.invalidate(identity.id(), mind, "ADMIN_PAUSED");
        AgentRegistry.get(source.getServer()).markState(identity.id(), AgentState.PAUSED); PlayerSessions.remove(source.getServer(), identity.id());
        return reply(source, name + " pausado. Su inventario está guardado.");
    }
    public static int retire(CommandSourceStack source, String name) {
        AgentIdentity identity = find(source.getServer(), name); if (identity == null || !authorized(source, identity, true)) return reject(source, "No encontré un bot que puedas retirar.");
        if (ar.com.companeros.horror.HorrorModule.isInfected(source.getServer(), identity.id())) return reject(source, "El huésped conserva su identidad durante la infección.");
        pause(source, name); AgentRegistry.get(source.getServer()).markState(identity.id(), AgentState.RETIRED); return reply(source, name + " retirado.");
    }
    public static int setMode(CommandSourceStack source, String name, String mode) {
        AgentIdentity identity = find(source.getServer(), name); if (identity == null || !authorized(source, identity, false)) return reject(source, "No encontré un compañero tuyo con ese nombre.");
        if (mode.equals("seguir")) return reject(source, "Usá /botmalo para coordinar una tarea con " + name + ".");
        AgentRuntime runtime = get(source.getServer()); Mind mind = runtime.minds.get(identity.id()); if (mind != null) runtime.invalidate(identity.id(), mind, "MODE_CHANGED");
        AgentRegistry.get(source.getServer()).updateSettings(identity.id(), identity.camp(), mode);
        return reply(source, name + ": " + mode);
    }
    public static int setRole(CommandSourceStack source, String name, AgentRoles.Role role) {
        AgentIdentity identity = find(source.getServer(), name);
        if (identity == null || !authorized(source, identity, true)) return reject(source, "Necesitás un bot registrado y permiso de administrador.");
        AgentRoles.get(source.getServer()).assign(identity.id(), role);
        AgentRuntime runtime = get(source.getServer()); Mind mind = runtime.minds.get(identity.id());
        if (mind != null) runtime.invalidate(identity.id(), mind, "ROLE_CHANGED");
        return reply(source, name + " · Rol: " + role.name().toLowerCase(Locale.ROOT));
    }
    public static int setCamp(CommandSourceStack source, String name) {
        AgentIdentity identity = find(source.getServer(), name); if (identity == null || !authorized(source, identity, false)) return reject(source, "No encontré un compañero tuyo con ese nombre.");
        GlobalPos camp = GlobalPos.of(source.getLevel().dimension(), BlockPos.containing(source.getPosition()));
        AgentRuntime runtime = get(source.getServer()); Mind mind = runtime.minds.get(identity.id()); if (mind != null) runtime.invalidate(identity.id(), mind, "CAMP_CHANGED");
        AgentRegistry.get(source.getServer()).updateSettings(identity.id(), camp, identity.mode()); return reply(source, "Campamento de " + name + " marcado.");
    }
    public static int inventory(CommandSourceStack source, String name) {
        AgentIdentity identity = find(source.getServer(), name); if (identity == null || !authorized(source, identity, false)) return reject(source, "No encontré un compañero tuyo con ese nombre.");
        ServerPlayer player = PlayerSessions.find(source.getServer(), identity.id()); if (player == null) return reject(source, "Conectá al bot para consultar su inventario.");
        List<String> entries = new ArrayList<>(); for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
            ItemStack stack = player.getInventory().getItem(i); if (!stack.isEmpty()) entries.add(stack.getCount() + " " + stack.getHoverName().getString());
        }
        return reply(source, name + ": " + (entries.isEmpty() ? "Inventario vacío" : String.join(", ", entries)));
    }
    public static int handOver(CommandSourceStack source, String name) {
        AgentIdentity identity = find(source.getServer(), name); if (identity == null || !authorized(source, identity, false)) return reject(source, "No encontré un compañero tuyo con ese nombre.");
        ServerPlayer player = PlayerSessions.find(source.getServer(), identity.id());
        if (player == null || source.getEntity() == null || player.distanceToSqr(source.getEntity()) > 16) return reject(source, "Acercate al compañero para recibir sus objetos.");
        for (int i = 0; i < 36; i++) { ItemStack stack = player.getInventory().removeItemNoUpdate(i); if (!stack.isEmpty()) player.drop(stack, false, true); }
        player.getInventory().setChanged(); return reply(source, "Objetos entregados.");
    }
    public static List<String> describe(MinecraftServer server) {
        AgentRuntime runtime = get(server); List<String> rows = new ArrayList<>();
        for (AgentIdentity identity : AgentRegistry.get(server).entries()) {
            if (identity.state() == AgentState.LEGACY || identity.state() == AgentState.RETIRED) continue;
            ServerPlayer player = PlayerSessions.find(server, identity.id()); Mind mind = runtime.minds.get(identity.id());
            boolean infected = ar.com.companeros.horror.HorrorModule.isInfected(server, identity.id());
            String activity = infected ? "Huésped · Etapa " + ar.com.companeros.horror.HorrorState.get(server).stage(server.overworld().getGameTime(), ar.com.companeros.horror.HorrorModule.config(server))
                : player == null ? "Desconectado" : mind == null ? "Evaluando entorno" : mind.activity;
            rows.add(identity.name() + " · " + activity
                + (player == null ? "" : " · Salud " + (int)player.getHealth() + "/20 · Comida " + player.getFoodData().getFoodLevel() + "/20 · " + player.blockPosition().toShortString())
                + " · Rol " + AgentRoles.get(server).role(identity.id()).name().toLowerCase(Locale.ROOT)
                + " · UUID " + identity.id() + " · Mensajes " + AgentInbox.get(server).pending(identity.id(), 64).size());
        } return List.copyOf(rows);
    }
    public static List<String> toolNames(MinecraftServer server) { return get(server).tools.certifiedTools().stream().map(t -> t.id() + " v" + t.version() + " · " + t.description()).toList(); }
    public static int proposeTool(CommandSourceStack source, String name, String json) {
        AgentIdentity identity = find(source.getServer(), name);
        if (identity == null || !authorized(source, identity, true)) return reject(source, "Necesitás un bot registrado y permiso de administrador.");
        if (ar.com.companeros.horror.HorrorModule.isInfected(source.getServer(), identity.id())) return reject(source, "El huésped ya no propone herramientas de jugador.");
        try {
            var proposal = get(source.getServer()).learning.propose(identity.id(), json);
            return reply(source, name + " propuso " + proposal.manifest().id() + " v" + proposal.manifest().version() + ". Pendiente de pruebas.");
        } catch (java.io.IOException | IllegalArgumentException failure) { return reject(source, "La propuesta no pudo validarse o guardarse."); }
    }
    public static int toolState(CommandSourceStack source, String name) {
        AgentIdentity identity = find(source.getServer(), name);
        if (identity == null || !authorized(source, identity, true)) return reject(source, "Necesitás un bot registrado y permiso de administrador.");
        AgentRuntime runtime = get(source.getServer());
        List<ToolManifest> all = new ArrayList<>(runtime.tools.certifiedTools());
        runtime.tools.pendingProposals().stream().map(ToolRegistry.Proposal::manifest).forEach(all::add);
        List<ToolManifest> own = all.stream().filter(tool -> tool.author().equals(identity.id())).limit(64).toList();
        if (own.isEmpty()) return reply(source, name + " todavía no creó herramientas.");
        for (ToolManifest tool : own) {
            var progress = runtime.learning.statistics(tool.sha256());
            reply(source, tool.id() + " v" + tool.version() + " · " + (progress.certified() ? "Certificada" : "En pruebas")
                + " · Éxitos " + progress.successes() + " · Fallos " + progress.failures() + " · Cancelaciones " + progress.cancellations());
        }
        return own.size();
    }
    public static int runTool(CommandSourceStack source, String name, String tool, String json) {
        AgentIdentity identity = find(source.getServer(), name); if (identity == null || !authorized(source, identity, true)) return reject(source, "Necesitás un bot registrado y permiso de administrador.");
        AgentRuntime runtime = get(source.getServer()); ServerPlayer player = PlayerSessions.find(source.getServer(), identity.id()); if (player == null) return reject(source, "Conectá al bot primero.");
        if (ar.com.companeros.horror.HorrorModule.isInfected(source.getServer(), identity.id())) return reject(source, "El huésped ya no usa herramientas de jugador.");
        try {
            if (json.length() > 4096) throw new IllegalArgumentException(); JsonObject parsed = JsonParser.parseString(json).getAsJsonObject(); defaults(tool, parsed);
            Mind mind = runtime.minds.computeIfAbsent(identity.id(), id -> new Mind(AgentJournal.get(runtime.server).entry(id).goal()));
            runtime.invalidate(identity.id(), mind, "ADMIN_TOOL_TEST"); mind.body = player; mind.dimension = player.level().dimension().location().toString();
            int version = runtime.toolVersion(identity.id(), tool);
            mind.execution = runtime.startExecution(identity.id(), tool, version, parsed, r -> remember(mind, r.status() + ":" + r.reason()));
            mind.activity = tool; mind.lastProgressTick = runtime.server.getTickCount(); mind.progress = 0;
            AgentJournal.get(runtime.server).checkpoint(identity.id(), mind.goal, "ADMIN_TOOL:" + tool, mind.repeatedFailure, true);
            return reply(source, name + " inició " + tool + ".");
        } catch (RuntimeException failure) { return reject(source, "La herramienta o sus argumentos no son válidos."); }
    }
    public static int setPopulationLimit(CommandSourceStack source, int value) {
        if (!authorized(source, null, true)) return reject(source, "Necesitás permiso de administrador.");
        AgentRuntime runtime = get(source.getServer());
        if (PlayerSessions.players(source.getServer()).size() > value) return reject(source, "Pausá bots antes de reducir la población.");
        runtime.config.setPopulationLimit(value); return reply(source, "Población habilitada: " + value + ".");
    }
    private static void defaults(String tool, JsonObject inputs) {
        if ((tool.equals("craft") || tool.equals("store")) && !inputs.has("count")) inputs.addProperty("count", tool.equals("craft") ? 1 : 64);
        if (tool.equals("collect") && !inputs.has("radius")) inputs.addProperty("radius", 1);
        if (tool.equals("create_banner") && !inputs.has("symbol")) inputs.addProperty("symbol", "none");
        if (tool.equals("create_banner") && !inputs.has("symbolColor")) inputs.addProperty("symbolColor", "black");
    }

    // Una propuesta sólo se prueba por su autor; las certificadas se comparten entre jugadores.
    private ToolExecution startExecution(UUID actor, String tool, int version, JsonObject inputs,
            java.util.function.Consumer<ToolResult> completed) {
        var context = new ToolRegistry.ExecutionContext(actor, server.getTickCount(), PERMISSIONS, inputs);
        var candidate = learning.pending(actor, tool, version);
        ToolManifest manifest = candidate.orElseGet(() -> tools.certifiedTools().stream()
            .filter(value -> value.id().equals(tool) && value.version() == version).findFirst()
            .orElseThrow(() -> new IllegalArgumentException("Herramienta desconocida")));
        if (AgentRoles.get(server).role(actor) == AgentRoles.Role.INVESTIGADOR
                && manifest.permissions().stream().anyMatch(permission -> Set.of(ToolPermission.MINE, ToolPermission.PLACE, ToolPermission.CRAFT).contains(permission)))
            throw new IllegalArgumentException("El investigador delega esa producción");
        return candidate.isPresent() ? learning.startTrial(candidate.get().sha256(), context, server, completed)
            : tools.start(tool, version, context, new WorldPrimitiveExecutor(server, completed, learning));
    }
    private static String capabilityDescription(ToolManifest tool) {
        String description = tool.description();
        return description.substring(0, Math.min(410, description.length())) + " sha256=" + tool.sha256();
    }
    private int toolVersion(UUID actor, String tool) {
        return java.util.stream.Stream.concat(tools.certifiedTools().stream(), learning.trialTools(actor).stream())
            .filter(manifest -> manifest.id().equals(tool)).mapToInt(ToolManifest::version).max().orElse(1);
    }
    private static JsonObject inputs(Map<String, String> arguments) {
        JsonObject inputs = new JsonObject();
        for (var entry : arguments.entrySet()) {
            if (Set.of("x", "y", "z", "count", "radius").contains(entry.getKey())) inputs.addProperty(entry.getKey(), Double.parseDouble(entry.getValue()));
            else inputs.addProperty(entry.getKey(), entry.getValue());
        } return inputs;
    }
}